// 校跑侠 · 管理后台前端逻辑(原生 JS 单页) const content = document.getElementById('content') const pageTitle = document.getElementById('pageTitle') // ---------- 登录态 / token ---------- const ADMIN_TOKEN_KEY = 'xpx_admin_token' function adminToken() { return localStorage.getItem(ADMIN_TOKEN_KEY) || '' } function setAdminToken(t) { if (t) localStorage.setItem(ADMIN_TOKEN_KEY, t) else localStorage.removeItem(ADMIN_TOKEN_KEY) } function authHeaders(extra = {}) { const h = { ...extra } const t = adminToken() if (t) h['Authorization'] = 'Bearer ' + t return h } function handleUnauthorized() { setAdminToken('') showLogin() } // ---------- 通用工具 ---------- async function api(path) { const r = await fetch(path, { headers: authHeaders() }) if (r.status === 401) { handleUnauthorized() throw new Error('登录已失效,请重新登录') } const j = await r.json() if (j.code !== 0) throw new Error(j.msg || '请求失败') return j.data } async function apiPatch(path, body) { const r = await fetch(path, { method: 'PATCH', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body) }) if (r.status === 401) { handleUnauthorized() throw new Error('登录已失效,请重新登录') } const j = await r.json() if (j.code !== 0) throw new Error(j.msg || '操作失败') return j.data } function esc(s) { return String(s == null ? '' : s).replace( /[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]) ) } function fmtDate(iso) { const d = new Date(iso) const p = (n) => String(n).padStart(2, '0') return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}` } const STATUS = { waiting: ['待接单', 'waiting'], running: ['配送中', 'running'], done: ['已完成', 'done'], canceled: ['已取消', 'canceled'] } function badge(s) { const [t, c] = STATUS[s] || [s, 'canceled'] return `${t}` } let toastTimer = null function toast(msg) { const t = document.getElementById('toast') t.textContent = msg t.hidden = false clearTimeout(toastTimer) toastTimer = setTimeout(() => (t.hidden = true), 1800) } function pager(res, action) { const totalPage = Math.max(1, Math.ceil(res.total / res.pageSize)) return `
共 ${res.total} 条 · 第 ${res.page}/${totalPage} 页
` } // ---------- 路由 ---------- function currentRoute() { const h = location.hash.replace(/^#\/?/, '') return h || 'dashboard' } function setMenu(r) { document.querySelectorAll('.menu__item').forEach((a) => { a.classList.toggle('is-active', a.dataset.route === r) }) pageTitle.textContent = { dashboard: '仪表盘', orders: '订单管理', services: '服务管理', customers: '客户管理', confessions: '表白墙' }[r] || '仪表盘' } function route() { const r = currentRoute() setMenu(r) if (r === 'orders') renderOrders() else if (r === 'services') renderServices() else if (r === 'customers') renderCustomers() else if (r === 'confessions') renderConfessions() else renderDashboard() } document.querySelectorAll('.menu__item').forEach((a) => { a.addEventListener('click', () => (location.hash = '#/' + a.dataset.route)) }) window.addEventListener('hashchange', route) // ---------- 仪表盘 ---------- async function renderDashboard() { content.innerHTML = '
加载中…
' try { const s = await api('/api/admin/stats') content.innerHTML = `
订单总数
${s.totalOrders}
待接单
${s.byStatus.waiting || 0}
配送中
${s.byStatus.running || 0}
已完成
${s.byStatus.done || 0}
已取消
${s.byStatus.canceled || 0}
成交额 GMV
¥${s.gmv}
今日订单
${s.todayOrders}
客户数 / 活跃
${s.totalCustomers} / ${s.activeCustomers}
快捷入口
` } catch (e) { content.innerHTML = `
加载失败:${esc(e.message)}
` } } // ---------- 订单管理 ---------- const ordersState = { status: 'all', keyword: '', page: 1, pageSize: 10 } const ORDER_TABS = [ ['all', '全部'], ['waiting', '待接单'], ['running', '配送中'], ['done', '已完成'], ['canceled', '已取消'] ] function renderOrders() { content.innerHTML = `
${ORDER_TABS.map( ([k, t]) => `${t}` ).join('')}
订单号客户类型路线金额 状态骑手下单时间操作
加载中…
` document.getElementById('orderTabs').addEventListener('click', (e) => { const tab = e.target.closest('.tab') if (!tab) return ordersState.status = tab.dataset.status ordersState.page = 1 loadOrders() }) document.getElementById('orderSearch').addEventListener('click', () => { ordersState.keyword = document.getElementById('orderKw').value.trim() ordersState.page = 1 loadOrders() }) document.getElementById('orderKw').addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById('orderSearch').click() }) loadOrders() } async function loadOrders() { document.querySelectorAll('#orderTabs .tab').forEach((t) => t.classList.toggle('is-active', t.dataset.status === ordersState.status) ) const body = document.getElementById('orderBody') body.innerHTML = '加载中…' try { const q = new URLSearchParams({ status: ordersState.status, keyword: ordersState.keyword, page: ordersState.page, pageSize: ordersState.pageSize }) const res = await api('/api/admin/orders?' + q.toString()) if (!res.list.length) { body.innerHTML = '暂无订单' } else { body.innerHTML = res.list .map( (o) => ` ${o.id} ${esc(o.customerName)}
${esc(o.customerPhone)} ${esc(o.type)} ${esc(o.from)} → ${esc(o.to)} ¥${o.total} ${badge(o.status)} ${o.riderName ? esc(o.riderName) : ''} ${fmtDate(o.createdAt)} ${orderActions(o)} ` ) .join('') } document.getElementById('orderPager').innerHTML = pager(res, 'order-page') } catch (e) { body.innerHTML = `加载失败:${esc(e.message)}` } } function orderActions(o) { const btn = (status, label, danger) => `` let b = [] if (o.status === 'waiting') b = [btn('running', '接单'), btn('canceled', '取消', true)] else if (o.status === 'running') b = [btn('done', '完成'), btn('canceled', '取消', true)] else b = [''] return `
${b.join('')}
` } // ---------- 客户管理 ---------- const custState = { keyword: '', page: 1, pageSize: 10 } function renderCustomers() { content.innerHTML = `
客户列表
客户ID姓名手机号校区 下单数累计消费状态注册时间操作
加载中…
` document.getElementById('custSearch').addEventListener('click', () => { custState.keyword = document.getElementById('custKw').value.trim() custState.page = 1 loadCustomers() }) document.getElementById('custKw').addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById('custSearch').click() }) loadCustomers() } async function loadCustomers() { const body = document.getElementById('custBody') body.innerHTML = '加载中…' try { const q = new URLSearchParams({ keyword: custState.keyword, page: custState.page, pageSize: custState.pageSize }) const res = await api('/api/admin/customers?' + q.toString()) if (!res.list.length) { body.innerHTML = '暂无客户' } else { body.innerHTML = res.list .map( (c) => ` ${c.id} ${esc(c.name)} ${esc(c.phone)} ${esc(c.campus)} ${c.orderCount} ¥${c.totalSpend} ${ c.status === 'active' ? '正常' : '已禁用' } ${fmtDate(c.createdAt)} ` ) .join('') } document.getElementById('custPager').innerHTML = pager(res, 'cust-page') } catch (e) { body.innerHTML = `加载失败:${esc(e.message)}` } } // ---------- 服务管理 ---------- const SERVICE_FORM_TEMPLATE = `
用于前端识别,不能重复
对应前端图标库中的 key
` let editServiceId = null function renderServices() { content.innerHTML = `
服务项目配置
排序 图标 名称 Key 基础价 状态 创建时间 操作
加载中…
` loadServices() } async function loadServices() { const body = document.getElementById('svcBody') body.innerHTML = '加载中…' try { const services = await api('/api/admin/services') if (!services.length) { body.innerHTML = '暂无服务' } else { body.innerHTML = services .sort((a, b) => a.sort - b.sort) .map(s => { return ` ${s.sort} ${esc(s.name)} ${esc(s.key)} ¥${s.base} ${s.status === 'active' ? '启用' : '禁用'} ${fmtDate(s.createdAt)} ${serviceActions(s)} ` }) .join('') } } catch (e) { body.innerHTML = `加载失败:${esc(e.message)}` } } function serviceActions(svc) { const toggleLabel = svc.status === 'active' ? '下架' : '上架' const toggleStatus = svc.status === 'active' ? 'inactive' : 'active' return `
` } async function openServiceModal(svc = null) { const modal = document.getElementById('modal') const title = document.getElementById('modalTitle') const bodyEl = document.getElementById('modalBody') modal.hidden = false title.textContent = svc ? '编辑服务' : '新增服务' bodyEl.innerHTML = `
${SERVICE_FORM_TEMPLATE}
` if (svc) { document.getElementById('svcName').value = svc.name document.getElementById('svcKey').value = svc.key document.getElementById('svcIcon').value = svc.icon || 'help' document.getElementById('svcBase').value = svc.base document.getElementById('svcSort').value = svc.sort document.getElementById('svcStatus').value = svc.status editServiceId = svc.id } else { editServiceId = null } } async function submitServiceForm() { const name = document.getElementById('svcName').value.trim() const key = document.getElementById('svcKey').value.trim().toLowerCase() const icon = document.getElementById('svcIcon').value const baseFee = parseFloat(document.getElementById('svcBase').value) || 4 const sort = parseInt(document.getElementById('svcSort').value) || 999 const status = document.getElementById('svcStatus').value if (!name || !key) { toast('请填写必要字段') return } try { const url = `/api/admin/services${editServiceId ? '/' + editServiceId : ''}` const method = editServiceId ? 'PUT' : 'POST' const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + adminToken() }, body: JSON.stringify({ name, key, icon, baseFee, sort, status }) }) const result = await res.json() if (result.code !== 0) { throw new Error(result.msg || '操作失败') } toast(editServiceId ? '修改成功' : '新增成功') document.getElementById('modal').hidden = true loadServices() } catch (err) { toast(err.message) } } async function deleteService(id, hasOrders) { if (hasOrders) { toast('该服务有订单,不能删除,可先「下架」隐藏') return } if (!confirm('确定要删除此服务吗?')) return try { const res = await fetch(`/api/admin/services/${id}`, { method: 'DELETE', headers: {'Authorization': 'Bearer admin-token'} }) const result = await res.json() if (result.code !== 0) { throw new Error(result.msg || '删除失败') } toast('删除成功') loadServices() } catch (err) { toast(err.message) } } // 上架/下架(切换 status,下架后 H5 首页不再展示该服务) async function toggleService(id, status) { try { const res = await fetch(`/api/admin/services/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + adminToken() }, body: JSON.stringify({ status }) }) const result = await res.json() if (result.code !== 0) { throw new Error(result.msg || '操作失败') } toast(status === 'active' ? '已上架' : '已下架') loadServices() } catch (err) { toast(err.message) } } // ---------- 表白墙审核 ---------- const confState = { status: 'all', page: 1, pageSize: 10 } const CONF_TABS = [ ['all', '全部'], ['pending', '待审核'], ['approved', '已通过'], ['rejected', '已驳回'] ] const CONF_STATUS = { pending: ['待审核', 'waiting'], approved: ['已通过', 'done'], rejected: ['已驳回', 'canceled'] } function confBadge(s) { const [t, c] = CONF_STATUS[s] || [s, 'canceled'] return `${t}` } function renderConfessions() { content.innerHTML = `
${CONF_TABS.map( ([k, t]) => `${t}` ).join('')}
内容署名 点赞评论状态 时间操作
加载中…
` document.getElementById('confTabs').addEventListener('click', (e) => { const tab = e.target.closest('.tab') if (!tab) return confState.status = tab.dataset.status confState.page = 1 loadConfessions() }) loadConfessions() } async function loadConfessions() { document.querySelectorAll('#confTabs .tab').forEach((t) => t.classList.toggle('is-active', t.dataset.status === confState.status) ) const body = document.getElementById('confBody') body.innerHTML = '加载中…' try { const q = new URLSearchParams({ status: confState.status, page: confState.page, pageSize: confState.pageSize }) const res = await api('/api/admin/confessions?' + q.toString()) if (!res.list.length) { body.innerHTML = '暂无表白' } else { body.innerHTML = res.list .map( (c) => `
${esc(c.content)}
${c.to ? esc(c.to) : ''} ${esc(c.author)}${c.anonymous ? ' (匿名)' : ''} ${c.likes} ${c.commentCount} ${confBadge(c.status)} ${fmtDate(c.createdAt)} ${confActions(c)} ` ) .join('') } document.getElementById('confPager').innerHTML = pager(res, 'conf-page') } catch (e) { body.innerHTML = `加载失败:${esc(e.message)}` } } function confActions(c) { const btn = (status, label, danger) => `` const del = `` let ops = [] if (c.status === 'pending') ops = [btn('approved', '通过'), btn('rejected', '驳回', true)] else if (c.status === 'approved') ops = [btn('rejected', '下架', true)] else ops = [btn('approved', '恢复')] ops.push(del) return `
${ops.join('')}
` } async function confSetStatus(id, status) { const res = await fetch(`/api/admin/confessions/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin-token' }, body: JSON.stringify({ status }) }) const j = await res.json() if (j.code !== 0) throw new Error(j.msg || '操作失败') return j.data } async function confDelete(id) { const res = await fetch(`/api/admin/confessions/${id}`, { method: 'DELETE', headers: { Authorization: 'Bearer admin-token' } }) const j = await res.json() if (j.code !== 0) throw new Error(j.msg || '删除失败') return j.data } // ---------- 客户详情弹窗 ---------- async function openCustomer(id) { const modal = document.getElementById('modal') const title = document.getElementById('modalTitle') const bodyEl = document.getElementById('modalBody') bodyEl.innerHTML = '
加载中…
' modal.hidden = false try { const { customer: c, orders } = await api('/api/admin/customers/' + id) title.textContent = `客户详情 · ${c.name}` bodyEl.innerHTML = `
客户 ID:${c.id}
姓名:${esc(c.name)}
手机号:${esc(c.phone)}
校区:${esc(c.campus)}
钱包余额:¥${c.balance}
优惠券:${c.coupons} 张
积分:${c.points}
累计消费:¥${c.totalSpend}
历史订单(${orders.length})
${ orders.length ? orders .map( (o) => `` ) .join('') : '' }
订单号类型金额状态时间
${o.id}${esc(o.type)}¥${o.total}${badge( o.status )}${fmtDate(o.createdAt)}
该客户暂无订单
` } catch (e) { bodyEl.innerHTML = `
加载失败:${esc(e.message)}
` } } document.querySelectorAll('[data-close]').forEach((el) => el.addEventListener('click', () => (document.getElementById('modal').hidden = true)) ) // ---------- 全局事件委托(操作按钮/分页/查看) ---------- document.addEventListener('click', async (e) => { const btn = e.target.closest('[data-action]') if (!btn) return const action = btn.dataset.action if (action.startsWith('svc-')) { const id = btn.dataset.id if (action === 'svc-add') { openServiceModal(null) } else if (action === 'svc-edit') { try { const svc = await api('/api/admin/services/' + id) openServiceModal(svc) } catch (err) { toast(err.message) } } else if (action === 'svc-toggle') { toggleService(id, btn.dataset.status) } else if (action === 'svc-delete') { const hasOrders = btn.dataset.hasorders === 'true' deleteService(id, hasOrders) } } else if (action === 'order-status') { try { await apiPatch(`/api/admin/orders/${btn.dataset.id}/status`, { status: btn.dataset.status }) toast('操作成功') loadOrders() } catch (err) { toast(err.message) } } else if (action === 'order-page') { ordersState.page = Number(btn.dataset.page) loadOrders() } else if (action === 'cust-page') { custState.page = Number(btn.dataset.page) loadCustomers() } else if (action === 'cust-view') { openCustomer(btn.dataset.id) } else if (action === 'conf-status') { try { await confSetStatus(btn.dataset.id, btn.dataset.status) toast('操作成功') loadConfessions() } catch (err) { toast(err.message) } } else if (action === 'conf-delete') { if (!confirm('确定删除这条表白吗?')) return try { await confDelete(btn.dataset.id) toast('已删除') loadConfessions() } catch (err) { toast(err.message) } } else if (action === 'conf-page') { confState.page = Number(btn.dataset.page) loadConfessions() } }) // ---------- 登录 / 启动 ---------- function showLogin() { document.getElementById('loginView').hidden = false document.getElementById('appLayout').style.display = 'none' } function showApp() { document.getElementById('loginView').hidden = true document.getElementById('appLayout').style.display = '' route() } async function doLogin(e) { if (e) e.preventDefault() const username = document.getElementById('loginUser').value.trim() const password = document.getElementById('loginPass').value const errEl = document.getElementById('loginErr') errEl.textContent = '' try { const r = await fetch('/api/admin/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }) const j = await r.json() if (j.code !== 0) throw new Error(j.msg || '登录失败') setAdminToken(j.data.token) document.getElementById('adminName').textContent = j.data.username || 'admin' document.getElementById('loginPass').value = '' showApp() } catch (err) { errEl.textContent = err.message } } function adminLogout() { fetch('/api/admin/auth/logout', { method: 'POST', headers: authHeaders() }).catch(() => {}) setAdminToken('') showLogin() } document.getElementById('loginForm').addEventListener('submit', doLogin) document.getElementById('logoutBtn').addEventListener('click', adminLogout) // 启动:有 token 进入后台,否则显示登录页 if (adminToken()) showApp() else showLogin()