491 lines
20 KiB
HTML
491 lines
20 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; }
|
|
.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>
|