Files
wx_service/web/marketing/index.html
T
nepiedg ac49e1458c feat(marketing): 新增营销图管理模块
- 新增 marketing 模块:model/repository/service/handler 四层架构
- 数据模型:marketing_categories、marketing_templates、marketing_user_downloads
- 小程序端接口:分类列表、模板列表/详情、下载记录、广告回调
- 管理后台接口:分类/模板 CRUD、下载统计(X-Admin-Token 鉴权)
- 路由注册:接入现有 AuthMiddleware,新增 AdminTokenMiddleware
- Web 管理后台:单页面 Vue3 + Element Plus(分类管理、模板管理、数据概览)

Closes #37, #38, #39, #40

Made-with: Cursor
2026-03-06 07:36:05 +00:00

411 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>营销图管理后台</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus/dist/index.css">
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/element-plus/dist/index.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/element-plus/dist/locale/zh-cn.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; }
.login-wrapper { display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.login-card { width: 400px; }
.app-header { background: #fff; padding: 16px 24px; border-bottom: 1px solid #e8e8e8; display: flex; justify-content: space-between; align-items: center; }
.app-header h2 { font-size: 18px; color: #1a1a1a; }
.app-body { padding: 24px; max-width: 1200px; margin: 0 auto; }
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 8px; padding: 20px; text-align: center; }
.stat-card .num { font-size: 28px; font-weight: 700; color: #409eff; }
.stat-card .label { font-size: 13px; color: #999; margin-top: 4px; }
.section-card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 24px; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.section-header h3 { font-size: 16px; color: #1a1a1a; }
.tpl-thumb { width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 1px solid #eee; }
.icon-preview { width: 32px; height: 32px; object-fit: contain; }
</style>
</head>
<body>
<div id="app">
<!-- Login -->
<div v-if="!authenticated" class="login-wrapper">
<el-card class="login-card">
<template #header><h3>营销图管理后台</h3></template>
<el-form @submit.prevent="login">
<el-form-item label="API 地址">
<el-input v-model="apiBase" placeholder="http://localhost:8080"></el-input>
</el-form-item>
<el-form-item label="管理员口令">
<el-input v-model="adminToken" type="password" placeholder="X-Admin-Token" @keyup.enter="login"></el-input>
</el-form-item>
<el-button type="primary" @click="login" :loading="loginLoading" style="width:100%">登录</el-button>
</el-form>
</el-card>
</div>
<!-- Main -->
<div v-else>
<div class="app-header">
<h2>营销图管理后台</h2>
<el-button text @click="logout">退出</el-button>
</div>
<div class="app-body">
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="num">{{ stats.categoryCount }}</div>
<div class="label">分类总数</div>
</div>
<div class="stat-card">
<div class="num">{{ stats.templateCount }}</div>
<div class="label">模板总数</div>
</div>
<div class="stat-card">
<div class="num">{{ stats.totalDownloads }}</div>
<div class="label">总下载次数</div>
</div>
<div class="stat-card">
<div class="num">{{ stats.todayDownloads }}</div>
<div class="label">今日下载</div>
</div>
</div>
<!-- Categories -->
<div class="section-card">
<div class="section-header">
<h3>分类管理</h3>
<el-button type="primary" size="small" @click="openCategoryDialog()">新增分类</el-button>
</div>
<el-table :data="categories" stripe>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column label="图标" width="60">
<template #default="{row}">
<img v-if="row.icon" :src="row.icon" class="icon-preview">
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称"></el-table-column>
<el-table-column prop="sort_order" label="排序" width="80"></el-table-column>
<el-table-column label="状态" width="80">
<template #default="{row}">
<el-tag :type="row.status===1?'success':'info'" size="small">{{ row.status===1?'启用':'禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template #default="{row}">
<el-button text type="primary" size="small" @click="openCategoryDialog(row)">编辑</el-button>
<el-popconfirm title="确定删除?" @confirm="deleteCategory(row.id)">
<template #reference><el-button text type="danger" size="small">删除</el-button></template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</div>
<!-- Templates -->
<div class="section-card">
<div class="section-header">
<h3>模板管理</h3>
<div>
<el-select v-model="tplFilterCategory" placeholder="全部分类" clearable size="small" style="width:140px;margin-right:8px" @change="loadTemplates">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id"></el-option>
</el-select>
<el-button type="primary" size="small" @click="openTemplateDialog()">新增模板</el-button>
</div>
</div>
<el-table :data="templates" stripe>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column label="缩略图" width="100">
<template #default="{row}">
<img :src="row.thumbnail_url || row.image_url" class="tpl-thumb" @click="previewImage(row.image_url)">
</template>
</el-table-column>
<el-table-column prop="title" label="名称"></el-table-column>
<el-table-column label="分类" width="100">
<template #default="{row}">{{ row.category ? row.category.name : '-' }}</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="70"></el-table-column>
<el-table-column prop="download_count" label="下载" width="70"></el-table-column>
<el-table-column label="状态" width="80">
<template #default="{row}">
<el-tag :type="row.status===1?'success':'info'" size="small">{{ row.status===1?'启用':'禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template #default="{row}">
<el-button text type="primary" size="small" @click="openTemplateDialog(row)">编辑</el-button>
<el-popconfirm title="确定删除?" @confirm="deleteTemplate(row.id)">
<template #reference><el-button text type="danger" size="small">删除</el-button></template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<div style="margin-top:16px;text-align:right">
<el-pagination
v-model:current-page="tplPage"
:page-size="tplPageSize"
:total="tplTotal"
layout="prev, pager, next"
@current-change="loadTemplates"
></el-pagination>
</div>
</div>
</div>
<!-- Category Dialog -->
<el-dialog v-model="catDialogVisible" :title="catForm.id?'编辑分类':'新增分类'" width="460px">
<el-form :model="catForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="catForm.name"></el-input></el-form-item>
<el-form-item label="图标URL"><el-input v-model="catForm.icon" placeholder="可选,图标地址"></el-input></el-form-item>
<el-form-item label="排序"><el-input-number v-model="catForm.sort_order" :min="0"></el-input-number></el-form-item>
<el-form-item label="状态">
<el-switch v-model="catForm.statusBool" active-text="启用" inactive-text="禁用"></el-switch>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="catDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCategory" :loading="catSaving">保存</el-button>
</template>
</el-dialog>
<!-- Template Dialog -->
<el-dialog v-model="tplDialogVisible" :title="tplForm.id?'编辑模板':'新增模板'" width="540px">
<el-form :model="tplForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="tplForm.title"></el-input></el-form-item>
<el-form-item label="分类">
<el-select v-model="tplForm.category_id" placeholder="选择分类">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="图片URL"><el-input v-model="tplForm.image_url" placeholder="模板图片地址"></el-input></el-form-item>
<el-form-item label="缩略图URL"><el-input v-model="tplForm.thumbnail_url" placeholder="可选,缩略图地址"></el-input></el-form-item>
<el-form-item label="宽度(px)"><el-input-number v-model="tplForm.width" :min="0"></el-input-number></el-form-item>
<el-form-item label="高度(px)"><el-input-number v-model="tplForm.height" :min="0"></el-input-number></el-form-item>
<el-form-item label="排序"><el-input-number v-model="tplForm.sort_order" :min="0"></el-input-number></el-form-item>
<el-form-item label="状态">
<el-switch v-model="tplForm.statusBool" active-text="启用" inactive-text="禁用"></el-switch>
</el-form-item>
<el-form-item v-if="tplForm.image_url" label="预览">
<img :src="tplForm.image_url" style="max-width:100%;max-height:200px;border-radius:4px;border:1px solid #eee">
</el-form-item>
</el-form>
<template #footer>
<el-button @click="tplDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveTemplate" :loading="tplSaving">保存</el-button>
</template>
</el-dialog>
<!-- Image Preview -->
<el-image-viewer v-if="previewVisible" :url-list="[previewUrl]" @close="previewVisible=false"></el-image-viewer>
</div>
</div>
<script>
const { createApp, ref, reactive, onMounted } = Vue
const app = createApp({
setup() {
const apiBase = ref(localStorage.getItem('mkt_api_base') || location.origin)
const adminToken = ref(localStorage.getItem('mkt_admin_token') || '')
const authenticated = ref(false)
const loginLoading = ref(false)
const stats = reactive({ categoryCount: 0, templateCount: 0, totalDownloads: 0, todayDownloads: 0 })
const categories = ref([])
const templates = ref([])
const tplPage = ref(1)
const tplPageSize = ref(20)
const tplTotal = ref(0)
const tplFilterCategory = ref(null)
const catDialogVisible = ref(false)
const catForm = reactive({ id: null, name: '', icon: '', sort_order: 0, statusBool: true })
const catSaving = ref(false)
const tplDialogVisible = ref(false)
const tplForm = reactive({ id: null, title: '', category_id: null, image_url: '', thumbnail_url: '', width: 0, height: 0, sort_order: 0, statusBool: true })
const tplSaving = ref(false)
const previewVisible = ref(false)
const previewUrl = ref('')
async function api(method, path, body) {
const url = apiBase.value.replace(/\/$/, '') + '/api/v1' + path
const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken.value } }
if (body) opts.body = JSON.stringify(body)
const res = await fetch(url, opts)
const data = await res.json()
if (data.code && data.code !== 200) throw new Error(data.message || '请求失败')
return data.data
}
async function login() {
loginLoading.value = true
try {
await api('GET', '/admin/marketing/categories')
authenticated.value = true
localStorage.setItem('mkt_api_base', apiBase.value)
localStorage.setItem('mkt_admin_token', adminToken.value)
loadAll()
} catch (e) {
ElementPlus.ElMessage.error('登录失败: ' + e.message)
} finally {
loginLoading.value = false
}
}
function logout() {
authenticated.value = false
localStorage.removeItem('mkt_admin_token')
}
async function loadAll() {
await Promise.all([loadCategories(), loadTemplates(), loadStats()])
}
async function loadCategories() {
try {
categories.value = await api('GET', '/admin/marketing/categories') || []
stats.categoryCount = categories.value.length
} catch (e) { console.error(e) }
}
async function loadTemplates() {
try {
let path = `/admin/marketing/templates?page=${tplPage.value}&page_size=${tplPageSize.value}`
if (tplFilterCategory.value) path += `&category_id=${tplFilterCategory.value}`
const data = await api('GET', path)
templates.value = data.templates || []
tplTotal.value = data.total || 0
stats.templateCount = data.total || 0
} catch (e) { console.error(e) }
}
async function loadStats() {
try {
const data = await api('GET', '/admin/marketing/stats')
if (data) {
stats.totalDownloads = data.TotalDownloads || 0
stats.todayDownloads = data.TodayDownloads || 0
}
} catch (e) { console.error(e) }
}
function openCategoryDialog(row) {
if (row) {
Object.assign(catForm, { id: row.id, name: row.name, icon: row.icon, sort_order: row.sort_order, statusBool: row.status === 1 })
} else {
Object.assign(catForm, { id: null, name: '', icon: '', sort_order: 0, statusBool: true })
}
catDialogVisible.value = true
}
async function saveCategory() {
catSaving.value = true
try {
const body = { name: catForm.name, icon: catForm.icon, sort_order: catForm.sort_order, status: catForm.statusBool ? 1 : 0 }
if (catForm.id) {
await api('PUT', `/admin/marketing/categories/${catForm.id}`, body)
} else {
await api('POST', '/admin/marketing/categories', body)
}
catDialogVisible.value = false
ElementPlus.ElMessage.success('保存成功')
await loadCategories()
} catch (e) {
ElementPlus.ElMessage.error(e.message)
} finally {
catSaving.value = false
}
}
async function deleteCategory(id) {
try {
await api('DELETE', `/admin/marketing/categories/${id}`)
ElementPlus.ElMessage.success('删除成功')
await loadCategories()
} catch (e) {
ElementPlus.ElMessage.error(e.message)
}
}
function openTemplateDialog(row) {
if (row) {
Object.assign(tplForm, {
id: row.id, title: row.title, category_id: row.category_id,
image_url: row.image_url, thumbnail_url: row.thumbnail_url,
width: row.width, height: row.height, sort_order: row.sort_order,
statusBool: row.status === 1
})
} else {
Object.assign(tplForm, { id: null, title: '', category_id: null, image_url: '', thumbnail_url: '', width: 0, height: 0, sort_order: 0, statusBool: true })
}
tplDialogVisible.value = true
}
async function saveTemplate() {
tplSaving.value = true
try {
const body = {
title: tplForm.title, category_id: tplForm.category_id,
image_url: tplForm.image_url, thumbnail_url: tplForm.thumbnail_url,
width: tplForm.width, height: tplForm.height,
sort_order: tplForm.sort_order, status: tplForm.statusBool ? 1 : 0
}
if (tplForm.id) {
await api('PUT', `/admin/marketing/templates/${tplForm.id}`, body)
} else {
await api('POST', '/admin/marketing/templates', body)
}
tplDialogVisible.value = false
ElementPlus.ElMessage.success('保存成功')
await loadTemplates()
} catch (e) {
ElementPlus.ElMessage.error(e.message)
} finally {
tplSaving.value = false
}
}
async function deleteTemplate(id) {
try {
await api('DELETE', `/admin/marketing/templates/${id}`)
ElementPlus.ElMessage.success('删除成功')
await loadTemplates()
} catch (e) {
ElementPlus.ElMessage.error(e.message)
}
}
function previewImage(url) {
previewUrl.value = url
previewVisible.value = true
}
onMounted(() => {
if (adminToken.value) {
login()
}
})
return {
apiBase, adminToken, authenticated, loginLoading, stats,
categories, templates, tplPage, tplPageSize, tplTotal, tplFilterCategory,
catDialogVisible, catForm, catSaving,
tplDialogVisible, tplForm, tplSaving,
previewVisible, previewUrl,
login, logout, loadTemplates,
openCategoryDialog, saveCategory, deleteCategory,
openTemplateDialog, saveTemplate, deleteTemplate,
previewImage
}
}
})
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn })
app.mount('#app')
</script>
</body>
</html>