新增微信管理模块和用户资料路由

- 集成微信管理路由与配置、模板和日志页面
- 新增个人信息用户资料路由
- 更新了 AdminLayout,添加了新的微信相关图标和菜单映射
- 删除了默认style.css内容
This commit is contained in:
wzclm 2025-03-08 18:11:28 +08:00
parent 14b73bcf87
commit 6fb1f781d4
9 changed files with 1524 additions and 82 deletions

View File

@ -0,0 +1 @@

114
src/api/wechat/index.js Normal file
View File

@ -0,0 +1,114 @@
import request from '@/utils/request'
/**
* 获取微信公众号配置
* @returns {Promise} 返回配置信息
*/
export function getWechatConfig() {
return request.get('/api/admin/wechat/config')
}
/**
* 更新微信公众号配置
* @param {Object} data - 配置数据
* @param {string} data.app_id - 公众号AppID
* @param {string} data.app_secret - 公众号AppSecret
* @param {string} data.token - 公众号Token
* @param {string} data.encoding_aes_key - 消息加密密钥
* @param {number} data.status - 状态0-禁用 1-启用
* @returns {Promise} 返回更新结果
*/
export function updateWechatConfig(data) {
return request.put('/api/admin/wechat/config', data)
}
/**
* 创建消息模板
* @param {Object} data - 模板数据
* @param {string} data.template_id - 模板ID
* @param {string} data.title - 模板标题
* @param {string} data.content - 模板内容
* @param {string} data.example - 模板示例
* @param {string} data.type - 模板类型activity-活动通知 system-系统通知
* @param {number} data.status - 状态0-禁用 1-启用
* @returns {Promise} 返回创建结果
*/
export function createTemplate(data) {
return request.post('/api/admin/wechat/templates', data)
}
/**
* 获取模板列表
* @param {Object} params - 查询参数
* @param {number} [params.page=1] - 页码
* @param {number} [params.page_size=10] - 每页数量
* @param {string} [params.type] - 模板类型
* @param {number} [params.status] - 状态
* @returns {Promise} 返回模板列表数据
*/
export function getTemplateList(params = {}) {
return request.get('/api/admin/wechat/templates', {
params: {
page: params.page || 1,
page_size: params.page_size || 10,
type: params.type,
status: params.status
}
})
}
/**
* 获取模板详情
* @param {string|number} id - 模板ID
* @returns {Promise} 返回模板详情
*/
export function getTemplateDetail(id) {
return request.get(`/api/admin/wechat/templates/${id}`)
}
/**
* 更新模板
* @param {string|number} id - 模板ID
* @param {Object} data - 更新数据
* @returns {Promise} 返回更新结果
*/
export function updateTemplate(id, data) {
return request.put(`/api/admin/wechat/templates/${id}`, data)
}
/**
* 删除模板
* @param {string|number} id - 模板ID
* @returns {Promise} 返回删除结果
*/
export function deleteTemplate(id) {
return request.delete(`/api/admin/wechat/templates/${id}`)
}
/**
* 获取消息发送记录
* @param {Object} params - 查询参数
* @param {number} [params.page=1] - 页码
* @param {number} [params.page_size=10] - 每页数量
* @param {number} [params.status] - 发送状态0-失败 1-成功
* @param {string} [params.start_date] - 开始日期
* @param {string} [params.end_date] - 结束日期
* @returns {Promise} 返回消息发送记录列表
*/
export function getMessageLogs(params = {}) {
// 移除所有 undefined 和空字符串的参数
const validParams = {}
Object.keys(params).forEach(key => {
if (params[key] !== undefined && params[key] !== '') {
validParams[key] = params[key]
}
})
return request.get('/api/admin/wechat/message-logs', {
params: {
page: 1,
page_size: 10,
...validParams
}
})
}

View File

@ -21,7 +21,8 @@ import {
VideoCamera,
ChatLineRound,
InfoFilled,
Grid
Grid,
ChatDotRound
} from "@element-plus/icons-vue";
import logo from '../assets/images/logo.png';
@ -49,7 +50,8 @@ const icons = {
VideoCamera: markRaw(VideoCamera),
ChatLineRound: markRaw(ChatLineRound),
InfoFilled: markRaw(InfoFilled),
Grid: markRaw(Grid)
Grid: markRaw(Grid),
ChatDotRound: markRaw(ChatDotRound)
};
//
@ -64,7 +66,8 @@ const iconMapping = {
'activity': 'Collection',
'course': 'DataLine',
'feedback': 'ChatLineRound',
'about': 'InfoFilled'
'about': 'InfoFilled',
'wechat': 'ChatDotRound'
};
//

