Files
zhuomi-hui/server/public/admin.html
T
Michelle ZanandCursor 0c02546366 Fix admin cover crop zoom controls.
Remove full-bleed mask inset that blocked visible zoom, and zoom around the crop center.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 13:10:35 +08:00

735 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>卓米会管理后台</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; background: #f3f5f7; color: #1f2937; }
button, input, select, textarea { font: inherit; }
.login { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.panel { width: min(420px, 100%); background: #fff; border-radius: 16px; padding: 28px; }
h1 { margin: 0 0 8px; font-size: 22px; }
.muted { color: #6b7280; font-size: 13px; }
label { display: block; margin: 14px 0 6px; font-size: 13px; color: #6b7280; }
input, select, textarea { width: 100%; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px 12px; background: #fff; }
textarea { min-height: 72px; resize: vertical; }
.primary, .ghost, .danger { border: 0; border-radius: 10px; padding: 10px 14px; cursor: pointer; }
.primary { background: #128a80; color: #fff; }
.ghost { background: #fff; color: #128a80; border: 1px solid #b7e0dc; }
.danger { background: #fff; color: #dc2626; border: 1px solid #fecaca; }
.app { display: none; min-height: 100vh; }
header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #fff; }
nav { display: flex; gap: 8px; padding: 12px 24px; }
nav button { background: transparent; border: 0; padding: 8px 12px; border-radius: 999px; cursor: pointer; color: #4b5563; }
nav button.on { background: #128a80; color: #fff; }
main { padding: 8px 24px 40px; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.card { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 12px; }
.stat { font-size: 28px; font-weight: 700; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 14px; overflow: hidden; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #f3f4f6; font-size: 14px; vertical-align: top; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.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; left: 0; top: 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; } }
</style>
</head>
<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">
<form class="panel" id="loginForm">
<h1>卓米会后台</h1>
<p class="muted">默认账号 admin / 123456,上线前请修改密码。</p>
<label>账号</label>
<input name="username" value="admin" autocomplete="username" />
<label>密码</label>
<input name="password" type="password" value="123456" autocomplete="current-password" />
<p class="error" id="loginError"></p>
<button class="primary" type="submit">登录</button>
</form>
</div>
<div class="app" id="appView">
<header>
<strong>卓米会</strong>
<div class="row">
<button class="ghost" id="passwordBtn" type="button">修改密码</button>
<button class="ghost" id="logoutBtn" type="button">退出</button>
</div>
</header>
<nav>
<button type="button" data-tab="stats" class="on">数据统计</button>
<button type="button" data-tab="activities">活动</button>
<button type="button" data-tab="posters">海报</button>
<button type="button" data-tab="signups">报名</button>
<button type="button" data-tab="checkin">签到码</button>
<button type="button" data-tab="staff">管理员</button>
<button type="button" data-tab="settings">提醒设置</button>
</nav>
<main>
<section id="stats"></section>
<section id="activities" hidden></section>
<section id="posters" hidden></section>
<section id="signups" hidden></section>
<section id="checkin" hidden></section>
<section id="staff" hidden></section>
<section id="settings" hidden></section>
</main>
</div>
<script>
const state = { token: localStorage.getItem('zhuomi_admin') || '', activities: [] }
const loginView = document.getElementById('loginView')
const appView = document.getElementById('appView')
function showApp(on) {
loginView.style.display = on ? 'none' : 'flex'
appView.style.display = on ? 'block' : 'none'
}
async function api(url, options = {}) {
const headers = { ...(options.headers || {}) }
if (state.token) headers.Authorization = state.token
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
const res = await fetch(url, { ...options, headers })
if (options.raw) return res
const data = await res.json().catch(() => ({}))
if (res.status === 401 && url !== '/api/admin/login') {
state.token = ''
localStorage.removeItem('zhuomi_admin')
showApp(false)
}
if (!res.ok || data.ok === false) throw new Error(data.message || '请求失败')
return data.data
}
async function uploadMedia(file) {
const form = new FormData()
form.append('file', file)
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 maskSize() {
const stage = document.getElementById('cropStage')
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
}
return {
maskW: maskW,
maskH: maskH,
maskLeft: (sw - maskW) / 2,
maskTop: (sh - maskH) / 2
}
}
function minCropScale() {
const size = maskSize()
return Math.max(size.maskW / cropState.naturalW, size.maskH / cropState.naturalH)
}
function layoutCrop() {
const stage = document.getElementById('cropStage')
const img = document.getElementById('cropImage')
const mask = document.getElementById('cropMask')
if (!stage || !img.naturalWidth || !cropState.naturalW) return
const size = maskSize()
mask.style.right = 'auto'
mask.style.bottom = 'auto'
mask.style.width = `${size.maskW}px`
mask.style.height = `${size.maskH}px`
mask.style.left = `${size.maskLeft}px`
mask.style.top = `${size.maskTop}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 scale = minCropScale()
cropState.scale = scale
cropState.x = (stage.clientWidth - cropState.naturalW * scale) / 2
cropState.y = (stage.clientHeight - cropState.naturalH * scale) / 2
layoutCrop()
}
function zoomCrop(factor) {
if (!cropState.naturalW) return
const size = maskSize()
const cx = size.maskLeft + size.maskW / 2
const cy = size.maskTop + size.maskH / 2
const before = cropState.scale
const next = Math.min(Math.max(before * factor, minCropScale()), minCropScale() * 8)
if (Math.abs(next - before) < 0.0001) return
// Keep the point under the crop center stable while zooming.
cropState.x = cx - ((cx - cropState.x) / before) * next
cropState.y = cy - ((cy - cropState.y) / before) * next
cropState.scale = next
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 = (event) => {
event.preventDefault()
zoomCrop(1.15)
}
document.getElementById('cropZoomOut').onclick = (event) => {
event.preventDefault()
zoomCrop(1 / 1.15)
}
stage.addEventListener('wheel', (event) => {
if (!document.getElementById('cropModal').classList.contains('on')) return
event.preventDefault()
zoomCrop(event.deltaY > 0 ? 1 / 1.08 : 1.08)
}, { passive: false })
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) {
return `
<label>封面图片地址</label>
<input name="cover" id="coverUrl" value="${escapeHtml(value.cover || '')}" placeholder="/static/uploads/..." />
<label>上传封面图片(可选区域裁剪)</label>
<input type="file" id="coverFile" accept="image/png,image/jpeg,image/webp" />
<label>视频地址</label>
<input name="video" id="videoUrl" value="${escapeHtml(value.video || '')}" placeholder="/static/uploads/..." />
<label>上传视频</label>
<input type="file" id="videoFile" accept="video/mp4,video/webm,video/quicktime" />
<div class="muted" id="mediaHint" style="margin-top:8px">首页封面为宽图,上传时请裁剪出要展示的区域。</div>
`
}
function bindMediaPickers(scope) {
const root = scope || document
const coverFile = root.querySelector('#coverFile')
const videoFile = root.querySelector('#videoFile')
const hint = root.querySelector('#mediaHint')
if (coverFile) {
coverFile.onchange = async (event) => {
const file = event.target.files[0]
event.target.value = ''
if (!file) return
openCoverCrop(file, root.querySelector('#coverUrl'), hint)
}
}
if (videoFile) {
videoFile.onchange = async (event) => {
const file = event.target.files[0]
if (!file) return
try {
if (hint) hint.textContent = '视频上传中...'
const uploaded = await uploadMedia(file)
root.querySelector('#videoUrl').value = uploaded.url
if (hint) hint.textContent = '视频已上传'
} catch (error) {
if (hint) hint.textContent = error.message
alert(error.message)
}
}
}
}
document.getElementById('loginForm').addEventListener('submit', async (event) => {
event.preventDefault()
const form = new FormData(event.target)
try {
const data = await api('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ username: form.get('username'), password: form.get('password') })
})
state.token = data.token
localStorage.setItem('zhuomi_admin', data.token)
document.getElementById('loginError').textContent = ''
boot()
} catch (error) {
document.getElementById('loginError').textContent = error.message
}
})
document.getElementById('logoutBtn').onclick = () => {
state.token = ''
localStorage.removeItem('zhuomi_admin')
showApp(false)
}
document.getElementById('passwordBtn').onclick = async () => {
const oldPassword = prompt('原密码')
const newPassword = prompt('新密码(至少 6 位)')
if (!oldPassword || !newPassword) return
try {
await api('/api/admin/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) })
state.token = ''
localStorage.removeItem('zhuomi_admin')
showApp(false)
alert('密码已更新,请重新登录')
} catch (error) {
alert(error.message)
}
}
document.querySelectorAll('nav button').forEach((button) => {
button.onclick = () => {
document.querySelectorAll('nav button').forEach((item) => item.classList.remove('on'))
button.classList.add('on')
document.querySelectorAll('main section').forEach((section) => {
section.hidden = section.id !== button.dataset.tab
})
}
})
async function boot() {
showApp(true)
await loadActivities()
await Promise.all([loadStats(), loadPosters(), loadSignups(), loadCodes(), loadStaff(), loadSettings()])
}
async function loadStats() {
const stats = await api('/api/admin/stats')
const rows = stats.bySharer.map((item) => `<tr><td>${escapeHtml(item.sharer)}</td><td>${item.count}</td></tr>`).join('')
document.getElementById('stats').innerHTML = `
<div class="grid">
<div class="card"><div class="muted">活动</div><div class="stat">${stats.activityCount}</div></div>
<div class="card"><div class="muted">报名</div><div class="stat">${stats.signupCount}</div></div>
<div class="card"><div class="muted">签到</div><div class="stat">${stats.checkinCount}</div></div>
</div>
<div class="card"><strong>推荐来源</strong>
<table><thead><tr><th>分享人</th><th>报名数</th></tr></thead><tbody>${rows || '<tr><td colspan="2">暂无报名</td></tr>'}</tbody></table>
</div>`
}
function activityForm(activity) {
const value = activity || { status: 'draft', isCurrent: false, brandName: '卓米会 ZHUOMI' }
return `
<form id="activityForm" class="card">
<strong>${value.id ? '编辑活动' : '新建活动'}</strong>
<p class="muted">状态选 past 即为往期;往期可置顶,并支持封面图 / 视频。</p>
<input type="hidden" name="id" value="${value.id || ''}" />
<label>名称</label><input name="title" value="${escapeHtml(value.title || '')}" />
<label>品牌名</label><input name="brandName" value="${escapeHtml(value.brandName || '')}" />
<label>简介</label><textarea name="brandDesc">${escapeHtml(value.brandDesc || '')}</textarea>
<label>时间</label><input name="startAt" value="${escapeHtml(value.startAt || '')}" />
<label>地址</label><input name="address" value="${escapeHtml(value.address || '')}" />
<label>纬度</label><input name="latitude" value="${value.latitude ?? ''}" />
<label>经度</label><input name="longitude" value="${value.longitude ?? ''}" />
${mediaFields(value)}
<label>状态</label>
<select name="status" id="activityStatus">
<option value="current" ${value.status === 'current' ? 'selected' : ''}>current(当前)</option>
<option value="past" ${value.status === 'past' ? 'selected' : ''}>past(往期)</option>
<option value="draft" ${value.status === 'draft' ? 'selected' : ''}>draft(草稿)</option>
</select>
<label><input type="checkbox" name="isCurrent" ${value.isCurrent ? 'checked' : ''} style="width:auto" /> 设为当前活动</label>
<label><input type="checkbox" name="pinned" ${value.pinned ? 'checked' : ''} style="width:auto" /> 往期置顶(仅 past 生效)</label>
<div class="row"><button class="primary" type="submit">保存</button></div>
</form>`
}
async function loadActivities() {
state.activities = await api('/api/admin/activities')
const list = state.activities.slice().sort((a, b) => {
const rank = (item) => (item.isCurrent ? 0 : item.status === 'current' ? 1 : item.status === 'past' ? 2 : 3)
const ra = rank(a)
const rb = rank(b)
if (ra !== rb) return ra - rb
if (a.status === 'past' && b.status === 'past') {
const ap = a.pinned ? 1 : 0
const bp = b.pinned ? 1 : 0
if (ap !== bp) return bp - ap
}
return String(b.createdAt || '').localeCompare(String(a.createdAt || ''))
})
const rows = list.map((item) => `
<tr>
<td>
${escapeHtml(item.title)}
<div class="muted">
${item.status}${item.isCurrent ? ' · 当前' : ''}${item.pinned ? ' · 置顶' : ''}${item.cover ? ' · 图' : ''}${item.video ? ' · 视频' : ''}
</div>
</td>
<td>${escapeHtml(item.startAt || '')}</td>
<td class="row">
${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-remind="${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>
</td>
</tr>`).join('')
document.getElementById('activities').innerHTML = `
<div class="split">
<table><thead><tr><th>活动</th><th>时间</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="3">暂无活动</td></tr>'}</tbody></table>
<div id="activityEditor">${activityForm()}</div>
</div>`
bindActivityEditor()
}
function bindActivityEditor() {
const form = document.getElementById('activityForm')
bindMediaPickers(form)
form.onsubmit = async (event) => {
event.preventDefault()
const formData = new FormData(form)
const body = Object.fromEntries(formData.entries())
body.isCurrent = formData.get('isCurrent') === 'on'
body.pinned = formData.get('pinned') === 'on'
const id = body.id
delete body.id
if (id) await api(`/api/admin/activities/${id}`, { method: 'PUT', body: JSON.stringify(body) })
else await api('/api/admin/activities', { method: 'POST', body: JSON.stringify(body) })
loadActivities()
loadStats()
}
document.querySelectorAll('#activities [data-edit]').forEach((button) => {
button.onclick = () => {
const activity = state.activities.find((item) => item.id === button.dataset.edit)
document.getElementById('activityEditor').innerHTML = activityForm(activity)
bindActivityEditor()
}
})
document.querySelectorAll('#activities [data-del]').forEach((button) => {
button.onclick = async () => {
if (!confirm('删除这场活动?')) return
await api(`/api/admin/activities/${button.dataset.del}`, { method: 'DELETE' })
loadActivities()
loadStats()
}
})
document.querySelectorAll('#activities [data-pin]').forEach((button) => {
button.onclick = async () => {
const next = String(button.dataset.pinned) !== '1'
await api(`/api/admin/activities/${button.dataset.pin}/pin`, {
method: 'POST',
body: JSON.stringify({ pinned: next })
})
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() {
const posters = await api('/api/admin/posters')
const options = state.activities.map((item) => `<option value="${item.id}">${escapeHtml(item.title)}</option>`).join('')
const rows = posters.map((item) => `
<tr>
<td>${escapeHtml(item.title || item.image)}</td>
<td>${item.enabled ? '启用' : '停用'}</td>
<td><button class="danger" data-poster="${item.id}" type="button">删除</button></td>
</tr>`).join('')
document.getElementById('posters').innerHTML = `
<form id="posterForm" class="card">
<strong>新增海报</strong>
<label>活动</label><select name="activityId">${options}</select>
<label>标题</label><input name="title" />
<label>图片地址</label><input name="image" id="posterImage" placeholder="/static/uploads/..." />
<label>或上传图片</label><input type="file" id="posterFile" accept="image/png,image/jpeg,image/webp" />
<div class="row" style="margin-top:12px"><button class="primary" type="submit">保存海报</button></div>
</form>
<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) => {
const file = event.target.files[0]
event.target.value = ''
if (!file) return
openCoverCrop(file, document.getElementById('posterImage'), null)
}
document.getElementById('posterForm').onsubmit = async (event) => {
event.preventDefault()
const body = Object.fromEntries(new FormData(event.target).entries())
await api('/api/admin/posters', { method: 'POST', body: JSON.stringify(body) })
loadPosters()
}
document.querySelectorAll('[data-poster]').forEach((button) => {
button.onclick = async () => {
await api(`/api/admin/posters/${button.dataset.poster}`, { method: 'DELETE' })
loadPosters()
}
})
}
async function loadSignups() {
const signups = await api('/api/admin/signups')
const rows = signups.map((item) => `
<tr>
<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.sourceSharer || '直接访问')}</td>
<td>${item.remindAccepted ? '已订阅' : '未订阅'}</td>
<td>${item.checkedIn ? '已签到' : '未签到'}</td>
<td><button class="danger" data-signup="${item.id}" type="button">删除</button></td>
</tr>`).join('')
document.getElementById('signups').innerHTML = `
<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>
</div>
<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) => {
button.onclick = async () => {
await api(`/api/admin/signups/${button.dataset.signup}`, { method: 'DELETE' })
loadSignups()
loadStats()
}
})
}
async function loadCodes() {
const codes = await api('/api/admin/checkin-codes')
const options = state.activities.map((item) => `<option value="${item.id}">${escapeHtml(item.title)}</option>`).join('')
const cards = codes.map((item) => `
<div class="card">
<img class="qr" src="${item.qrDataUrl}" alt="签到码" />
<div class="muted">${escapeHtml(item.token)}</div>
<div class="muted">有效至 ${escapeHtml(item.expiresAt)}</div>
</div>`).join('')
document.getElementById('checkin').innerHTML = `
<form id="codeForm" class="card row">
<select name="activityId">${options}</select>
<input name="ttlHours" type="number" value="12" style="width:120px" />
<button class="primary" type="submit">生成签到码</button>
</form>
<div class="grid">${cards || '<div class="card">还没有签到码</div>'}</div>`
document.getElementById('codeForm').onsubmit = async (event) => {
event.preventDefault()
const body = Object.fromEntries(new FormData(event.target).entries())
await api('/api/admin/checkin-codes', { method: 'POST', body: JSON.stringify(body) })
loadCodes()
}
}
async function loadStaff() {
const users = await api('/api/admin/users')
const rows = users.map((item) => `
<tr>
<td>${escapeHtml(item.nickName || item.name || '未命名')}<div class="muted">${escapeHtml(item.phone || '')}</div></td>
<td style="font-size:12px;word-break:break-all">${escapeHtml(item.openid)}</td>
<td>${item.allowed ? '管理员' : '普通用户'}</td>
<td>
${item.allowed
? `<button class="danger" data-remove="${encodeURIComponent(item.openid)}" type="button">取消管理员</button>`
: `<button class="ghost" data-add="${escapeHtml(item.openid)}" type="button">设为管理员</button>`}
</td>
</tr>`).join('')
document.getElementById('staff').innerHTML = `
<div class="card"><strong>小程序管理员</strong>
<p class="muted">在后台设为管理员后,该用户打开小程序「往期」会直接看到增加、删除、置顶,界面上没有「管理员登录」入口。</p>
</div>
<table>
<thead><tr><th>用户</th><th>openid</th><th>状态</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="4">暂无用户,请先在小程序登录一次</td></tr>'}</tbody>
</table>`
document.querySelectorAll('[data-add]').forEach((button) => {
button.onclick = async () => {
await api('/api/admin/allowlist', { method: 'POST', body: JSON.stringify({ openid: button.dataset.add }) })
loadStaff()
}
})
document.querySelectorAll('[data-remove]').forEach((button) => {
button.onclick = async () => {
await api(`/api/admin/allowlist/${button.dataset.remove}`, { method: 'DELETE' })
loadStaff()
}
})
}
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) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[char]))
}
if (state.token) {
api('/api/admin/stats').then(boot).catch(() => showApp(false))
}
</script>
</body>
</html>