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:
+241
-20
@@ -35,10 +35,51 @@
|
||||
.split { display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; }
|
||||
.error { color: #dc2626; min-height: 1.2em; font-size: 13px; }
|
||||
img.qr { width: 140px; height: 140px; }
|
||||
.crop-modal {
|
||||
position: fixed; inset: 0; z-index: 50; display: none;
|
||||
align-items: center; justify-content: center;
|
||||
background: rgba(15, 23, 42, 0.55); padding: 16px;
|
||||
}
|
||||
.crop-modal.on { display: flex; }
|
||||
.crop-panel {
|
||||
width: min(720px, 100%); background: #fff; border-radius: 16px;
|
||||
padding: 16px; max-height: 92vh; overflow: auto;
|
||||
}
|
||||
.crop-stage {
|
||||
position: relative; width: 100%; height: min(52vh, 420px);
|
||||
background: #111827; border-radius: 12px; overflow: hidden;
|
||||
touch-action: none; user-select: none;
|
||||
}
|
||||
.crop-stage img {
|
||||
position: absolute; left: 0; top: 0;
|
||||
max-width: none; cursor: grab;
|
||||
}
|
||||
.crop-mask {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
||||
border: 2px solid #fff; border-radius: 4px;
|
||||
}
|
||||
@media (max-width: 800px) { .grid, .split { grid-template-columns: 1fr; } }
|
||||
</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>
|
||||
@@ -67,6 +108,7 @@
|
||||
<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>
|
||||
@@ -75,6 +117,7 @@
|
||||
<section id="signups" hidden></section>
|
||||
<section id="checkin" hidden></section>
|
||||
<section id="staff" hidden></section>
|
||||
<section id="settings" hidden></section>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
@@ -109,17 +152,164 @@
|
||||
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) {
|
||||
return `
|
||||
<label>封面图片地址</label>
|
||||
<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" />
|
||||
<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>
|
||||
<div class="muted" id="mediaHint" style="margin-top:8px">首页封面为宽图,上传时请裁剪出要展示的区域。</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -131,16 +321,9 @@
|
||||
if (coverFile) {
|
||||
coverFile.onchange = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
try {
|
||||
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)
|
||||
}
|
||||
openCoverCrop(file, root.querySelector('#coverUrl'), hint)
|
||||
}
|
||||
}
|
||||
if (videoFile) {
|
||||
@@ -211,7 +394,7 @@
|
||||
async function boot() {
|
||||
showApp(true)
|
||||
await loadActivities()
|
||||
await Promise.all([loadStats(), loadPosters(), loadSignups(), loadCodes(), loadStaff()])
|
||||
await Promise.all([loadStats(), loadPosters(), loadSignups(), loadCodes(), loadStaff(), loadSettings()])
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
@@ -281,7 +464,7 @@
|
||||
<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>
|
||||
@@ -335,6 +518,22 @@
|
||||
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() {
|
||||
@@ -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>`
|
||||
document.getElementById('posterFile').onchange = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
try {
|
||||
const uploaded = await uploadMedia(file)
|
||||
document.getElementById('posterImage').value = uploaded.url
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
}
|
||||
openCoverCrop(file, document.getElementById('posterImage'), null)
|
||||
}
|
||||
document.getElementById('posterForm').onsubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
@@ -385,8 +580,10 @@
|
||||
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('')
|
||||
@@ -394,7 +591,7 @@
|
||||
<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></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) => {
|
||||
button.onclick = async () => {
|
||||
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) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
|
||||
Reference in New Issue
Block a user