View File

@ -40,6 +40,39 @@ const router = createRouter({
component: () => import('../views/dashboard/index.vue'),
meta: { title: '控制台', icon: 'HomeFilled' }
},
{
path: 'system/profile',
name: 'UserProfile',
component: () => import('../views/system/profile/index.vue'),
meta: { title: '个人信息', hideInMenu: true }
},
// 微信管理
{
path: 'wechat',
name: 'Wechat',
meta: { title: '消息推送管理', icon: 'ChatDotRound' },
redirect: '/wechat/config',
children: [
{
path: 'config',
name: 'WechatConfig',
component: () => import('../views/wechat/config/index.vue'),
meta: { title: '公众号配置' }
},
{
path: 'templates',
name: 'WechatTemplates',
component: () => import('../views/wechat/templates/index.vue'),
meta: { title: '消息模板' }
},
{
path: 'logs',
name: 'WechatLogs',
component: () => import('../views/wechat/logs/index.vue'),
meta: { title: '发送记录' }
}
]
},
// 系统管理
{
path: 'system',

View File

@ -1,79 +0,0 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@ -0,0 +1,364 @@
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { User, Lock, Message, Phone } from '@element-plus/icons-vue'
//
const userInfo = reactive({
username: 'admin',
nickname: '管理员',
email: 'admin@example.com',
phone: '13800138000',
avatar: '',
role: '超级管理员',
createTime: '2023-01-01',
lastLoginTime: '2024-03-20 10:00:00',
lastLoginIp: '192.168.1.100'
})
//
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
//
const passwordRules = {
oldPassword: [
{ required: true, message: '请输入原密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能小于6位', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能小于6位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== passwordForm.newPassword) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
//
const profileForm = reactive({
nickname: userInfo.nickname,
email: userInfo.email,
phone: userInfo.phone
})
//
const profileRules = {
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
}
//
const passwordFormRef = ref()
const profileFormRef = ref()
//
const handleChangePassword = async () => {
if (!passwordFormRef.value) return
await passwordFormRef.value.validate((valid) => {
if (valid) {
ElMessage.success('密码修改成功(这是模拟消息)')
//
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
}
})
}
//
const handleUpdateProfile = async () => {
if (!profileFormRef.value) return
await profileFormRef.value.validate((valid) => {
if (valid) {
ElMessage.success('个人信息更新成功(这是模拟消息)')
//
userInfo.nickname = profileForm.nickname
userInfo.email = profileForm.email
userInfo.phone = profileForm.phone
}
})
}
//
const handleAvatarSuccess = (response) => {
userInfo.avatar = response.url
ElMessage.success('头像上传成功(这是模拟消息)')
}
//
const beforeAvatarUpload = (file) => {
const isJPG = file.type === 'image/jpeg' || file.type === 'image/png'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG) {
ElMessage.error('上传头像图片只能是 JPG/PNG 格式!')
}
if (!isLt2M) {
ElMessage.error('上传头像图片大小不能超过 2MB!')
}
return isJPG && isLt2M
}
</script>
<template>
<div class="profile-container">
<!-- 基本信息卡片 -->
<el-card class="profile-card">
<template #header>
<div class="card-header">
<span>基本信息</span>
</div>
</template>
<div class="profile-content">
<!-- 左侧头像区域 -->
<div class="avatar-container">
<el-upload
class="avatar-uploader"
action="#"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
:on-success="handleAvatarSuccess"
>
<img
v-if="userInfo.avatar"
:src="userInfo.avatar"
class="avatar"
/>
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
<div class="upload-tip">点击上传头像</div>
</div>
<!-- 右侧信息区域 -->
<div class="info-container">
<el-form
ref="profileFormRef"
:model="profileForm"
:rules="profileRules"
label-width="100px"
>
<el-form-item label="用户名">
<el-input v-model="userInfo.username" disabled />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="profileForm.nickname" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="profileForm.email">
<template #prefix>
<el-icon><Message /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="profileForm.phone">
<template #prefix>
<el-icon><Phone /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="角色">
<el-input v-model="userInfo.role" disabled />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleUpdateProfile">保存修改</el-button>
</el-form-item>
</el-form>
</div>
</div>
</el-card>
<!-- 安全设置卡片 -->
<el-card class="profile-card">
<template #header>
<div class="card-header">
<span>安全设置</span>
</div>
</template>
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="100px"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="passwordForm.oldPassword"
type="password"
show-password
placeholder="请输入原密码"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="passwordForm.newPassword"
type="password"
show-password
placeholder="请输入新密码"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
show-password
placeholder="请确认新密码"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleChangePassword">修改密码</el-button>
</el-form-item>
</el-form>
<!-- 登录信息 -->
<div class="login-info">
<div class="info-item">
<span class="label">上次登录时间</span>
<span class="value">{{ userInfo.lastLoginTime }}</span>
</div>
<div class="info-item">
<span class="label">上次登录IP</span>
<span class="value">{{ userInfo.lastLoginIp }}</span>
</div>
</div>
</el-card>
</div>
</template>
<style lang="scss" scoped>
.profile-container {
padding: 20px;
.profile-card {
margin-bottom: 20px;
.card-header {
display: flex;
align-items: center;
span {
font-size: 16px;
font-weight: 500;
}
}
.profile-content {
display: flex;
gap: 40px;
.avatar-container {
text-align: center;
.avatar-uploader {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
width: 178px;
height: 178px;
&:hover {
border-color: #409EFF;
}
.avatar {
width: 178px;
height: 178px;
display: block;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
text-align: center;
line-height: 178px;
}
}
.upload-tip {
font-size: 12px;
color: #666;
margin-top: 8px;
}
}
.info-container {
flex: 1;
max-width: 500px;
}
}
}
.login-info {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
.info-item {
margin-bottom: 10px;
font-size: 14px;
.label {
color: #606266;
margin-right: 10px;
}
.value {
color: #333;
}
&:last-child {
margin-bottom: 0;
}
}
}
}
:deep(.el-upload) {
width: 178px;
height: 178px;
}
</style>

View File

@ -0,0 +1,367 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Setting, Key, Lock, Warning } from '@element-plus/icons-vue'
import { getWechatConfig, updateWechatConfig } from '@/api/wechat'
//
const formData = reactive({
app_id: '',
app_secret: '',
token: '',
encoding_aes_key: '',
status: 1
})
//
const loading = ref(false)
//
const getConfig = async () => {
loading.value = true
try {
const res = await getWechatConfig()
if (res.success) {
const { app_id, app_secret, token, encoding_aes_key, status } = res.data
Object.assign(formData, {
app_id,
app_secret: app_secret || formData.app_secret,
token,
encoding_aes_key,
status: status ?? 1
})
} else {
ElMessage.error(res.message || '获取配置失败')
}
} catch (error) {
console.error('获取配置失败:', error)
ElMessage.error('获取配置失败')
} finally {
loading.value = false
}
}
//
const rules = {
app_id: [
{ required: true, message: '请输入AppID', trigger: 'blur' },
{ min: 10, message: 'AppID长度不能小于10个字符', trigger: 'blur' }
],
app_secret: [
{ required: true, message: '请输入AppSecret', trigger: 'blur' },
{ min: 10, message: 'AppSecret长度不能小于10个字符', trigger: 'blur' }
],
token: [
{ required: true, message: '请输入Token', trigger: 'blur' },
{ min: 3, message: 'Token长度不能小于3个字符', trigger: 'blur' }
],
encoding_aes_key: [
{ min: 10, message: '加密密钥长度不能小于10个字符', trigger: 'blur' }
]
}
//
const formRef = ref()
//
const handleSave = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
const res = await updateWechatConfig(formData)
if (res.success) {
ElMessage.success('保存成功')
getConfig() //
} else {
ElMessage.error(res.message || '保存失败')
}
} catch (error) {
console.error('保存失败:', error)
ElMessage.error('保存失败')
}
}
})
}
//
onMounted(() => {
getConfig()
})
</script>
<template>
<div class="app-container">
<el-card class="box-card" v-loading="loading">
<template #header>
<div class="card-header">
<div class="header-title">
<el-icon class="icon"><Setting /></el-icon>
<span>公众号配置</span>
</div>
<div class="header-desc">配置微信公众号的基本信息用于消息推送和用户交互</div>
</div>
</template>
<div class="main-content">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="140px"
class="config-form"
>
<div class="form-section">
<div class="section-title">基本配置</div>
<el-form-item label="AppID" prop="app_id">
<el-input
v-model="formData.app_id"
placeholder="请输入公众号AppID"
clearable
>
<template #prefix>
<el-icon><Key /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="AppSecret" prop="app_secret">
<el-input
v-model="formData.app_secret"
type="password"
placeholder="请输入公众号AppSecret"
show-password
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="Token" prop="token">
<el-input
v-model="formData.token"
placeholder="请输入公众号Token"
clearable
>
<template #prefix>
<el-icon><Key /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="消息加密密钥" prop="encoding_aes_key">
<el-input
v-model="formData.encoding_aes_key"
placeholder="请输入消息加密密钥(选填)"
clearable
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave" class="save-button">
保存配置
</el-button>
</el-form-item>
</div>
</el-form>
<!-- 配置说明 -->
<div class="config-tips">
<div class="tips-header">
<el-icon class="warning-icon"><Warning /></el-icon>
<span>配置说明</span>
</div>
<div class="tips-content">
<div class="tip-item">
<div class="tip-title">AppID AppSecret</div>
<div class="tip-desc">可在微信公众平台 > 开发 > 基本配置中获取是进行消息交互的重要凭证</div>
</div>
<div class="tip-item">
<div class="tip-title">Token</div>
<div class="tip-desc">用于验证消息的确来自微信服务器建议使用16位以上的随机字符串</div>
</div>
<div class="tip-item">
<div class="tip-title">消息加密密钥</div>
<div class="tip-desc">用于加强消息安全性可选填建议在正式环境中启用</div>
</div>
<div class="tip-item">
<div class="tip-title">安全提示</div>
<div class="tip-desc">所有配置信息请妥善保管不要泄露给他人建议定期更新Token和加密密钥</div>
</div>
</div>
</div>
</div>
</el-card>
</div>
</template>
<style lang="scss" scoped>
.app-container {
padding: 20px;
background-color: #f5f7fa;
min-height: calc(100vh - 120px);
.box-card {
max-width: 1000px;
margin: 0 auto;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
:deep(.el-card__header) {
padding: 20px;
border-bottom: 1px solid #ebeef5;
}
.card-header {
.header-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.icon {
font-size: 24px;
color: #409EFF;
}
span {
font-size: 18px;
font-weight: 600;
color: #303133;
}
}
.header-desc {
font-size: 14px;
color: #909399;
}
}
.main-content {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 30px;
padding: 20px;
.config-form {
.form-section {
padding: 20px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #ebeef5;
.section-title {
font-size: 16px;
font-weight: 500;
color: #303133;
margin-bottom: 20px;
padding-left: 10px;
border-left: 4px solid #409EFF;
}
}
:deep(.el-form-item) {
margin-bottom: 22px;
.el-form-item__label {
font-weight: 500;
}
.el-input {
.el-input__wrapper {
box-shadow: 0 0 0 1px #dcdfe6 inset;
&:hover {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
&.is-focus {
box-shadow: 0 0 0 1px #409EFF inset;
}
}
.el-input__prefix {
color: #909399;
}
}
}
.save-button {
width: 160px;
height: 40px;
font-size: 15px;
margin-top: 10px;
&:hover {
transform: translateY(-1px);
}
}
}
.config-tips {
background-color: #fff;
border-radius: 8px;
border: 1px solid #ebeef5;
height: fit-content;
.tips-header {
padding: 15px 20px;
border-bottom: 1px solid #ebeef5;
display: flex;
align-items: center;
gap: 8px;
.warning-icon {
font-size: 18px;
color: #e6a23c;
}
span {
font-size: 16px;
font-weight: 500;
color: #303133;
}
}
.tips-content {
padding: 20px;
.tip-item {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.tip-title {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 8px;
}
.tip-desc {
font-size: 13px;
color: #606266;
line-height: 1.6;
}
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,262 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Timer, Message } from '@element-plus/icons-vue'
import { getMessageLogs } from '@/api/wechat'
//
const queryParams = reactive({
page: 1,
page_size: 10,
status: undefined,
start_date: '',
end_date: ''
})
//
const dateRange = ref([])
//
const loading = ref(false)
const logsList = ref([])
//
const pagination = reactive({
total: 0,
page: 1,
pageSize: 10
})
//
const getList = async () => {
loading.value = true
try {
//
const params = {
page: queryParams.page,
page_size: queryParams.page_size
}
// undefined
if (queryParams.status !== undefined) {
params.status = queryParams.status
}
//
if (queryParams.start_date) {
params.start_date = queryParams.start_date
}
if (queryParams.end_date) {
params.end_date = queryParams.end_date
}
const res = await getMessageLogs(params)
if (res.success) {
logsList.value = res.data.list || []
if (res.data.pagination) {
pagination.total = res.data.pagination.total || 0
pagination.page = res.data.pagination.current || 1
pagination.pageSize = res.data.pagination.page_size || 10
}
} else {
ElMessage.error(res.message || '获取发送记录失败')
}
} catch (error) {
console.error('获取发送记录失败:', error)
ElMessage.error('获取发送记录失败')
} finally {
loading.value = false
}
}
//
const resetQuery = () => {
queryParams.status = undefined
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.page = 1
dateRange.value = [] //
getList()
}
//
const handleCurrentChange = (val) => {
queryParams.page = val
getList()
}
//
const handleSizeChange = (val) => {
queryParams.page_size = val
queryParams.page = 1
getList()
}
//
const formatStatus = (status) => {
const statusMap = {
0: { type: 'danger', text: '发送失败' },
1: { type: 'success', text: '发送成功' }
}
return statusMap[status] || { type: 'info', text: '未知状态' }
}
//
const pickerOptions = {
shortcuts: [
{
text: '最近一周',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7)
return [start, end]
}
},
{
text: '最近一个月',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30)
return [start, end]
}
},
{
text: '最近三个月',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90)
return [start, end]
}
}
]
}
//
const handleDateRangeChange = (dates) => {
if (dates) {
queryParams.start_date = dates[0]
queryParams.end_date = dates[1]
} else {
queryParams.start_date = ''
queryParams.end_date = ''
}
}
onMounted(() => {
getList()
})
</script>
<template>
<div class="app-container">
<!-- 搜索区域 -->
<el-card class="search-container">
<el-form :model="queryParams" ref="queryForm" :inline="true">
<el-form-item label="发送状态" prop="status">
<el-select
v-model="queryParams.status"
placeholder="请选择发送状态"
clearable
style="width: 200px"
>
<el-option label="发送成功" :value="1" />
<el-option label="发送失败" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="发送时间" prop="date_range">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:shortcuts="pickerOptions.shortcuts"
value-format="YYYY-MM-DD"
@change="handleDateRangeChange"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getList">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格区域 -->
<el-card class="table-container">
<el-table
v-loading="loading"
:data="logsList"
border
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="template_id" label="模板ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="template_title" label="模板标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="content" label="发送内容" min-width="300" show-overflow-tooltip />
<el-table-column prop="status" label="发送状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="formatStatus(row.status).type" size="small">
{{ formatStatus(row.status).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error_message" label="错误信息" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.status === 0" class="error-message">{{ row.error_message }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="发送时间" width="180" align="center" />
<el-table-column prop="sender_name" label="发送人" width="120" align="center" show-overflow-tooltip />
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
:background="true"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
v-if="pagination.total > 10"
/>
</div>
</el-card>
</div>
</template>
<style lang="scss" scoped>
.app-container {
padding: 20px;
.search-container {
margin-bottom: 20px;
}
.table-container {
margin-bottom: 20px;
:deep(.el-card__header) {
padding: 12px 20px;
}
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.error-message {
color: #f56c6c;
}
}
</style>

View File

@ -0,0 +1,377 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
import { getTemplateList, createTemplate, updateTemplate, deleteTemplate } from '@/api/wechat'
//
const queryParams = reactive({
page: 1,
page_size: 10,
title: '',
type: undefined,
status: undefined
})
//
const templateTypes = [
{ label: '活动通知', value: 'activity' },
{ label: '系统通知', value: 'system' }
]
//
const loading = ref(false)
const templateList = ref([])
//
const pagination = reactive({
total: 0,
page: 1,
pageSize: 10
})
//
const getList = async () => {
loading.value = true
try {
const params = {
...queryParams,
type: queryParams.type || undefined,
status: queryParams.status === '' ? undefined : queryParams.status
}
const res = await getTemplateList(params)
if (res.success) {
templateList.value = res.data.list || []
if (res.data.pagination) {
pagination.total = res.data.pagination.total || 0
pagination.page = res.data.pagination.current || 1
pagination.pageSize = res.data.pagination.page_size || 10
}
} else {
ElMessage.error(res.message || '获取模板列表失败')
}
} catch (error) {
console.error('获取模板列表失败:', error)
ElMessage.error('获取模板列表失败')
} finally {
loading.value = false
}
}
//
const resetQuery = () => {
queryParams.title = ''
queryParams.type = undefined
queryParams.status = undefined
queryParams.page = 1
getList()
}
//
const dialogVisible = ref(false)
const dialogType = ref('add')
const dialogTitle = computed(() => dialogType.value === 'add' ? '添加模板' : '编辑模板')
//
const formRef = ref()
const formData = ref({
template_id: '',
title: '',
content: '',
example: '',
type: '',
status: 1
})
//
const rules = {
template_id: [
{ required: true, message: '请输入模板ID', trigger: 'blur' }
],
title: [
{ required: true, message: '请输入模板标题', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入模板内容', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择模板类型', trigger: 'change' }
]
}
// /
const handleAddOrEdit = (type, row) => {
dialogType.value = type
dialogVisible.value = true
if (type === 'edit' && row) {
formData.value = { ...row }
} else {
formData.value = {
template_id: '',
title: '',
content: '',
example: '',
type: '',
status: 1
}
}
}
//
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确认删除该模板吗?', '提示', {
type: 'warning'
})
const res = await deleteTemplate(row.id)
if (res.success) {
ElMessage.success('删除成功')
getList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
}
}
//
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
const api = dialogType.value === 'add' ? createTemplate : updateTemplate
const res = await api(
dialogType.value === 'add' ? formData.value : formData.value.id,
formData.value
)
if (res.success) {
ElMessage.success(dialogType.value === 'add' ? '添加成功' : '修改成功')
dialogVisible.value = false
getList()
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('提交失败')
}
}
})
}
//
const handleCurrentChange = (val) => {
queryParams.page = val
getList()
}
//
const handleSizeChange = (val) => {
queryParams.page_size = val
queryParams.page = 1
getList()
}
onMounted(() => {
getList()
})
</script>
<template>
<div class="app-container">
<!-- 搜索区域 -->
<el-card class="search-container">
<el-form :model="queryParams" ref="queryForm" :inline="true">
<el-form-item label="模板标题" prop="title">
<el-input
v-model="queryParams.title"
placeholder="请输入模板标题"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="模板类型" prop="type">
<el-select
v-model="queryParams.type"
placeholder="请选择模板类型"
clearable
style="width: 200px"
>
<el-option
v-for="item in templateTypes"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select
v-model="queryParams.status"
placeholder="请选择状态"
clearable
style="width: 200px"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getList">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮区域 -->
<el-card class="table-container">
<template #header>
<el-button type="primary" :icon="Plus" @click="handleAddOrEdit('add')">新增模板</el-button>
</template>
<!-- 表格区域 -->
<el-table
v-loading="loading"
:data="templateList"
border
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="template_id" label="模板ID" min-width="120" show-overflow-tooltip />
<el-table-column prop="title" label="模板标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="content" label="模板内容" min-width="250" show-overflow-tooltip />
<el-table-column prop="example" label="模板示例" min-width="250" show-overflow-tooltip />
<el-table-column prop="type" label="模板类型" width="120" align="center">
<template #default="{ row }">
{{ templateTypes.find(item => item.value === row.type)?.label || '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link :icon="Edit" @click="handleAddOrEdit('edit', row)">编辑</el-button>
<el-button type="danger" link :icon="Delete" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
:background="true"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 添加/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="700px"
append-to-body
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
>
<el-form-item label="模板ID" prop="template_id">
<el-input v-model="formData.template_id" placeholder="请输入模板ID" />
</el-form-item>
<el-form-item label="模板标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入模板标题" />
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input
v-model="formData.content"
type="textarea"
:rows="4"
placeholder="请输入模板内容"
/>
</el-form-item>
<el-form-item label="模板示例" prop="example">
<el-input
v-model="formData.example"
type="textarea"
:rows="4"
placeholder="请输入模板示例"
/>
</el-form-item>
<el-form-item label="模板类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择模板类型" style="width: 100%">
<el-option
v-for="item in templateTypes"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false"> </el-button>
<el-button type="primary" @click="submitForm"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.app-container {
padding: 20px;
.search-container {
margin-bottom: 20px;
}
.table-container {
margin-bottom: 20px;
:deep(.el-card__header) {
padding: 12px 20px;
}
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.dialog-footer {
text-align: right;
padding-top: 20px;
}
}
</style>