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
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user