2026-09-29 19:01:36 +08:00
|
|
|
|
<!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; }
|
2026-10-08 13:06:43 +08:00
|
|
|
|
.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;
|
2026-10-08 13:12:42 +08:00
|
|
|
|
max-width: none; max-height: none; width: auto; height: auto;
|
|
|
|
|
|
cursor: grab; will-change: left, top, width, height;
|
2026-10-08 13:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
.crop-mask {
|
2026-10-08 13:10:35 +08:00
|
|
|
|
position: absolute; left: 0; top: 0; pointer-events: none;
|
2026-10-08 13:06:43 +08:00
|
|
|
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
|
|
|
|
|
border: 2px solid #fff; border-radius: 4px;
|
|
|
|
|
|
}
|
2026-09-29 19:01:36 +08:00
|
|
|
|
@media (max-width: 800px) { .grid, .split { grid-template-columns: 1fr; } }
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
2026-10-08 13:06:43 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-09-29 19:01:36 +08:00
|
|
|
|
<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>
|
2026-10-08 13:06:43 +08:00
|
|
|
|
<button type="button" data-tab="settings">提醒设置</button>
|
2026-09-29 19:01:36 +08:00
|
|
|
|
</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>
|
2026-10-08 13:06:43 +08:00
|
|
|
|
<section id="settings" hidden></section>
|
2026-09-29 19:01:36 +08:00
|
|
|
|
</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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 13:06:43 +08:00
|
|
|
|
// 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,
|
2026-10-08 13:19:15 +08:00
|
|
|
|
objectUrl: '',
|
2026-10-08 13:06:43 +08:00
|
|
|
|
naturalW: 0,
|
|
|
|
|
|
naturalH: 0,
|
|
|
|
|
|
scale: 1,
|
|
|
|
|
|
x: 0,
|
|
|
|
|
|
y: 0,
|
|
|
|
|
|
dragging: false,
|
|
|
|
|
|
lastX: 0,
|
|
|
|
|
|
lastY: 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 13:10:35 +08:00
|
|
|
|
function maskSize() {
|
2026-10-08 13:06:43 +08:00
|
|
|
|
const stage = document.getElementById('cropStage')
|
2026-10-08 13:12:42 +08:00
|
|
|
|
const sw = Math.max(stage.clientWidth, 1)
|
|
|
|
|
|
const sh = Math.max(stage.clientHeight, 1)
|
|
|
|
|
|
let maskW = Math.max(sw - 32, 1)
|
2026-10-08 13:06:43 +08:00
|
|
|
|
let maskH = maskW / COVER_RATIO
|
|
|
|
|
|
if (maskH > sh - 32) {
|
2026-10-08 13:12:42 +08:00
|
|
|
|
maskH = Math.max(sh - 32, 1)
|
2026-10-08 13:06:43 +08:00
|
|
|
|
maskW = maskH * COVER_RATIO
|
|
|
|
|
|
}
|
2026-10-08 13:10:35 +08:00
|
|
|
|
return {
|
|
|
|
|
|
maskW: maskW,
|
|
|
|
|
|
maskH: maskH,
|
|
|
|
|
|
maskLeft: (sw - maskW) / 2,
|
2026-10-08 13:12:42 +08:00
|
|
|
|
maskTop: (sh - maskH) / 2,
|
|
|
|
|
|
stageW: sw,
|
|
|
|
|
|
stageH: sh
|
2026-10-08 13:10:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function minCropScale() {
|
2026-10-08 13:12:42 +08:00
|
|
|
|
if (!cropState.naturalW || !cropState.naturalH) return 1
|
2026-10-08 13:10:35 +08:00
|
|
|
|
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')
|
2026-10-08 13:12:42 +08:00
|
|
|
|
if (!stage || !cropState.naturalW || !cropState.scale) return
|
2026-10-08 13:10:35 +08:00
|
|
|
|
const size = maskSize()
|
|
|
|
|
|
mask.style.width = `${size.maskW}px`
|
|
|
|
|
|
mask.style.height = `${size.maskH}px`
|
|
|
|
|
|
mask.style.left = `${size.maskLeft}px`
|
|
|
|
|
|
mask.style.top = `${size.maskTop}px`
|
2026-10-08 13:06:43 +08:00
|
|
|
|
|
2026-10-08 13:12:42 +08:00
|
|
|
|
const w = cropState.naturalW * cropState.scale
|
|
|
|
|
|
const h = cropState.naturalH * cropState.scale
|
2026-10-08 13:19:15 +08:00
|
|
|
|
// Cover the crop window so zoom/pan never leaves it empty.
|
|
|
|
|
|
const minX = size.maskLeft + size.maskW - w
|
|
|
|
|
|
const maxX = size.maskLeft
|
|
|
|
|
|
const minY = size.maskTop + size.maskH - h
|
|
|
|
|
|
const maxY = size.maskTop
|
|
|
|
|
|
cropState.x = Math.min(maxX, Math.max(minX, cropState.x))
|
|
|
|
|
|
cropState.y = Math.min(maxY, Math.max(minY, cropState.y))
|
2026-10-08 13:12:42 +08:00
|
|
|
|
img.style.width = `${w}px`
|
|
|
|
|
|
img.style.height = `${h}px`
|
|
|
|
|
|
img.style.left = `${cropState.x}px`
|
|
|
|
|
|
img.style.top = `${cropState.y}px`
|
|
|
|
|
|
img.style.transform = 'none'
|
2026-10-08 13:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fitCropImage() {
|
2026-10-08 13:12:42 +08:00
|
|
|
|
const size = maskSize()
|
|
|
|
|
|
// Modal must be visible first, otherwise stage size is 0 and scale collapses.
|
|
|
|
|
|
if (size.stageW < 40 || size.stageH < 40) return false
|
2026-10-08 13:10:35 +08:00
|
|
|
|
const scale = minCropScale()
|
2026-10-08 13:06:43 +08:00
|
|
|
|
cropState.scale = scale
|
2026-10-08 13:12:42 +08:00
|
|
|
|
cropState.x = (size.stageW - cropState.naturalW * scale) / 2
|
|
|
|
|
|
cropState.y = (size.stageH - cropState.naturalH * scale) / 2
|
2026-10-08 13:06:43 +08:00
|
|
|
|
layoutCrop()
|
2026-10-08 13:12:42 +08:00
|
|
|
|
return true
|
2026-10-08 13:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 13:10:35 +08:00
|
|
|
|
function zoomCrop(factor) {
|
|
|
|
|
|
if (!cropState.naturalW) return
|
|
|
|
|
|
const size = maskSize()
|
2026-10-08 13:12:42 +08:00
|
|
|
|
if (size.stageW < 40 || size.stageH < 40) return
|
|
|
|
|
|
if (!cropState.scale || !Number.isFinite(cropState.scale)) {
|
|
|
|
|
|
fitCropImage()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const before = cropState.scale
|
|
|
|
|
|
const minScale = minCropScale()
|
|
|
|
|
|
const next = Math.min(Math.max(before * factor, minScale), minScale * 8)
|
|
|
|
|
|
if (!Number.isFinite(next) || Math.abs(next - before) < 0.0001) return
|
2026-10-08 13:10:35 +08:00
|
|
|
|
const cx = size.maskLeft + size.maskW / 2
|
|
|
|
|
|
const cy = size.maskTop + size.maskH / 2
|
|
|
|
|
|
// Keep the point under the crop center stable while zooming.
|
2026-10-08 13:12:42 +08:00
|
|
|
|
cropState.x = cx - ((cx - cropState.x) * next) / before
|
|
|
|
|
|
cropState.y = cy - ((cy - cropState.y) * next) / before
|
2026-10-08 13:10:35 +08:00
|
|
|
|
cropState.scale = next
|
|
|
|
|
|
layoutCrop()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 13:06:43 +08:00
|
|
|
|
function openCoverCrop(file, targetInput, hint) {
|
|
|
|
|
|
const modal = document.getElementById('cropModal')
|
|
|
|
|
|
const img = document.getElementById('cropImage')
|
2026-10-08 13:19:15 +08:00
|
|
|
|
if (cropState.objectUrl) URL.revokeObjectURL(cropState.objectUrl)
|
2026-10-08 13:06:43 +08:00
|
|
|
|
cropState.targetInput = targetInput
|
|
|
|
|
|
cropState.hint = hint
|
2026-10-08 13:19:15 +08:00
|
|
|
|
cropState.scale = 1
|
2026-10-08 13:12:42 +08:00
|
|
|
|
cropState.x = 0
|
|
|
|
|
|
cropState.y = 0
|
2026-10-08 13:19:15 +08:00
|
|
|
|
// Keep the blob URL alive until the dialog closes. Revoking it on load
|
|
|
|
|
|
// makes the image vanish the next time width/height change (zoom).
|
|
|
|
|
|
cropState.objectUrl = URL.createObjectURL(file)
|
2026-10-08 13:06:43 +08:00
|
|
|
|
img.onload = () => {
|
|
|
|
|
|
cropState.naturalW = img.naturalWidth
|
|
|
|
|
|
cropState.naturalH = img.naturalHeight
|
|
|
|
|
|
modal.classList.add('on')
|
2026-10-08 13:12:42 +08:00
|
|
|
|
requestAnimationFrame(() => {
|
2026-10-08 13:19:15 +08:00
|
|
|
|
if (!fitCropImage()) setTimeout(() => fitCropImage(), 50)
|
2026-10-08 13:12:42 +08:00
|
|
|
|
})
|
2026-10-08 13:06:43 +08:00
|
|
|
|
}
|
2026-10-08 13:19:15 +08:00
|
|
|
|
img.src = cropState.objectUrl
|
2026-10-08 13:06:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeCoverCrop() {
|
|
|
|
|
|
document.getElementById('cropModal').classList.remove('on')
|
2026-10-08 13:19:15 +08:00
|
|
|
|
const img = document.getElementById('cropImage')
|
|
|
|
|
|
img.removeAttribute('src')
|
|
|
|
|
|
if (cropState.objectUrl) {
|
|
|
|
|
|
URL.revokeObjectURL(cropState.objectUrl)
|
|
|
|
|
|
cropState.objectUrl = ''
|
|
|
|
|
|
}
|
2026-10-08 13:06:43 +08:00
|
|
|
|
cropState.targetInput = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function exportCroppedFile() {
|
|
|
|
|
|
const img = document.getElementById('cropImage')
|
2026-10-08 13:12:42 +08:00
|
|
|
|
const size = maskSize()
|
|
|
|
|
|
const sx = (size.maskLeft - cropState.x) / cropState.scale
|
|
|
|
|
|
const sy = (size.maskTop - cropState.y) / cropState.scale
|
|
|
|
|
|
const sw = size.maskW / cropState.scale
|
|
|
|
|
|
const sh = size.maskH / cropState.scale
|
2026-10-08 13:06:43 +08:00
|
|
|
|
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)
|
2026-10-08 13:12:42 +08:00
|
|
|
|
return new Promise((resolve, reject) => {
|
2026-10-08 13:06:43 +08:00
|
|
|
|
canvas.toBlob((blob) => {
|
2026-10-08 13:12:42 +08:00
|
|
|
|
if (!blob) {
|
|
|
|
|
|
reject(new Error('裁剪失败'))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-10-08 13:06:43 +08:00
|
|
|
|
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 })
|
2026-10-08 13:10:35 +08:00
|
|
|
|
document.getElementById('cropZoomIn').onclick = (event) => {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
zoomCrop(1.15)
|
2026-10-08 13:06:43 +08:00
|
|
|
|
}
|
2026-10-08 13:10:35 +08:00
|
|
|
|
document.getElementById('cropZoomOut').onclick = (event) => {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
zoomCrop(1 / 1.15)
|
2026-10-08 13:06:43 +08:00
|
|
|
|
}
|
2026-10-08 13:10:35 +08:00
|
|
|
|
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 })
|
2026-10-08 13:06:43 +08:00
|
|
|
|
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()
|
|
|
|
|
|
})
|
|
|
|
|
|
})()
|
|
|
|
|
|
|
2026-09-29 19:01:36 +08:00
|
|
|
|
function mediaFields(value) {
|
|
|
|
|
|
return `
|
|
|
|
|
|
<label>封面图片地址</label>
|
|
|
|
|
|
<input name="cover" id="coverUrl" value="${escapeHtml(value.cover || '')}" placeholder="/static/uploads/..." />
|
2026-10-08 13:06:43 +08:00
|
|
|
|
<label>上传封面图片(可选区域裁剪)</label>
|
2026-09-29 19:01:36 +08:00
|
|
|
|
<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" />
|
2026-10-08 13:06:43 +08:00
|
|
|
|
<div class="muted" id="mediaHint" style="margin-top:8px">首页封面为宽图,上传时请裁剪出要展示的区域。</div>
|
2026-09-29 19:01:36 +08:00
|
|
|
|
`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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]
|
2026-10-08 13:06:43 +08:00
|
|
|
|
event.target.value = ''
|
2026-09-29 19:01:36 +08:00
|
|
|
|
if (!file) return
|
2026-10-08 13:06:43 +08:00
|
|
|
|
openCoverCrop(file, root.querySelector('#coverUrl'), hint)
|
2026-09-29 19:01:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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()
|
2026-10-08 13:06:43 +08:00
|
|
|
|
await Promise.all([loadStats(), loadPosters(), loadSignups(), loadCodes(), loadStaff(), loadSettings()])
|
2026-09-29 19:01:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>`
|
2026-10-08 13:06:43 +08:00
|
|
|
|
: `<button class="ghost" data-remind="${item.id}" type="button">发提醒</button>`}
|
2026-09-29 19:01:36 +08:00
|
|
|
|
<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()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-10-08 13:06:43 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-09-29 19:01:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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]
|
2026-10-08 13:06:43 +08:00
|
|
|
|
event.target.value = ''
|
2026-09-29 19:01:36 +08:00
|
|
|
|
if (!file) return
|
2026-10-08 13:06:43 +08:00
|
|
|
|
openCoverCrop(file, document.getElementById('posterImage'), null)
|
2026-09-29 19:01:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
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>
|
2026-10-08 13:06:43 +08:00
|
|
|
|
<td>${escapeHtml(item.company || '')}<div class="muted">${escapeHtml(item.position || '')}</div></td>
|
2026-09-29 19:01:36 +08:00
|
|
|
|
<td>${escapeHtml(item.activityTitle || '')}</td>
|
|
|
|
|
|
<td>${escapeHtml(item.sourceSharer || '直接访问')}</td>
|
2026-10-08 13:06:43 +08:00
|
|
|
|
<td>${item.remindAccepted ? '已订阅' : '未订阅'}</td>
|
2026-09-29 19:01:36 +08:00
|
|
|
|
<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>
|
2026-10-08 13:06:43 +08:00
|
|
|
|
<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>`
|
2026-09-29 19:01:36 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 13:06:43 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 19:01:36 +08:00
|
|
|
|
function escapeHtml(value) {
|
|
|
|
|
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
|
|
|
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
|
|
|
|
}[char]))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (state.token) {
|
|
|
|
|
|
api('/api/admin/stats').then(boot).catch(() => showApp(false))
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|