Files
zhuomi-hui/server/routes/admin.js
T
Michelle ZanandCursor 0ca2e87bf2 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>
2026-10-08 13:06:43 +08:00

363 lines
12 KiB
JavaScript

const crypto = require('crypto')
const express = require('express')
const QRCode = require('qrcode')
const db = require('../lib/db')
const passwords = require('../lib/passwords')
const wechat = require('../lib/wechat')
const { uploader } = require('../lib/upload')
const { adminAuth } = require('../lib/auth')
const { uid, token, ok, fail, wrap, str } = require('../lib/http')
const { activityPayload, setPinned } = require('../lib/serialize')
const router = express.Router()
const auth = adminAuth()
router.post('/login', (req, res) => {
const username = str(req.body && req.body.username)
const password = String((req.body && req.body.password) || '')
const session = db.update((data) => {
const admin = data.admins.find((item) => item.username === username)
if (!admin || !passwords.verify(password, admin.password)) return null
admin.token = token()
return { token: admin.token, username: admin.username }
})
if (!session) return fail(res, 401, '账号或密码错误')
ok(res, session)
})
router.post('/password', auth, (req, res) => {
const oldPassword = String((req.body && req.body.oldPassword) || '')
const nextPassword = String((req.body && req.body.newPassword) || '')
if (nextPassword.length < 6) return fail(res, 400, '新密码至少 6 位')
if (!passwords.verify(oldPassword, req.admin.password)) return fail(res, 400, '原密码不正确')
db.update(() => {
req.admin.password = passwords.hash(nextPassword)
req.admin.token = ''
})
ok(res, { relogin: true })
})
router.get('/stats', auth, (req, res) => {
const data = db.read()
const byActivity = data.activities.map((activity) => {
const rows = data.signups.filter((item) => item.activityId === activity.id)
return {
id: activity.id,
title: activity.title,
signups: rows.length,
checkins: rows.filter((item) => item.checkedIn).length
}
})
const sharerMap = {}
data.signups.forEach((item) => {
const key = item.sourceSharer || '直接访问'
sharerMap[key] = (sharerMap[key] || 0) + 1
})
ok(res, {
activityCount: data.activities.length,
signupCount: data.signups.length,
checkinCount: data.signups.filter((item) => item.checkedIn).length,
byActivity,
bySharer: Object.keys(sharerMap).map((sharer) => ({ sharer, count: sharerMap[sharer] }))
})
})
router.get('/users', auth, (req, res) => {
const data = db.read()
ok(
res,
data.users.map((user) => ({
openid: user.openid,
nickName: user.nickName || '',
name: user.name || '',
phone: user.phone || '',
allowed: data.adminAllowlist.includes(user.openid),
createdAt: user.createdAt || ''
}))
)
})
router.get('/allowlist', auth, (req, res) => {
ok(res, db.read().adminAllowlist)
})
router.post('/allowlist', auth, (req, res) => {
const openid = str(req.body && req.body.openid)
if (!openid) return fail(res, 400, '缺少 openid')
ok(
res,
db.update((data) => {
if (!data.adminAllowlist.includes(openid)) data.adminAllowlist.push(openid)
return data.adminAllowlist
})
)
})
router.delete('/allowlist/:openid', auth, (req, res) => {
const openid = req.params.openid
ok(
res,
db.update((data) => {
data.adminAllowlist = data.adminAllowlist.filter((item) => item !== openid)
return data.adminAllowlist
})
)
})
function clearOtherCurrent(data, keepId) {
data.activities.forEach((item) => {
if (item.id !== keepId) item.isCurrent = false
})
}
router.get('/activities', auth, (req, res) => {
ok(res, db.read().activities)
})
router.post('/activities', auth, (req, res) => {
const created = db.update((data) => {
const payload = activityPayload(req.body || {}, null, data)
if (!payload.title) return null
if (payload.isCurrent) clearOtherCurrent(data, payload.id)
data.activities.push(payload)
return payload
})
if (!created) return fail(res, 400, '请填写活动名称')
ok(res, created)
})
router.put('/activities/:id', auth, (req, res) => {
const result = db.update((data) => {
const index = data.activities.findIndex((item) => item.id === req.params.id)
if (index < 0) return { status: 404, error: '活动不存在' }
const payload = activityPayload(req.body || {}, data.activities[index], data)
if (!payload.title) return { status: 400, error: '请填写活动名称' }
if (payload.isCurrent) clearOtherCurrent(data, payload.id)
data.activities[index] = payload
return { activity: payload }
})
if (result.error) return fail(res, result.status, result.error)
ok(res, result.activity)
})
router.post('/activities/:id/pin', auth, (req, res) => {
const result = db.update((data) => {
const activity = data.activities.find((item) => item.id === req.params.id)
if (!activity) return { status: 404, error: '活动不存在' }
if (activity.status !== 'past') return { status: 400, error: '只能置顶往期活动' }
return { activity: setPinned(activity, !!(req.body && req.body.pinned)) }
})
if (result.error) return fail(res, result.status, result.error)
ok(res, result.activity)
})
router.delete('/activities/:id', auth, (req, res) => {
const removed = db.update((data) => {
if (!data.activities.some((item) => item.id === req.params.id)) return false
data.activities = data.activities.filter((item) => item.id !== req.params.id)
data.posters = data.posters.filter((item) => item.activityId !== req.params.id)
data.checkinCodes = data.checkinCodes.filter((item) => item.activityId !== req.params.id)
return true
})
if (!removed) return fail(res, 404, '活动不存在')
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) => {
ok(res, db.read().posters)
})
router.post('/posters', auth, (req, res) => {
const body = req.body || {}
if (!body.activityId) return fail(res, 400, '请选择活动')
const poster = {
id: uid('m'),
activityId: str(body.activityId),
image: str(body.image),
title: str(body.title, 60),
enabled: body.enabled !== false,
sort: Number(body.sort) || 0,
createdAt: new Date().toISOString()
}
db.update((data) => data.posters.push(poster))
ok(res, poster)
})
router.put('/posters/:id', auth, (req, res) => {
const body = req.body || {}
const updated = db.update((data) => {
const poster = data.posters.find((item) => item.id === req.params.id)
if (!poster) return null
if (body.activityId != null) poster.activityId = str(body.activityId)
if (body.image != null) poster.image = str(body.image)
if (body.title != null) poster.title = str(body.title, 60)
if (body.enabled != null) poster.enabled = !!body.enabled
if (body.sort != null) poster.sort = Number(body.sort) || 0
return poster
})
if (!updated) return fail(res, 404, '海报不存在')
ok(res, updated)
})
router.delete('/posters/:id', auth, (req, res) => {
const removed = db.update((data) => {
const before = data.posters.length
data.posters = data.posters.filter((item) => item.id !== req.params.id)
return data.posters.length !== before
})
if (!removed) return fail(res, 404, '海报不存在')
ok(res, { id: req.params.id })
})
router.post('/upload-file', auth, uploader())
function signupRows(activityId) {
const rows = db.read().signups
return activityId ? rows.filter((item) => item.activityId === activityId) : rows
}
router.get('/signups', auth, (req, res) => {
ok(res, signupRows(str(req.query.activityId)))
})
function csvCell(value) {
const text = value == null ? '' : String(value)
// Prefix formula-leading cells so spreadsheets don't execute them.
const safe = /^[=+\-@]/.test(text) ? `'${text}` : text
return /[",\n\r]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe
}
router.get('/signups/export', adminAuth({ allowQueryToken: true }), (req, res) => {
const header = ['姓名', '手机号', '公司', '职位', '活动', '分享人', '订阅提醒', '报名时间', '是否签到', '签到时间']
const lines = [header.join(',')]
signupRows(str(req.query.activityId)).forEach((item) => {
lines.push(
[
item.name,
item.phone,
item.company,
item.position || '',
item.activityTitle,
item.sourceSharer,
item.remindAccepted ? '是' : '否',
item.createdAt,
item.checkedIn ? '是' : '否',
item.checkedInAt
]
.map(csvCell)
.join(',')
)
})
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="signups.csv"')
res.send(`\uFEFF${lines.join('\n')}`)
})
router.delete('/signups/:id', auth, (req, res) => {
const removed = db.update((data) => {
const before = data.signups.length
data.signups = data.signups.filter((item) => item.id !== req.params.id)
return data.signups.length !== before
})
if (!removed) return fail(res, 404, '报名不存在')
ok(res, { id: req.params.id })
})
function withQr(code, width) {
return QRCode.toDataURL(code.token, { width, margin: 1 }).then((qrDataUrl) =>
Object.assign({}, code, { qrDataUrl })
)
}
router.get(
'/checkin-codes',
auth,
wrap(async (req, res) => {
ok(res, await Promise.all(db.read().checkinCodes.map((code) => withQr(code, 280))))
})
)
router.post(
'/checkin-codes',
auth,
wrap(async (req, res) => {
const activityId = str(req.body && req.body.activityId)
const hours = Math.min(Math.max(Number(req.body && req.body.ttlHours) || 12, 1), 24 * 30)
if (!db.read().activities.some((item) => item.id === activityId)) return fail(res, 404, '活动不存在')
const code = {
id: uid('c'),
activityId,
token: crypto.randomBytes(16).toString('hex'),
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + hours * 3600 * 1000).toISOString()
}
db.update((data) => data.checkinCodes.push(code))
ok(res, await withQr(code, 360))
})
)
module.exports = router