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 <cursoragent@cursor.com>
This commit is contained in:
@@ -5,4 +5,6 @@ server/public/uploads/
|
|||||||
server/data/db.json
|
server/data/db.json
|
||||||
server/data/*.tmp
|
server/data/*.tmp
|
||||||
.env
|
.env
|
||||||
|
server/.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ npm test # 接口冒烟测试(使用临时数据库,不影响真
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `PORT` | 端口,默认 3000 |
|
| `PORT` | 端口,默认 3000 |
|
||||||
| `WX_APPID` / `WX_SECRET` | 配置后走真实 `code2session` 登录;不配置则为演示登录 |
|
| `WX_APPID` / `WX_SECRET` | 配置后走真实 `code2session` 登录;不配置则为演示登录 |
|
||||||
|
| `WX_SUBSCRIBE_TMPL_ID` | 可选,订阅消息模板 ID(也可在管理后台「提醒设置」里填) |
|
||||||
|
| `WX_MINI_STATE` | 订阅消息跳转版本:`formal` / `trial` / `developer`,默认 formal |
|
||||||
| `DB_FILE` | 数据文件路径,默认 `server/data/db.json` |
|
| `DB_FILE` | 数据文件路径,默认 `server/data/db.json` |
|
||||||
| `UPLOAD_DIR` | 上传目录,默认 `server/public/uploads` |
|
| `UPLOAD_DIR` | 上传目录,默认 `server/public/uploads` |
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"pages/detail/detail"
|
"pages/detail/detail"
|
||||||
],
|
],
|
||||||
"window": {
|
"window": {
|
||||||
"navigationBarBackgroundColor": "#FFFFFF",
|
"navigationBarBackgroundColor": "#0D8B87",
|
||||||
"navigationBarTitleText": "卓米会",
|
"navigationBarTitleText": "卓米会",
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "white",
|
||||||
"backgroundColor": "#F3F5F7",
|
"backgroundColor": "#F3F5F7",
|
||||||
"backgroundTextStyle": "dark"
|
"backgroundTextStyle": "dark"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Component({
|
|||||||
activity(activity) {
|
activity(activity) {
|
||||||
const item = activity || {}
|
const item = activity || {}
|
||||||
this.setData({
|
this.setData({
|
||||||
poster: media.absUrl(item.poster || item.cover),
|
poster: media.absUrl(item.cover || item.poster),
|
||||||
video: media.absUrl(item.video),
|
video: media.absUrl(item.video),
|
||||||
ended: item.status === 'past'
|
ended: item.status === 'past'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"component": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<view class="root {{show ? 'on' : ''}}" wx:if="{{show}}" catchtouchmove="noop">
|
||||||
|
<view class="mask" bindtap="onCancel"></view>
|
||||||
|
<view class="sheet" catchtap="noop">
|
||||||
|
<view class="handle"></view>
|
||||||
|
<text class="title">活动报名</text>
|
||||||
|
<text class="subtitle">填写信息提交后,将推送微信服务通知</text>
|
||||||
|
|
||||||
|
<view class="notice">
|
||||||
|
<text class="notice-icon">📣</text>
|
||||||
|
<text class="notice-text">{{remindReady ? '提交后将申请活动提醒授权,同意即可在活动开始前收到微信通知' : '提交即视为同意接收活动提醒(管理员需先在后台配置订阅消息模板)'}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<view class="label-row">
|
||||||
|
<text class="label">姓名</text>
|
||||||
|
<text class="required">*</text>
|
||||||
|
</view>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
placeholder="请输入姓名"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
value="{{name}}"
|
||||||
|
maxlength="20"
|
||||||
|
data-field="name"
|
||||||
|
bindinput="onInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<view class="label-row">
|
||||||
|
<text class="label">手机号码</text>
|
||||||
|
<text class="required">*</text>
|
||||||
|
</view>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
value="{{phone}}"
|
||||||
|
maxlength="11"
|
||||||
|
data-field="phone"
|
||||||
|
bindinput="onInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">公司名称</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
placeholder="选填"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
value="{{company}}"
|
||||||
|
maxlength="40"
|
||||||
|
data-field="company"
|
||||||
|
bindinput="onInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="label">职位</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
placeholder="选填"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
value="{{position}}"
|
||||||
|
maxlength="40"
|
||||||
|
data-field="position"
|
||||||
|
bindinput="onInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="btn primary {{submitting ? 'busy' : ''}}" bindtap="onSubmit">
|
||||||
|
{{submitting ? '提交中…' : '提交报名'}}
|
||||||
|
</view>
|
||||||
|
<view class="btn ghost" bindtap="onCancel">取消</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/** 后端地址。上线改为 HTTPS 域名,并加入小程序 request / downloadFile 合法域名。 */
|
/** 后端地址。上线域名需加入小程序 request / uploadFile / downloadFile 合法域名。 */
|
||||||
const BASE = 'http://127.0.0.1:3000'
|
const BASE = 'https://dev.zmh.zm.luckypay.cc'
|
||||||
|
|
||||||
module.exports = { BASE }
|
module.exports = { BASE: BASE }
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const activityUtil = require('../../utils/activity')
|
|||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
activity: null,
|
activity: null,
|
||||||
failed: false
|
failed: false,
|
||||||
|
signupOpen: false
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad(query) {
|
onLoad(query) {
|
||||||
@@ -36,8 +37,13 @@ Page({
|
|||||||
activityUtil.openMap(this.data.activity)
|
activityUtil.openMap(this.data.activity)
|
||||||
},
|
},
|
||||||
|
|
||||||
goSignup() {
|
openSignup() {
|
||||||
activityUtil.goSignup(this.data.activity)
|
if (!this.data.activity || this.data.activity.status === 'past') return
|
||||||
|
this.setData({ signupOpen: true })
|
||||||
|
},
|
||||||
|
|
||||||
|
closeSignup() {
|
||||||
|
this.setData({ signupOpen: false })
|
||||||
},
|
},
|
||||||
|
|
||||||
goHome() {
|
goHome() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"navigationBarTitleText": "卓米会",
|
"navigationBarTitleText": "卓米会",
|
||||||
"usingComponents": {
|
"usingComponents": {
|
||||||
"activity-board": "/components/activity-board/index"
|
"activity-board": "/components/activity-board/index",
|
||||||
|
"signup-sheet": "/components/signup-sheet/index"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
<block wx:if="{{activity}}">
|
||||||
<activity-board
|
<activity-board
|
||||||
wx:if="{{activity}}"
|
|
||||||
activity="{{activity}}"
|
activity="{{activity}}"
|
||||||
show-past="{{false}}"
|
show-past="{{false}}"
|
||||||
bind:map="openMap"
|
bind:map="openMap"
|
||||||
bind:signup="goSignup"
|
bind:signup="openSignup"
|
||||||
/>
|
/>
|
||||||
|
<signup-sheet
|
||||||
|
show="{{signupOpen}}"
|
||||||
|
activity-id="{{activity.id}}"
|
||||||
|
bind:close="closeSignup"
|
||||||
|
/>
|
||||||
|
</block>
|
||||||
<view wx:elif="{{failed}}" class="empty">
|
<view wx:elif="{{failed}}" class="empty">
|
||||||
<text>活动不存在或已下线</text>
|
<text>活动不存在或已下线</text>
|
||||||
<view class="home" bindtap="goHome">回到首页</view>
|
<view class="home" bindtap="goHome">回到首页</view>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const fallback = require('../../utils/fallback')
|
|||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
activity: fallback.CURRENT
|
activity: fallback.CURRENT,
|
||||||
|
signupOpen: false
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad(query) {
|
onLoad(query) {
|
||||||
@@ -34,8 +35,14 @@ Page({
|
|||||||
activityUtil.openMap(this.data.activity)
|
activityUtil.openMap(this.data.activity)
|
||||||
},
|
},
|
||||||
|
|
||||||
goSignup() {
|
openSignup() {
|
||||||
activityUtil.goSignup(this.data.activity)
|
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() {
|
goPast() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"navigationBarTitleText": "卓米会",
|
"navigationBarTitleText": "卓米会",
|
||||||
"enablePullDownRefresh": true,
|
"enablePullDownRefresh": true,
|
||||||
"usingComponents": {
|
"usingComponents": {
|
||||||
"activity-board": "/components/activity-board/index"
|
"activity-board": "/components/activity-board/index",
|
||||||
|
"signup-sheet": "/components/signup-sheet/index"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
activity="{{activity}}"
|
activity="{{activity}}"
|
||||||
show-past="{{true}}"
|
show-past="{{true}}"
|
||||||
bind:map="openMap"
|
bind:map="openMap"
|
||||||
bind:signup="goSignup"
|
bind:signup="openSignup"
|
||||||
bind:past="goPast"
|
bind:past="goPast"
|
||||||
/>
|
/>
|
||||||
|
<signup-sheet
|
||||||
|
show="{{signupOpen}}"
|
||||||
|
activity-id="{{activity.id}}"
|
||||||
|
bind:close="closeSignup"
|
||||||
|
/>
|
||||||
|
|||||||
@@ -12,12 +12,29 @@ function chooseOne(mediaType) {
|
|||||||
success: (res) => {
|
success: (res) => {
|
||||||
const file = res.tempFiles && res.tempFiles[0]
|
const file = res.tempFiles && res.tempFiles[0]
|
||||||
if (file && file.tempFilePath) resolve(file.tempFilePath)
|
if (file && file.tempFilePath) resolve(file.tempFilePath)
|
||||||
|
else reject(new Error('未选择文件'))
|
||||||
},
|
},
|
||||||
fail: reject
|
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) {
|
function uploadWithLoading(filePath) {
|
||||||
wx.showLoading({ title: '上传中', mask: true })
|
wx.showLoading({ title: '上传中', mask: true })
|
||||||
return api.uploadMedia(filePath).then(
|
return api.uploadMedia(filePath).then(
|
||||||
@@ -71,6 +88,7 @@ Page({
|
|||||||
|
|
||||||
pickCover() {
|
pickCover() {
|
||||||
chooseOne('image')
|
chooseOne('image')
|
||||||
|
.then(cropCover)
|
||||||
.then(uploadWithLoading)
|
.then(uploadWithLoading)
|
||||||
.then((data) => this.setData({ cover: data.url, coverPreview: media.absUrl(data.url) }))
|
.then((data) => this.setData({ cover: data.url, coverPreview: media.absUrl(data.url) }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ const activityUtil = require('../../utils/activity')
|
|||||||
|
|
||||||
function decorate(item) {
|
function decorate(item) {
|
||||||
const cover = item.cover || item.poster || ''
|
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({
|
Page({
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<view class="btn del {{deleting ? 'active' : ''}}" bindtap="onDelete">{{deleting ? '完成' : '删除'}}</view>
|
<view class="btn del {{deleting ? 'active' : ''}}" bindtap="onDelete">{{deleting ? '完成' : '删除'}}</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="section-head">
|
||||||
|
<view class="section-bar"></view>
|
||||||
|
<text class="section-title">往期活动</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view wx:if="{{loaded && !list.length}}" class="empty">还没有往期活动</view>
|
<view wx:if="{{loaded && !list.length}}" class="empty">还没有往期活动</view>
|
||||||
|
|
||||||
<view
|
<view
|
||||||
|
|||||||
@@ -5,7 +5,28 @@
|
|||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
margin-bottom: 4rpx;
|
margin-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-bar {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
background: #0d8b87;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@@ -35,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
margin-top: 160rpx;
|
margin-top: 120rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
|
|||||||
@@ -1,71 +1,15 @@
|
|||||||
const api = require('../../utils/api')
|
|
||||||
const authUtil = require('../../utils/auth')
|
|
||||||
const activityUtil = require('../../utils/activity')
|
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
name: '',
|
activityId: ''
|
||||||
phone: '',
|
|
||||||
company: '',
|
|
||||||
submitting: false
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad(query) {
|
onLoad(query) {
|
||||||
this.activityId = (query && query.id) || ''
|
this.setData({ 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) {
|
onClose() {
|
||||||
this.setData({ name: event.detail.value })
|
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/index/index' }) })
|
||||||
},
|
},
|
||||||
|
|
||||||
onPhone(event) {
|
onSuccess() {}
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"navigationBarTitleText": "立即报名"
|
"navigationBarTitleText": "活动报名",
|
||||||
|
"usingComponents": {
|
||||||
|
"signup-sheet": "/components/signup-sheet/index"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
<view class="page">
|
<signup-sheet
|
||||||
<view class="card">
|
show="{{true}}"
|
||||||
<text class="label">姓名</text>
|
activity-id="{{activityId}}"
|
||||||
<input class="input" placeholder="怎么称呼你" placeholder-class="placeholder" value="{{name}}" maxlength="20" bindinput="onName" />
|
bind:close="onClose"
|
||||||
<text class="label">手机号</text>
|
bind:success="onSuccess"
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -1,52 +1,3 @@
|
|||||||
.page {
|
page {
|
||||||
padding: 28rpx 32rpx 48rpx;
|
background: transparent;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,12 +45,13 @@
|
|||||||
},
|
},
|
||||||
"compileType": "miniprogram",
|
"compileType": "miniprogram",
|
||||||
"libVersion": "3.8.10",
|
"libVersion": "3.8.10",
|
||||||
"appid": "wx5f75e1013b7b5a31",
|
"appid": "wxf84a8a74ac7c1a7d",
|
||||||
"projectname": "zhuomi-mp",
|
"projectname": "zhuomi-mp",
|
||||||
"condition": {},
|
"condition": {},
|
||||||
"editorSetting": {
|
"editorSetting": {
|
||||||
"tabIndent": "insertSpaces",
|
"tabIndent": "insertSpaces",
|
||||||
"tabSize": 2
|
"tabSize": 2
|
||||||
},
|
},
|
||||||
"simulatorPluginLibVersion": {}
|
"simulatorPluginLibVersion": {},
|
||||||
|
"isGameTourist": false
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ function goSignup(activity) {
|
|||||||
function shareMessage(activity, fallbackId) {
|
function shareMessage(activity, fallbackId) {
|
||||||
const item = activity || {}
|
const item = activity || {}
|
||||||
const openid = wx.getStorageSync('openid') || ''
|
const openid = wx.getStorageSync('openid') || ''
|
||||||
const cover = item.poster || item.cover
|
const cover = item.cover || item.poster
|
||||||
const message = {
|
const message = {
|
||||||
title: item.title || '卓米会',
|
title: item.title || '卓米会',
|
||||||
path: '/pages/detail/detail?id=' + (item.id || fallbackId || '') + '&sharer=' + openid
|
path: '/pages/detail/detail?id=' + (item.id || fallbackId || '') + '&sharer=' + openid
|
||||||
|
|||||||
@@ -38,5 +38,6 @@ module.exports = {
|
|||||||
|
|
||||||
signup: (data) => authed('/api/signup', 'POST', data),
|
signup: (data) => authed('/api/signup', 'POST', data),
|
||||||
mySignups: () => authed('/api/signup/mine'),
|
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' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
WX_APPID=你的小程序AppID
|
||||||
|
WX_SECRET=你的小程序AppSecret
|
||||||
|
# WX_SUBSCRIBE_TMPL_ID=订阅消息模板ID
|
||||||
|
# PORT=3000
|
||||||
+15
-1
@@ -33,7 +33,11 @@ const SEED = {
|
|||||||
],
|
],
|
||||||
posters: [],
|
posters: [],
|
||||||
signups: [],
|
signups: [],
|
||||||
checkinCodes: []
|
checkinCodes: [],
|
||||||
|
settings: {
|
||||||
|
// 微信公众平台 → 订阅消息 → 选用「活动提醒」类模板后填入模板 ID
|
||||||
|
subscribeTmplId: process.env.WX_SUBSCRIBE_TMPL_ID || ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLLECTIONS = ['users', 'admins', 'adminAllowlist', 'activities', 'posters', 'signups', 'checkinCodes']
|
const COLLECTIONS = ['users', 'admins', 'adminAllowlist', 'activities', 'posters', 'signups', 'checkinCodes']
|
||||||
@@ -49,6 +53,16 @@ function normalize(data) {
|
|||||||
dirty = true
|
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.
|
// The allowlist is the only source of truth for mini-program admins.
|
||||||
data.users.forEach((user) => {
|
data.users.forEach((user) => {
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ function publicActivity(activity, posters) {
|
|||||||
theme: activity.theme || 0,
|
theme: activity.theme || 0,
|
||||||
cover: activity.cover || '',
|
cover: activity.cover || '',
|
||||||
video: activity.video || '',
|
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
|
createdAt: activity.createdAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-6
@@ -1,10 +1,14 @@
|
|||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const { HttpError } = require('./http')
|
const { HttpError } = require('./http')
|
||||||
|
|
||||||
|
let tokenCache = null
|
||||||
|
|
||||||
|
function wxConfigured() {
|
||||||
|
return !!(process.env.WX_APPID && process.env.WX_SECRET)
|
||||||
|
}
|
||||||
|
|
||||||
async function code2session(code, deviceId) {
|
async function code2session(code, deviceId) {
|
||||||
const appid = process.env.WX_APPID
|
if (!wxConfigured()) {
|
||||||
const secret = process.env.WX_SECRET
|
|
||||||
if (!appid || !secret) {
|
|
||||||
const seed = deviceId || 'guest'
|
const seed = deviceId || 'guest'
|
||||||
return {
|
return {
|
||||||
openid: `demo_${crypto.createHash('sha1').update(seed).digest('hex').slice(0, 16)}`,
|
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')
|
const url = new URL('https://api.weixin.qq.com/sns/jscode2session')
|
||||||
url.search = new URLSearchParams({
|
url.search = new URLSearchParams({
|
||||||
appid,
|
appid: process.env.WX_APPID,
|
||||||
secret,
|
secret: process.env.WX_SECRET,
|
||||||
js_code: code,
|
js_code: code,
|
||||||
grant_type: 'authorization_code'
|
grant_type: 'authorization_code'
|
||||||
}).toString()
|
}).toString()
|
||||||
@@ -31,4 +35,66 @@ async function code2session(code, deviceId) {
|
|||||||
return { openid: session.openid, demo: false }
|
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
|
||||||
|
}
|
||||||
|
|||||||
+241
-20
@@ -35,10 +35,51 @@
|
|||||||
.split { display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; }
|
.split { display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; }
|
||||||
.error { color: #dc2626; min-height: 1.2em; font-size: 13px; }
|
.error { color: #dc2626; min-height: 1.2em; font-size: 13px; }
|
||||||
img.qr { width: 140px; height: 140px; }
|
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; } }
|
@media (max-width: 800px) { .grid, .split { grid-template-columns: 1fr; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="crop-modal" id="cropModal">
|
||||||
|
<div class="crop-panel">
|
||||||
|
<strong>裁剪封面</strong>
|
||||||
|
<p class="muted">拖动图片选择区域,比例已锁定为小程序首页封面(约 2.3:1)。裁剪后再上传,预览就不会被裁掉重要内容。</p>
|
||||||
|
<div class="crop-stage" id="cropStage">
|
||||||
|
<img id="cropImage" alt="裁剪预览" draggable="false" />
|
||||||
|
<div class="crop-mask" id="cropMask"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:12px">
|
||||||
|
<button class="ghost" id="cropZoomOut" type="button">缩小</button>
|
||||||
|
<button class="ghost" id="cropZoomIn" type="button">放大</button>
|
||||||
|
<button class="ghost" id="cropCancel" type="button">取消</button>
|
||||||
|
<button class="primary" id="cropConfirm" type="button">裁剪并上传</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="login" id="loginView">
|
<div class="login" id="loginView">
|
||||||
<form class="panel" id="loginForm">
|
<form class="panel" id="loginForm">
|
||||||
<h1>卓米会后台</h1>
|
<h1>卓米会后台</h1>
|
||||||
@@ -67,6 +108,7 @@
|
|||||||
<button type="button" data-tab="signups">报名</button>
|
<button type="button" data-tab="signups">报名</button>
|
||||||
<button type="button" data-tab="checkin">签到码</button>
|
<button type="button" data-tab="checkin">签到码</button>
|
||||||
<button type="button" data-tab="staff">管理员</button>
|
<button type="button" data-tab="staff">管理员</button>
|
||||||
|
<button type="button" data-tab="settings">提醒设置</button>
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
<section id="stats"></section>
|
<section id="stats"></section>
|
||||||
@@ -75,6 +117,7 @@
|
|||||||
<section id="signups" hidden></section>
|
<section id="signups" hidden></section>
|
||||||
<section id="checkin" hidden></section>
|
<section id="checkin" hidden></section>
|
||||||
<section id="staff" hidden></section>
|
<section id="staff" hidden></section>
|
||||||
|
<section id="settings" hidden></section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
@@ -109,17 +152,164 @@
|
|||||||
return api('/api/admin/upload-file', { method: 'POST', body: form })
|
return api('/api/admin/upload-file', { method: 'POST', body: form })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Banner on the mini program is ~750×320, keep the same aspect when cropping covers.
|
||||||
|
const COVER_RATIO = 750 / 320
|
||||||
|
const cropState = {
|
||||||
|
targetInput: null,
|
||||||
|
hint: null,
|
||||||
|
naturalW: 0,
|
||||||
|
naturalH: 0,
|
||||||
|
scale: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
dragging: false,
|
||||||
|
lastX: 0,
|
||||||
|
lastY: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutCrop() {
|
||||||
|
const stage = document.getElementById('cropStage')
|
||||||
|
const img = document.getElementById('cropImage')
|
||||||
|
const mask = document.getElementById('cropMask')
|
||||||
|
if (!stage || !img.naturalWidth) return
|
||||||
|
const sw = stage.clientWidth
|
||||||
|
const sh = stage.clientHeight
|
||||||
|
let maskW = sw - 32
|
||||||
|
let maskH = maskW / COVER_RATIO
|
||||||
|
if (maskH > sh - 32) {
|
||||||
|
maskH = sh - 32
|
||||||
|
maskW = maskH * COVER_RATIO
|
||||||
|
}
|
||||||
|
mask.style.width = `${maskW}px`
|
||||||
|
mask.style.height = `${maskH}px`
|
||||||
|
mask.style.left = `${(sw - maskW) / 2}px`
|
||||||
|
mask.style.top = `${(sh - maskH) / 2}px`
|
||||||
|
|
||||||
|
img.style.width = `${cropState.naturalW * cropState.scale}px`
|
||||||
|
img.style.height = `${cropState.naturalH * cropState.scale}px`
|
||||||
|
img.style.transform = `translate(${cropState.x}px, ${cropState.y}px)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitCropImage() {
|
||||||
|
const stage = document.getElementById('cropStage')
|
||||||
|
const maskW = Math.min(stage.clientWidth - 32, (stage.clientHeight - 32) * COVER_RATIO)
|
||||||
|
const maskH = maskW / COVER_RATIO
|
||||||
|
const scale = Math.max(maskW / cropState.naturalW, maskH / cropState.naturalH)
|
||||||
|
cropState.scale = scale
|
||||||
|
cropState.x = (stage.clientWidth - cropState.naturalW * scale) / 2
|
||||||
|
cropState.y = (stage.clientHeight - cropState.naturalH * scale) / 2
|
||||||
|
layoutCrop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCoverCrop(file, targetInput, hint) {
|
||||||
|
const modal = document.getElementById('cropModal')
|
||||||
|
const img = document.getElementById('cropImage')
|
||||||
|
cropState.targetInput = targetInput
|
||||||
|
cropState.hint = hint
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
img.onload = () => {
|
||||||
|
cropState.naturalW = img.naturalWidth
|
||||||
|
cropState.naturalH = img.naturalHeight
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
fitCropImage()
|
||||||
|
modal.classList.add('on')
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCoverCrop() {
|
||||||
|
document.getElementById('cropModal').classList.remove('on')
|
||||||
|
document.getElementById('cropImage').removeAttribute('src')
|
||||||
|
cropState.targetInput = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCroppedFile() {
|
||||||
|
const stage = document.getElementById('cropStage')
|
||||||
|
const mask = document.getElementById('cropMask')
|
||||||
|
const img = document.getElementById('cropImage')
|
||||||
|
const maskRect = mask.getBoundingClientRect()
|
||||||
|
const stageRect = stage.getBoundingClientRect()
|
||||||
|
const maskLeft = maskRect.left - stageRect.left
|
||||||
|
const maskTop = maskRect.top - stageRect.top
|
||||||
|
const sx = (maskLeft - cropState.x) / cropState.scale
|
||||||
|
const sy = (maskTop - cropState.y) / cropState.scale
|
||||||
|
const sw = maskRect.width / cropState.scale
|
||||||
|
const sh = maskRect.height / cropState.scale
|
||||||
|
const outW = 1500
|
||||||
|
const outH = Math.round(outW / COVER_RATIO)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = outW
|
||||||
|
canvas.height = outH
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.fillStyle = '#111827'
|
||||||
|
ctx.fillRect(0, 0, outW, outH)
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outW, outH)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
resolve(new File([blob], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' }))
|
||||||
|
}, 'image/jpeg', 0.92)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
;(function bindCropUi() {
|
||||||
|
const stage = document.getElementById('cropStage')
|
||||||
|
const img = document.getElementById('cropImage')
|
||||||
|
stage.addEventListener('pointerdown', (event) => {
|
||||||
|
cropState.dragging = true
|
||||||
|
cropState.lastX = event.clientX
|
||||||
|
cropState.lastY = event.clientY
|
||||||
|
stage.setPointerCapture(event.pointerId)
|
||||||
|
})
|
||||||
|
stage.addEventListener('pointermove', (event) => {
|
||||||
|
if (!cropState.dragging) return
|
||||||
|
cropState.x += event.clientX - cropState.lastX
|
||||||
|
cropState.y += event.clientY - cropState.lastY
|
||||||
|
cropState.lastX = event.clientX
|
||||||
|
cropState.lastY = event.clientY
|
||||||
|
layoutCrop()
|
||||||
|
})
|
||||||
|
stage.addEventListener('pointerup', () => { cropState.dragging = false })
|
||||||
|
stage.addEventListener('pointercancel', () => { cropState.dragging = false })
|
||||||
|
document.getElementById('cropZoomIn').onclick = () => {
|
||||||
|
cropState.scale *= 1.12
|
||||||
|
layoutCrop()
|
||||||
|
}
|
||||||
|
document.getElementById('cropZoomOut').onclick = () => {
|
||||||
|
cropState.scale /= 1.12
|
||||||
|
layoutCrop()
|
||||||
|
}
|
||||||
|
document.getElementById('cropCancel').onclick = closeCoverCrop
|
||||||
|
document.getElementById('cropConfirm').onclick = async () => {
|
||||||
|
const hint = cropState.hint
|
||||||
|
const input = cropState.targetInput
|
||||||
|
try {
|
||||||
|
if (hint) hint.textContent = '裁剪上传中...'
|
||||||
|
const file = await exportCroppedFile()
|
||||||
|
closeCoverCrop()
|
||||||
|
const uploaded = await uploadMedia(file)
|
||||||
|
if (input) input.value = uploaded.url
|
||||||
|
if (hint) hint.textContent = '封面已裁剪并上传'
|
||||||
|
} catch (error) {
|
||||||
|
if (hint) hint.textContent = error.message
|
||||||
|
alert(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (document.getElementById('cropModal').classList.contains('on')) layoutCrop()
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
function mediaFields(value) {
|
function mediaFields(value) {
|
||||||
return `
|
return `
|
||||||
<label>封面图片地址</label>
|
<label>封面图片地址</label>
|
||||||
<input name="cover" id="coverUrl" value="${escapeHtml(value.cover || '')}" placeholder="/static/uploads/..." />
|
<input name="cover" id="coverUrl" value="${escapeHtml(value.cover || '')}" placeholder="/static/uploads/..." />
|
||||||
<label>上传封面图片</label>
|
<label>上传封面图片(可选区域裁剪)</label>
|
||||||
<input type="file" id="coverFile" accept="image/png,image/jpeg,image/webp" />
|
<input type="file" id="coverFile" accept="image/png,image/jpeg,image/webp" />
|
||||||
<label>视频地址</label>
|
<label>视频地址</label>
|
||||||
<input name="video" id="videoUrl" value="${escapeHtml(value.video || '')}" placeholder="/static/uploads/..." />
|
<input name="video" id="videoUrl" value="${escapeHtml(value.video || '')}" placeholder="/static/uploads/..." />
|
||||||
<label>上传视频</label>
|
<label>上传视频</label>
|
||||||
<input type="file" id="videoFile" accept="video/mp4,video/webm,video/quicktime" />
|
<input type="file" id="videoFile" accept="video/mp4,video/webm,video/quicktime" />
|
||||||
<div class="muted" id="mediaHint" style="margin-top:8px"></div>
|
<div class="muted" id="mediaHint" style="margin-top:8px">首页封面为宽图,上传时请裁剪出要展示的区域。</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,16 +321,9 @@
|
|||||||
if (coverFile) {
|
if (coverFile) {
|
||||||
coverFile.onchange = async (event) => {
|
coverFile.onchange = async (event) => {
|
||||||
const file = event.target.files[0]
|
const file = event.target.files[0]
|
||||||
|
event.target.value = ''
|
||||||
if (!file) return
|
if (!file) return
|
||||||
try {
|
openCoverCrop(file, root.querySelector('#coverUrl'), hint)
|
||||||
if (hint) hint.textContent = '图片上传中...'
|
|
||||||
const uploaded = await uploadMedia(file)
|
|
||||||
root.querySelector('#coverUrl').value = uploaded.url
|
|
||||||
if (hint) hint.textContent = '图片已上传'
|
|
||||||
} catch (error) {
|
|
||||||
if (hint) hint.textContent = error.message
|
|
||||||
alert(error.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (videoFile) {
|
if (videoFile) {
|
||||||
@@ -211,7 +394,7 @@
|
|||||||
async function boot() {
|
async function boot() {
|
||||||
showApp(true)
|
showApp(true)
|
||||||
await loadActivities()
|
await loadActivities()
|
||||||
await Promise.all([loadStats(), loadPosters(), loadSignups(), loadCodes(), loadStaff()])
|
await Promise.all([loadStats(), loadPosters(), loadSignups(), loadCodes(), loadStaff(), loadSettings()])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
@@ -281,7 +464,7 @@
|
|||||||
<td class="row">
|
<td class="row">
|
||||||
${item.status === 'past'
|
${item.status === 'past'
|
||||||
? `<button class="ghost" data-pin="${item.id}" data-pinned="${item.pinned ? 1 : 0}" type="button">${item.pinned ? '取消置顶' : '置顶'}</button>`
|
? `<button class="ghost" data-pin="${item.id}" data-pinned="${item.pinned ? 1 : 0}" type="button">${item.pinned ? '取消置顶' : '置顶'}</button>`
|
||||||
: ''}
|
: `<button class="ghost" data-remind="${item.id}" type="button">发提醒</button>`}
|
||||||
<button class="ghost" data-edit="${item.id}" type="button">编辑</button>
|
<button class="ghost" data-edit="${item.id}" type="button">编辑</button>
|
||||||
<button class="danger" data-del="${item.id}" type="button">删除</button>
|
<button class="danger" data-del="${item.id}" type="button">删除</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -335,6 +518,22 @@
|
|||||||
loadActivities()
|
loadActivities()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
document.querySelectorAll('#activities [data-remind]').forEach((button) => {
|
||||||
|
button.onclick = async () => {
|
||||||
|
if (!confirm('向已同意订阅的报名用户发送活动提醒?')) return
|
||||||
|
try {
|
||||||
|
const result = await api(`/api/admin/activities/${button.dataset.remind}/remind`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: '{}'
|
||||||
|
})
|
||||||
|
alert(result.demo
|
||||||
|
? `演示模式:模拟发送 ${result.sent}/${result.total} 人`
|
||||||
|
: `已发送 ${result.sent}/${result.total} 人${result.failed ? `,失败 ${result.failed}` : ''}`)
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPosters() {
|
async function loadPosters() {
|
||||||
@@ -358,13 +557,9 @@
|
|||||||
<table><thead><tr><th>海报</th><th>状态</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="3">暂无海报</td></tr>'}</tbody></table>`
|
<table><thead><tr><th>海报</th><th>状态</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="3">暂无海报</td></tr>'}</tbody></table>`
|
||||||
document.getElementById('posterFile').onchange = async (event) => {
|
document.getElementById('posterFile').onchange = async (event) => {
|
||||||
const file = event.target.files[0]
|
const file = event.target.files[0]
|
||||||
|
event.target.value = ''
|
||||||
if (!file) return
|
if (!file) return
|
||||||
try {
|
openCoverCrop(file, document.getElementById('posterImage'), null)
|
||||||
const uploaded = await uploadMedia(file)
|
|
||||||
document.getElementById('posterImage').value = uploaded.url
|
|
||||||
} catch (error) {
|
|
||||||
alert(error.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
document.getElementById('posterForm').onsubmit = async (event) => {
|
document.getElementById('posterForm').onsubmit = async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -385,8 +580,10 @@
|
|||||||
const rows = signups.map((item) => `
|
const rows = signups.map((item) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(item.name)}<div class="muted">${escapeHtml(item.phone)}</div></td>
|
<td>${escapeHtml(item.name)}<div class="muted">${escapeHtml(item.phone)}</div></td>
|
||||||
|
<td>${escapeHtml(item.company || '')}<div class="muted">${escapeHtml(item.position || '')}</div></td>
|
||||||
<td>${escapeHtml(item.activityTitle || '')}</td>
|
<td>${escapeHtml(item.activityTitle || '')}</td>
|
||||||
<td>${escapeHtml(item.sourceSharer || '直接访问')}</td>
|
<td>${escapeHtml(item.sourceSharer || '直接访问')}</td>
|
||||||
|
<td>${item.remindAccepted ? '已订阅' : '未订阅'}</td>
|
||||||
<td>${item.checkedIn ? '已签到' : '未签到'}</td>
|
<td>${item.checkedIn ? '已签到' : '未签到'}</td>
|
||||||
<td><button class="danger" data-signup="${item.id}" type="button">删除</button></td>
|
<td><button class="danger" data-signup="${item.id}" type="button">删除</button></td>
|
||||||
</tr>`).join('')
|
</tr>`).join('')
|
||||||
@@ -394,7 +591,7 @@
|
|||||||
<div class="row" style="margin-bottom:12px">
|
<div class="row" style="margin-bottom:12px">
|
||||||
<a class="primary" style="text-decoration:none" href="/api/admin/signups/export?token=${encodeURIComponent(state.token)}">导出 CSV</a>
|
<a class="primary" style="text-decoration:none" href="/api/admin/signups/export?token=${encodeURIComponent(state.token)}">导出 CSV</a>
|
||||||
</div>
|
</div>
|
||||||
<table><thead><tr><th>报名人</th><th>活动</th><th>分享人</th><th>签到</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="5">暂无报名</td></tr>'}</tbody></table>`
|
<table><thead><tr><th>报名人</th><th>公司 / 职位</th><th>活动</th><th>分享人</th><th>提醒</th><th>签到</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="7">暂无报名</td></tr>'}</tbody></table>`
|
||||||
document.querySelectorAll('[data-signup]').forEach((button) => {
|
document.querySelectorAll('[data-signup]').forEach((button) => {
|
||||||
button.onclick = async () => {
|
button.onclick = async () => {
|
||||||
await api(`/api/admin/signups/${button.dataset.signup}`, { method: 'DELETE' })
|
await api(`/api/admin/signups/${button.dataset.signup}`, { method: 'DELETE' })
|
||||||
@@ -463,6 +660,30 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const settings = await api('/api/admin/settings')
|
||||||
|
document.getElementById('settings').innerHTML = `
|
||||||
|
<form id="settingsForm" class="card">
|
||||||
|
<strong>活动提醒(订阅消息)</strong>
|
||||||
|
<p class="muted">
|
||||||
|
1. 微信公众平台 → 功能 → 订阅消息 → 选用活动提醒类模板<br>
|
||||||
|
2. 模板字段建议:thing1 活动名称、time2 活动时间、thing3 活动地点<br>
|
||||||
|
3. 把模板 ID 填到下方;用户报名时会弹出授权,同意后才能收到提醒<br>
|
||||||
|
当前微信密钥:${settings.wxConfigured ? '已配置' : '未配置(演示模式只能模拟发送)'}
|
||||||
|
</p>
|
||||||
|
<label>订阅消息模板 ID</label>
|
||||||
|
<input name="subscribeTmplId" value="${escapeHtml(settings.subscribeTmplId || '')}" placeholder="例如 xxxxxxxxxxxxx" />
|
||||||
|
<div class="row" style="margin-top:12px"><button class="primary" type="submit">保存</button></div>
|
||||||
|
</form>`
|
||||||
|
document.getElementById('settingsForm').onsubmit = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const body = Object.fromEntries(new FormData(event.target).entries())
|
||||||
|
await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(body) })
|
||||||
|
alert('已保存')
|
||||||
|
loadSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
|||||||
+67
-1
@@ -3,6 +3,7 @@ const express = require('express')
|
|||||||
const QRCode = require('qrcode')
|
const QRCode = require('qrcode')
|
||||||
const db = require('../lib/db')
|
const db = require('../lib/db')
|
||||||
const passwords = require('../lib/passwords')
|
const passwords = require('../lib/passwords')
|
||||||
|
const wechat = require('../lib/wechat')
|
||||||
const { uploader } = require('../lib/upload')
|
const { uploader } = require('../lib/upload')
|
||||||
const { adminAuth } = require('../lib/auth')
|
const { adminAuth } = require('../lib/auth')
|
||||||
const { uid, token, ok, fail, wrap, str } = require('../lib/http')
|
const { uid, token, ok, fail, wrap, str } = require('../lib/http')
|
||||||
@@ -162,6 +163,69 @@ router.delete('/activities/:id', auth, (req, res) => {
|
|||||||
ok(res, { id: req.params.id })
|
ok(res, { id: req.params.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/settings', auth, (req, res) => {
|
||||||
|
const settings = db.read().settings || {}
|
||||||
|
ok(res, {
|
||||||
|
subscribeTmplId: settings.subscribeTmplId || '',
|
||||||
|
wxConfigured: wechat.wxConfigured()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/settings', auth, (req, res) => {
|
||||||
|
const subscribeTmplId = str(req.body && req.body.subscribeTmplId, 80)
|
||||||
|
const settings = db.update((data) => {
|
||||||
|
if (!data.settings) data.settings = {}
|
||||||
|
data.settings.subscribeTmplId = subscribeTmplId
|
||||||
|
return data.settings
|
||||||
|
})
|
||||||
|
ok(res, settings)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/activities/:id/remind',
|
||||||
|
auth,
|
||||||
|
wrap(async (req, res) => {
|
||||||
|
const data = db.read()
|
||||||
|
const activity = data.activities.find((item) => item.id === req.params.id)
|
||||||
|
if (!activity) return fail(res, 404, '活动不存在')
|
||||||
|
const tmplId = str(data.settings && data.settings.subscribeTmplId)
|
||||||
|
if (!tmplId) return fail(res, 400, '请先在后台配置订阅消息模板 ID')
|
||||||
|
|
||||||
|
const targets = data.signups.filter((item) => item.activityId === activity.id && item.remindAccepted)
|
||||||
|
if (!targets.length) return fail(res, 400, '没有可提醒的报名用户(需用户报名时同意订阅)')
|
||||||
|
|
||||||
|
if (!wechat.wxConfigured()) {
|
||||||
|
return ok(res, {
|
||||||
|
demo: true,
|
||||||
|
total: targets.length,
|
||||||
|
sent: targets.length,
|
||||||
|
failed: 0,
|
||||||
|
message: '演示模式:未配置微信密钥,已模拟发送成功'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = `pages/detail/detail?id=${activity.id}`
|
||||||
|
const payload = wechat.buildRemindData(activity)
|
||||||
|
let sent = 0
|
||||||
|
const failures = []
|
||||||
|
for (const signup of targets) {
|
||||||
|
try {
|
||||||
|
const result = await wechat.sendSubscribeMessage({
|
||||||
|
openid: signup.openid,
|
||||||
|
templateId: tmplId,
|
||||||
|
page,
|
||||||
|
data: payload
|
||||||
|
})
|
||||||
|
if (result.ok) sent += 1
|
||||||
|
else failures.push({ openid: signup.openid, errmsg: result.errmsg || String(result.errcode) })
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({ openid: signup.openid, errmsg: error.message || '发送失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok(res, { demo: false, total: targets.length, sent, failed: failures.length, failures: failures.slice(0, 20) })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
router.get('/posters', auth, (req, res) => {
|
router.get('/posters', auth, (req, res) => {
|
||||||
ok(res, db.read().posters)
|
ok(res, db.read().posters)
|
||||||
})
|
})
|
||||||
@@ -227,7 +291,7 @@ function csvCell(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.get('/signups/export', adminAuth({ allowQueryToken: true }), (req, res) => {
|
router.get('/signups/export', adminAuth({ allowQueryToken: true }), (req, res) => {
|
||||||
const header = ['姓名', '手机号', '公司', '活动', '分享人', '报名时间', '是否签到', '签到时间']
|
const header = ['姓名', '手机号', '公司', '职位', '活动', '分享人', '订阅提醒', '报名时间', '是否签到', '签到时间']
|
||||||
const lines = [header.join(',')]
|
const lines = [header.join(',')]
|
||||||
signupRows(str(req.query.activityId)).forEach((item) => {
|
signupRows(str(req.query.activityId)).forEach((item) => {
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -235,8 +299,10 @@ router.get('/signups/export', adminAuth({ allowQueryToken: true }), (req, res) =
|
|||||||
item.name,
|
item.name,
|
||||||
item.phone,
|
item.phone,
|
||||||
item.company,
|
item.company,
|
||||||
|
item.position || '',
|
||||||
item.activityTitle,
|
item.activityTitle,
|
||||||
item.sourceSharer,
|
item.sourceSharer,
|
||||||
|
item.remindAccepted ? '是' : '否',
|
||||||
item.createdAt,
|
item.createdAt,
|
||||||
item.checkedIn ? '是' : '否',
|
item.checkedIn ? '是' : '否',
|
||||||
item.checkedInAt
|
item.checkedInAt
|
||||||
|
|||||||
@@ -148,12 +148,24 @@ router.delete('/activity/past/:id', userAuth, requireUserAdmin, (req, res) => {
|
|||||||
ok(res, { id: removed })
|
ok(res, { id: removed })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/subscribe/config', (req, res) => {
|
||||||
|
const settings = db.read().settings || {}
|
||||||
|
const tmplId = str(settings.subscribeTmplId)
|
||||||
|
ok(res, {
|
||||||
|
enabled: !!tmplId,
|
||||||
|
tmplIds: tmplId ? [tmplId] : [],
|
||||||
|
notice: '提交即视为同意接收活动提醒'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/signup', userAuth, (req, res) => {
|
router.post('/signup', userAuth, (req, res) => {
|
||||||
const body = req.body || {}
|
const body = req.body || {}
|
||||||
const activityId = str(body.activityId)
|
const activityId = str(body.activityId)
|
||||||
const name = str(body.name, 20)
|
const name = str(body.name, 20)
|
||||||
const phone = str(body.phone)
|
const phone = str(body.phone)
|
||||||
let sourceSharer = str(body.sourceSharer, 64)
|
let sourceSharer = str(body.sourceSharer, 64)
|
||||||
|
// accept / reject / ban / filter / none — matches wx.requestSubscribeMessage result values
|
||||||
|
const remindStatus = str(body.remindStatus || 'none', 16) || 'none'
|
||||||
if (!activityId || !name || !phone) return fail(res, 400, '请填写姓名和手机号')
|
if (!activityId || !name || !phone) return fail(res, 400, '请填写姓名和手机号')
|
||||||
if (!PHONE_RE.test(phone)) return fail(res, 400, '请填写正确的手机号')
|
if (!PHONE_RE.test(phone)) return fail(res, 400, '请填写正确的手机号')
|
||||||
if (sourceSharer === req.user.openid) sourceSharer = ''
|
if (sourceSharer === req.user.openid) sourceSharer = ''
|
||||||
@@ -172,7 +184,10 @@ router.post('/signup', userAuth, (req, res) => {
|
|||||||
name,
|
name,
|
||||||
phone,
|
phone,
|
||||||
company: str(body.company, 40),
|
company: str(body.company, 40),
|
||||||
|
position: str(body.position, 40),
|
||||||
sourceSharer,
|
sourceSharer,
|
||||||
|
remindStatus,
|
||||||
|
remindAccepted: remindStatus === 'accept',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
checkedIn: false,
|
checkedIn: false,
|
||||||
checkedInAt: ''
|
checkedInAt: ''
|
||||||
|
|||||||
@@ -1,3 +1,31 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// Load local secrets from server/.env (gitignored). Never commit this file.
|
||||||
|
function loadEnv() {
|
||||||
|
const file = path.join(__dirname, '.env')
|
||||||
|
if (!fs.existsSync(file)) return
|
||||||
|
fs.readFileSync(file, 'utf8')
|
||||||
|
.split('\n')
|
||||||
|
.forEach((line) => {
|
||||||
|
const text = line.trim()
|
||||||
|
if (!text || text.startsWith('#')) return
|
||||||
|
const i = text.indexOf('=')
|
||||||
|
if (i < 1) return
|
||||||
|
const key = text.slice(0, i).trim()
|
||||||
|
let value = text.slice(i + 1).trim()
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
if (!process.env[key]) process.env[key] = value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnv()
|
||||||
|
|
||||||
const app = require('./app')
|
const app = require('./app')
|
||||||
const db = require('./lib/db')
|
const db = require('./lib/db')
|
||||||
|
|
||||||
@@ -10,5 +38,7 @@ app.listen(PORT, () => {
|
|||||||
console.log(`管理后台 http://localhost:${PORT}/admin`)
|
console.log(`管理后台 http://localhost:${PORT}/admin`)
|
||||||
if (!process.env.WX_APPID || !process.env.WX_SECRET) {
|
if (!process.env.WX_APPID || !process.env.WX_SECRET) {
|
||||||
console.log('未配置 WX_APPID / WX_SECRET,小程序登录走演示模式')
|
console.log('未配置 WX_APPID / WX_SECRET,小程序登录走演示模式')
|
||||||
|
} else {
|
||||||
|
console.log(`微信登录已启用 AppID ${process.env.WX_APPID}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+16
-1
@@ -87,9 +87,17 @@ test('full flow: admin, past activity, signup, checkin, export', async () => {
|
|||||||
const guest = await login('device-b')
|
const guest = await login('device-b')
|
||||||
const signup = await call('POST', '/api/signup', {
|
const signup = await call('POST', '/api/signup', {
|
||||||
token: guest.token,
|
token: guest.token,
|
||||||
body: { activityId: '93', name: '张三', phone: '13800000000', company: '=cmd', sourceSharer: user.openid }
|
body: {
|
||||||
|
activityId: '93',
|
||||||
|
name: '张三',
|
||||||
|
phone: '13800000000',
|
||||||
|
company: '=cmd',
|
||||||
|
sourceSharer: user.openid,
|
||||||
|
remindStatus: 'accept'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
assert.strictEqual(signup.status, 200)
|
assert.strictEqual(signup.status, 200)
|
||||||
|
assert.strictEqual(signup.body.data.remindAccepted, true)
|
||||||
const dup = await call('POST', '/api/signup', { token: guest.token, body: { activityId: '93', name: '张三', phone: '13800000000' } })
|
const dup = await call('POST', '/api/signup', { token: guest.token, body: { activityId: '93', name: '张三', phone: '13800000000' } })
|
||||||
assert.strictEqual(dup.status, 400)
|
assert.strictEqual(dup.status, 400)
|
||||||
|
|
||||||
@@ -101,6 +109,13 @@ test('full flow: admin, past activity, signup, checkin, export', async () => {
|
|||||||
const stats = await call('GET', '/api/admin/stats', { token: adminToken })
|
const stats = await call('GET', '/api/admin/stats', { token: adminToken })
|
||||||
assert.strictEqual(stats.body.data.checkinCount, 1)
|
assert.strictEqual(stats.body.data.checkinCount, 1)
|
||||||
|
|
||||||
|
await call('PUT', '/api/admin/settings', { token: adminToken, body: { subscribeTmplId: 'tmpl_test_id' } })
|
||||||
|
const config = await call('GET', '/api/subscribe/config')
|
||||||
|
assert.deepStrictEqual(config.body.data.tmplIds, ['tmpl_test_id'])
|
||||||
|
const remind = await call('POST', '/api/admin/activities/93/remind', { token: adminToken, body: {} })
|
||||||
|
assert.strictEqual(remind.status, 200)
|
||||||
|
assert.strictEqual(remind.body.data.sent, 1)
|
||||||
|
|
||||||
const csv = await call('GET', `/api/admin/signups/export?token=${adminToken}`)
|
const csv = await call('GET', `/api/admin/signups/export?token=${adminToken}`)
|
||||||
assert.strictEqual(csv.status, 200)
|
assert.strictEqual(csv.status, 200)
|
||||||
assert.match(csv.body, /张三,13800000000,'=cmd/)
|
assert.match(csv.body, /张三,13800000000,'=cmd/)
|
||||||
|
|||||||
Reference in New Issue
Block a user