feat: move marketing admin to /api/admin and remove built-in page

This commit is contained in:
root
2026-03-10 01:14:30 +08:00
parent d8623fab8f
commit 59508efb05
3 changed files with 27 additions and 522 deletions
+26 -1
View File
@@ -4,9 +4,16 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
adminhandler "wx_service/internal/admin" adminhandler "wx_service/internal/admin"
marketinghandler "wx_service/internal/marketing/handler"
) )
func registerAdminRoutes(router *gin.Engine, handler *adminhandler.Handler) { func registerAdminRoutes(
router *gin.Engine,
handler *adminhandler.Handler,
categoryHandler *marketinghandler.CategoryHandler,
templateHandler *marketinghandler.TemplateHandler,
downloadHandler *marketinghandler.DownloadHandler,
) {
if handler == nil { if handler == nil {
return return
} }
@@ -39,6 +46,24 @@ func registerAdminRoutes(router *gin.Engine, handler *adminhandler.Handler) {
protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes) protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes)
protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes) protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes)
protected.POST("/memberships/redeem-codes/:id/status", handler.UpdateMembershipRedeemCodeStatus) protected.POST("/memberships/redeem-codes/:id/status", handler.UpdateMembershipRedeemCodeStatus)
if categoryHandler != nil && templateHandler != nil && downloadHandler != nil {
marketing := protected.Group("/marketing")
{
marketing.GET("/categories", categoryHandler.AdminList)
marketing.POST("/categories", categoryHandler.AdminCreate)
marketing.PUT("/categories/:id", categoryHandler.AdminUpdate)
marketing.DELETE("/categories/:id", categoryHandler.AdminDelete)
marketing.GET("/templates", templateHandler.AdminList)
marketing.POST("/templates", templateHandler.AdminCreate)
marketing.PUT("/templates/:id", templateHandler.AdminUpdate)
marketing.DELETE("/templates/:id", templateHandler.AdminDelete)
marketing.GET("/stats", downloadHandler.AdminStats)
marketing.POST("/upload/qiniu/token", downloadHandler.AdminQiniuToken)
}
}
} }
} }
} }
+1 -31
View File
@@ -2,8 +2,6 @@ package routes
import ( import (
"net/http" "net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@@ -22,29 +20,6 @@ import (
smokehandler "wx_service/internal/smoke/handler" smokehandler "wx_service/internal/smoke/handler"
) )
func resolveMarketingPage() string {
candidates := []string{
filepath.Join("web", "marketing", "index.html"),
filepath.Join("..", "web", "marketing", "index.html"),
}
if executable, err := os.Executable(); err == nil {
exeDir := filepath.Dir(executable)
candidates = append(candidates,
filepath.Join(exeDir, "web", "marketing", "index.html"),
filepath.Join(exeDir, "..", "web", "marketing", "index.html"),
)
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return filepath.Join("web", "marketing", "index.html")
}
func Register( func Register(
router *gin.Engine, router *gin.Engine,
db *gorm.DB, db *gorm.DB,
@@ -93,7 +68,7 @@ func Register(
registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler) registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
} }
registerAdminRoutes(router, adminHandler) registerAdminRoutes(router, adminHandler, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
// 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。 // 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。
expiryAPI := router.Group("/api/expiry") expiryAPI := router.Group("/api/expiry")
@@ -108,11 +83,6 @@ func Register(
} }
} }
// Web 管理后台静态文件
marketingPage := resolveMarketingPage()
router.StaticFile("/admin/marketing", marketingPage)
router.StaticFile("/admin/marketing/", marketingPage)
// 健康检查:用于容器/负载均衡探活 // 健康检查:用于容器/负载均衡探活
router.GET("/healthz", func(c *gin.Context) { router.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
-490
View File
@@ -1,490 +0,0 @@
<!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; }
.upload-inline { display: flex; gap: 8px; width: 100%; }
.upload-inline .el-input { flex: 1; }
</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">
<div class="upload-inline">
<el-input v-model="catForm.icon" placeholder="可选,图标地址"></el-input>
<el-button :loading="catIconUploading" @click="pickAndUpload('category_icon')">上传</el-button>
</div>
</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">
<div class="upload-inline">
<el-input v-model="tplForm.image_url" placeholder="模板图片地址"></el-input>
<el-button :loading="tplImageUploading" @click="pickAndUpload('template_image')">上传</el-button>
</div>
</el-form-item>
<el-form-item label="缩略图URL">
<div class="upload-inline">
<el-input v-model="tplForm.thumbnail_url" placeholder="可选,缩略图地址"></el-input>
<el-button :loading="tplThumbUploading" @click="pickAndUpload('template_thumb')">上传</el-button>
</div>
</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 catIconUploading = 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 tplImageUploading = ref(false)
const tplThumbUploading = 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 requestUploadToken(filename) {
return await api('POST', '/admin/marketing/upload/qiniu/token', { filename })
}
async function uploadFileToQiniu(file) {
const tokenData = await requestUploadToken(file.name)
if (!tokenData || !tokenData.token || !tokenData.key || !tokenData.upload_url) {
throw new Error('上传凭证返回异常')
}
const formData = new FormData()
formData.append('token', tokenData.token)
formData.append('key', tokenData.key)
formData.append('file', file)
const uploadResp = await fetch(tokenData.upload_url, { method: 'POST', body: formData })
if (!uploadResp.ok) {
throw new Error(`上传失败(${uploadResp.status})`)
}
const uploadResult = await uploadResp.json().catch(() => ({}))
const finalKey = uploadResult.key || tokenData.key
if (!finalKey) throw new Error('上传后未返回文件 key')
const cdn = (tokenData.cdn_domain || '').replace(/\/$/, '')
if (cdn) return `${cdn}/${finalKey}`
return finalKey
}
async function pickAndUpload(target) {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async () => {
const file = input.files && input.files[0]
if (!file) return
const loadingRef =
target === 'category_icon' ? catIconUploading :
target === 'template_image' ? tplImageUploading : tplThumbUploading
loadingRef.value = true
try {
const uploadedURL = await uploadFileToQiniu(file)
if (target === 'category_icon') {
catForm.icon = uploadedURL
} else if (target === 'template_image') {
tplForm.image_url = uploadedURL
} else {
tplForm.thumbnail_url = uploadedURL
}
ElementPlus.ElMessage.success('上传成功')
} catch (e) {
ElementPlus.ElMessage.error('上传失败: ' + e.message)
} finally {
loadingRef.value = false
}
}
input.click()
}
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, catIconUploading,
tplDialogVisible, tplForm, tplSaving, tplImageUploading, tplThumbUploading,
previewVisible, previewUrl,
login, logout, loadTemplates,
openCategoryDialog, saveCategory, deleteCategory,
openTemplateDialog, saveTemplate, deleteTemplate,
previewImage, pickAndUpload
}
}
})
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn })
app.mount('#app')
</script>
</body>
</html>