// 校跑侠 · 管理后台前端逻辑(原生 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 `
`
}
// ---------- 路由 ----------
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.byStatus.waiting || 0}
配送中
${s.byStatus.running || 0}
已完成
${s.byStatus.done || 0}
已取消
${s.byStatus.canceled || 0}
客户数 / 活跃
${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 = `
| 订单号 | 客户 | 类型 | 路线 | 金额 |
状态 | 骑手 | 下单时间 | 操作 |
| 加载中… |
`
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) =>
`| ${o.id} | ${esc(o.type)} | ¥${o.total} | ${badge(
o.status
)} | ${fmtDate(o.createdAt)} |
`
)
.join('')
: '| 该客户暂无订单 |
'
}
`
} 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()