This commit is contained in:
yvan 2025-08-05 17:46:04 +08:00
parent e03259608e
commit e43d78ddf0
8 changed files with 1294 additions and 0 deletions

View File

@ -0,0 +1,42 @@
package request
// SaveMpConfigRequest 保存微信配置请求
type SaveMpConfigRequest struct {
ConfigType string `json:"configType" binding:"required" example:"mp"`
AppID string `json:"appId" binding:"required" example:"wx1234567890"`
AppSecret string `json:"appSecret" binding:"required" example:"your_app_secret"`
Token *string `json:"token" example:"your_token"`
EncodingAESKey *string `json:"encodingAESKey" example:"your_encoding_aes_key"`
// 公众号特有配置
ServerURL *string `json:"serverUrl" example:"https://your-server.com"`
WebhookURL *string `json:"webhookUrl" example:"https://your-server.com/webhook"`
// 小程序特有配置
MiniAppName *string `json:"miniAppName" example:"我的小程序"`
MiniAppDesc *string `json:"miniAppDesc" example:"小程序描述"`
// 状态信息
Status string `json:"status" example:"active"`
// 扩展配置
ExtraConfig *string `json:"extraConfig" example:"{}"`
Remark *string `json:"remark" example:"配置备注"`
}
// TestMpConfigRequest 测试微信配置请求
type TestMpConfigRequest struct {
ConfigType string `json:"configType" binding:"required" example:"mp"`
AppID string `json:"appId" binding:"required" example:"wx1234567890"`
AppSecret string `json:"appSecret" binding:"required" example:"your_app_secret"`
Token *string `json:"token" example:"your_token"`
EncodingAESKey *string `json:"encodingAESKey" example:"your_encoding_aes_key"`
// 公众号特有配置
ServerURL *string `json:"serverUrl" example:"https://your-server.com"`
WebhookURL *string `json:"webhookUrl" example:"https://your-server.com/webhook"`
// 小程序特有配置
MiniAppName *string `json:"miniAppName" example:"我的小程序"`
MiniAppDesc *string `json:"miniAppDesc" example:"小程序描述"`
}

View File

@ -0,0 +1,56 @@
package request
// CreateMpNewsRequest 创建图文记录请求
type CreateMpNewsRequest struct {
MediaID string `json:"mediaId" example:"media_id_123"`
Title string `json:"title" binding:"required" example:"图文标题"`
Author *string `json:"author" example:"作者名称"`
Digest *string `json:"digest" example:"图文摘要"`
Content *string `json:"content" example:"图文内容"`
ContentURL *string `json:"contentUrl" example:"https://mp.weixin.qq.com/s/xxx"`
SourceURL *string `json:"sourceUrl" example:"https://example.com"`
ThumbMediaID *string `json:"thumbMediaId" example:"thumb_media_id"`
ThumbURL *string `json:"thumbUrl" example:"https://example.com/thumb.jpg"`
ShowCover bool `json:"showCover" example:"true"`
NeedOpenComment bool `json:"needOpenComment" example:"false"`
OnlyFansCanComment bool `json:"onlyFansCanComment" example:"false"`
// 发布信息
PublishStatus string `json:"publishStatus" example:"draft"`
// 微信相关
WechatURL *string `json:"wechatUrl" example:"https://mp.weixin.qq.com/s/xxx"`
WechatMsgID *string `json:"wechatMsgId" example:"msg_id_123"`
}
// UpdateMpNewsRequest 更新图文记录请求
type UpdateMpNewsRequest struct {
ID uint `json:"id" binding:"required"`
MediaID string `json:"mediaId"`
Title string `json:"title"`
Author *string `json:"author"`
Digest *string `json:"digest"`
Content *string `json:"content"`
ContentURL *string `json:"contentUrl"`
SourceURL *string `json:"sourceUrl"`
ThumbMediaID *string `json:"thumbMediaId"`
ThumbURL *string `json:"thumbUrl"`
ShowCover bool `json:"showCover"`
NeedOpenComment bool `json:"needOpenComment"`
OnlyFansCanComment bool `json:"onlyFansCanComment"`
// 发布信息
PublishStatus string `json:"publishStatus"`
// 微信相关
WechatURL *string `json:"wechatUrl"`
WechatMsgID *string `json:"wechatMsgId"`
}
// MpNewsPageRequest 图文记录分页请求
type MpNewsPageRequest struct {
PageInfo
Title *string `json:"title" form:"title"`
Author *string `json:"author" form:"author"`
PublishStatus *string `json:"publishStatus" form:"publishStatus"`
}

View File

@ -0,0 +1,164 @@
package service
import (
"errors"
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/wechat-integration/model"
"github.com/silenceper/wechat/v2"
"github.com/silenceper/wechat/v2/cache"
"github.com/silenceper/wechat/v2/officialaccount"
"github.com/silenceper/wechat/v2/officialaccount/basic"
"github.com/silenceper/wechat/v2/officialaccount/config"
)
type WechatMpService struct{}
// GetWechatOfficialAccount 获取微信公众号实例
func (w *WechatMpService) GetWechatOfficialAccount(mpConfig *model.MpConfig) (*officialaccount.OfficialAccount, error) {
if mpConfig == nil {
return nil, errors.New("微信公众号配置不能为空")
}
if mpConfig.AppID == "" || mpConfig.AppSecret == "" {
return nil, errors.New("微信公众号配置不完整")
}
wc := wechat.NewWechat()
memory := cache.NewMemory()
cfg := &config.Config{
AppID: mpConfig.AppID,
AppSecret: mpConfig.AppSecret,
Cache: memory,
}
// 如果有Token和EncodingAESKey设置服务器配置
if mpConfig.Token != nil && *mpConfig.Token != "" {
cfg.Token = *mpConfig.Token
}
if mpConfig.EncodingAESKey != nil && *mpConfig.EncodingAESKey != "" {
cfg.EncodingAESKey = *mpConfig.EncodingAESKey
}
return wc.GetOfficialAccount(cfg), nil
}
// GenerateQrCode 生成公众号二维码
func (w *WechatMpService) GenerateQrCode(mpConfig *model.MpConfig) (string, error) {
oa, err := w.GetWechatOfficialAccount(mpConfig)
if err != nil {
return "", err
}
// 获取基础服务
basicService := oa.GetBasic()
// 创建永久二维码请求
qrRequest := basic.NewLimitQrRequest("mp_config_qr")
// 获取二维码ticket
ticket, err := basicService.GetQRTicket(qrRequest)
if err != nil {
global.GVA_LOG.Error("获取二维码ticket失败: " + err.Error())
return "", fmt.Errorf("获取二维码ticket失败: %v", err)
}
// 生成二维码图片URL
qrCodeURL := basic.ShowQRCode(ticket)
global.GVA_LOG.Info("生成公众号二维码成功: " + qrCodeURL)
return qrCodeURL, nil
}
// ClearQuota 清空API配额
func (w *WechatMpService) ClearQuota(mpConfig *model.MpConfig) error {
oa, err := w.GetWechatOfficialAccount(mpConfig)
if err != nil {
return err
}
// 调用清空API配额接口
basic := oa.GetBasic()
err = basic.ClearQuota()
if err != nil {
global.GVA_LOG.Error("清空API配额失败: " + err.Error())
return fmt.Errorf("清空API配额失败: %v", err)
}
global.GVA_LOG.Info("清空API配额成功")
return nil
}
// ValidateConfig 验证公众号配置
func (w *WechatMpService) ValidateConfig(mpConfig *model.MpConfig) error {
oa, err := w.GetWechatOfficialAccount(mpConfig)
if err != nil {
return err
}
// 通过获取access_token来验证配置是否正确
basic := oa.GetBasic()
token, err := basic.GetAccessToken()
if err != nil {
global.GVA_LOG.Error("验证公众号配置失败: " + err.Error())
return fmt.Errorf("验证配置失败: %v", err)
}
if token == "" {
return errors.New("获取access_token失败请检查AppID和AppSecret")
}
global.GVA_LOG.Info("验证公众号配置成功")
return nil
}
// GetServerIPs 获取微信服务器IP地址
func (w *WechatMpService) GetServerIPs(mpConfig *model.MpConfig) ([]string, error) {
oa, err := w.GetWechatOfficialAccount(mpConfig)
if err != nil {
return nil, err
}
basic := oa.GetBasic()
ips, err := basic.GetCallbackIP()
if err != nil {
global.GVA_LOG.Error("获取微信服务器IP失败: " + err.Error())
return nil, fmt.Errorf("获取微信服务器IP失败: %v", err)
}
return ips, nil
}
// GetUserInfo 获取用户基本信息
func (w *WechatMpService) GetUserInfo(mpConfig *model.MpConfig, openID string) (map[string]interface{}, error) {
oa, err := w.GetWechatOfficialAccount(mpConfig)
if err != nil {
return nil, err
}
user := oa.GetUser()
userInfo, err := user.GetUserInfo(openID)
if err != nil {
global.GVA_LOG.Error("获取用户信息失败: " + err.Error())
return nil, fmt.Errorf("获取用户信息失败: %v", err)
}
return map[string]interface{}{
"openid": userInfo.OpenID,
"nickname": userInfo.Nickname,
"sex": userInfo.Sex,
"province": userInfo.Province,
"city": userInfo.City,
"country": userInfo.Country,
"headimgurl": userInfo.Headimgurl,
"unionid": userInfo.UnionID,
}, nil
}
// SendTextMessage 发送文本消息 (暂时注释需要正确的API调用方式)
// func (w *WechatMpService) SendTextMessage(mpConfig *model.MpConfig, openID, content string) error {
// // TODO: 实现发送文本消息功能
// return errors.New("发送消息功能暂未实现")
// }

View File

@ -0,0 +1,13 @@
/**
* 菜单编辑器组件
* 参照yudao-ui-admin的MenuEditor组件设计
*/
import MenuEditor from './index.vue'
// 组件安装函数
MenuEditor.install = function(app) {
app.component(MenuEditor.name, MenuEditor)
}
export default MenuEditor

View File

@ -0,0 +1,505 @@
<template>
<div class="menu-editor">
<div v-if="!hasSelectedMenu" class="no-selection">
<el-empty description="请选择菜单进行配置" />
</div>
<div v-else class="editor-content">
<!-- 菜单基本信息 -->
<el-card class="menu-basic-info" shadow="never">
<template #header>
<div class="card-header">
<span>{{ isParent ? '一级菜单' : '二级菜单' }}配置</span>
<el-button
v-if="!isNewMenu"
type="danger"
size="small"
@click="handleDelete"
>
删除菜单
</el-button>
</div>
</template>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
size="default"
>
<!-- 菜单名称 -->
<el-form-item label="菜单名称" prop="name">
<el-input
v-model="formData.name"
:maxlength="isParent ? 4 : 8"
placeholder="请输入菜单名称"
show-word-limit
/>
<div class="form-tip">
{{ isParent ? '一级菜单名称不超过4个字符' : '二级菜单名称不超过8个字符' }}
</div>
</el-form-item>
<!-- 菜单类型 -->
<el-form-item label="菜单类型" prop="type">
<el-select
v-model="formData.type"
placeholder="请选择菜单类型"
@change="handleTypeChange"
>
<el-option
v-for="option in menuTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<!-- 菜单Key点击事件类型 -->
<el-form-item
v-if="formData.type === 'click'"
label="菜单Key"
prop="menuKey"
>
<el-input
v-model="formData.menuKey"
placeholder="请输入菜单Key"
maxlength="128"
/>
<div class="form-tip">用于标识菜单建议使用英文</div>
</el-form-item>
<!-- 跳转链接view类型 -->
<el-form-item
v-if="formData.type === 'view'"
label="跳转链接"
prop="url"
>
<el-input
v-model="formData.url"
placeholder="请输入跳转链接"
maxlength="1000"
/>
<div class="form-tip">必须是http://https://</div>
</el-form-item>
<!-- 小程序配置 -->
<template v-if="formData.type === 'miniprogram'">
<el-form-item label="小程序AppID" prop="miniProgramAppId">
<el-input
v-model="formData.miniProgramAppId"
placeholder="请输入小程序AppID"
maxlength="32"
/>
</el-form-item>
<el-form-item label="小程序页面路径" prop="miniProgramPagePath">
<el-input
v-model="formData.miniProgramPagePath"
placeholder="请输入小程序页面路径"
maxlength="1000"
/>
<div class="form-tip">例如pages/index/index</div>
</el-form-item>
<el-form-item label="备用网页" prop="url">
<el-input
v-model="formData.url"
placeholder="请输入备用网页链接"
maxlength="1000"
/>
<div class="form-tip">低版本微信客户端不支持小程序时的备用链接</div>
</el-form-item>
</template>
<!-- 排序 -->
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="formData.sort"
:min="0"
:max="999"
placeholder="排序值"
/>
<div class="form-tip">数值越小排序越靠前</div>
</el-form-item>
</el-form>
</el-card>
<!-- 回复消息配置仅点击类型菜单 -->
<el-card
v-if="formData.type === 'click'"
class="reply-config"
shadow="never"
>
<template #header>
<span>回复消息配置</span>
</template>
<WxReply v-model="replyData" />
</el-card>
<!-- 操作按钮 -->
<div class="editor-actions">
<el-button type="primary" @click="handleSave">
{{ isNewMenu ? '添加菜单' : '保存修改' }}
</el-button>
<el-button @click="handleCancel">取消</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { WxReply } from '@/components/wechat'
import { MenuType, ReplyType, createEmptyReply } from '../../utils'
import { validateUrl, validateTextContent } from '../../utils/validators'
defineOptions({
name: 'MenuEditor'
})
// Props
const props = defineProps({
modelValue: {
type: Object,
default: () => ({})
},
isParent: {
type: Boolean,
default: true
},
isNew: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'save', 'delete', 'cancel'])
//
const formRef = ref(null)
const formData = ref({
id: null,
name: '',
type: 'click',
menuKey: '',
url: '',
miniProgramAppId: '',
miniProgramPagePath: '',
sort: 0
})
const replyData = ref(createEmptyReply())
//
const hasSelectedMenu = computed(() => {
return Object.keys(props.modelValue).length > 0 || props.isNew
})
const isNewMenu = computed(() => {
return props.isNew || !props.modelValue.id
})
//
const menuTypeOptions = computed(() => {
const baseOptions = [
{ label: '点击推事件', value: MenuType.CLICK },
{ label: '跳转URL', value: MenuType.VIEW },
{ label: '跳转小程序', value: MenuType.MINIPROGRAM }
]
//
if (props.isParent) {
baseOptions.push(
{ label: '扫码推事件', value: MenuType.SCANCODE_PUSH },
{ label: '扫码推事件且弹出"消息接收中"提示框', value: MenuType.SCANCODE_WAITMSG },
{ label: '弹出系统拍照发图', value: MenuType.PIC_SYSPHOTO },
{ label: '弹出拍照或者相册发图', value: MenuType.PIC_PHOTO_OR_ALBUM },
{ label: '弹出微信相册发图器', value: MenuType.PIC_WEIXIN },
{ label: '弹出地理位置选择器', value: MenuType.LOCATION_SELECT }
)
}
return baseOptions
})
//
const formRules = computed(() => ({
name: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{
min: 1,
max: props.isParent ? 4 : 8,
message: `菜单名称长度为1-${props.isParent ? 4 : 8}个字符`,
trigger: 'blur'
}
],
type: [
{ required: true, message: '请选择菜单类型', trigger: 'change' }
],
menuKey: [
{ required: true, message: '请输入菜单Key', trigger: 'blur' },
{ min: 1, max: 128, message: '菜单Key长度为1-128个字符', trigger: 'blur' }
],
url: [
{ required: true, message: '请输入跳转链接', trigger: 'blur' },
{ validator: (rule, value, callback) => {
if (value && !validateUrl(value)) {
callback(new Error('请输入正确的URL格式'))
} else {
callback()
}
}, trigger: 'blur' }
],
miniProgramAppId: [
{ required: true, message: '请输入小程序AppID', trigger: 'blur' }
],
miniProgramPagePath: [
{ required: true, message: '请输入小程序页面路径', trigger: 'blur' }
]
}))
//
const resetForm = () => {
formData.value = {
id: null,
name: '',
type: 'click',
menuKey: '',
url: '',
miniProgramAppId: '',
miniProgramPagePath: '',
sort: 0
}
replyData.value = createEmptyReply()
//
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate()
}
})
}
// modelValue
watch(() => props.modelValue, (newValue) => {
if (newValue && Object.keys(newValue).length > 0) {
//
Object.assign(formData.value, {
id: newValue.id,
name: newValue.name || '',
type: newValue.type || 'click',
menuKey: newValue.menuKey || '',
url: newValue.url || '',
miniProgramAppId: newValue.miniProgramAppId || '',
miniProgramPagePath: newValue.miniProgramPagePath || '',
sort: newValue.sort || 0
})
//
if (newValue.reply) {
replyData.value = { ...newValue.reply }
} else {
replyData.value = createEmptyReply()
}
} else {
//
resetForm()
}
}, { immediate: true, deep: true })
//
const handleTypeChange = (type) => {
//
if (type !== 'click') {
formData.value.menuKey = ''
replyData.value = createEmptyReply()
}
if (type !== 'view' && type !== 'miniprogram') {
formData.value.url = ''
}
if (type !== 'miniprogram') {
formData.value.miniProgramAppId = ''
formData.value.miniProgramPagePath = ''
}
//
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate()
}
})
}
//
const handleSave = async () => {
try {
//
await formRef.value.validate()
//
const saveData = { ...formData.value }
//
if (formData.value.type === 'click') {
saveData.reply = { ...replyData.value }
}
emit('save', saveData)
ElMessage.success(isNewMenu.value ? '菜单添加成功' : '菜单保存成功')
} catch (error) {
console.error('表单验证失败:', error)
}
}
//
const handleDelete = async () => {
try {
await ElMessageBox.confirm('确定要删除这个菜单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
emit('delete', formData.value)
ElMessage.success('菜单删除成功')
} catch {
//
}
}
//
const handleCancel = () => {
emit('cancel')
}
</script>
<style scoped>
.menu-editor {
padding: 20px;
background: #f8f9fa;
min-height: 600px;
}
.no-selection {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
background: white;
border-radius: 8px;
border: 1px solid #e6e6e6;
}
.editor-content {
max-width: 600px;
}
.menu-basic-info {
margin-bottom: 20px;
}
.menu-basic-info :deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 500;
color: #303133;
}
.menu-basic-info :deep(.el-card__body) {
padding: 20px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
.reply-config {
margin-bottom: 20px;
}
.reply-config :deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
font-weight: 500;
color: #303133;
}
.reply-config :deep(.el-card__body) {
padding: 20px;
}
.editor-actions {
display: flex;
gap: 12px;
padding: 20px;
background: white;
border-radius: 8px;
border: 1px solid #e6e6e6;
}
.editor-actions .el-button {
min-width: 100px;
}
/* 表单样式优化 */
.menu-editor :deep(.el-form-item) {
margin-bottom: 20px;
}
.menu-editor :deep(.el-form-item__label) {
font-weight: 500;
color: #606266;
}
.menu-editor :deep(.el-input__inner) {
border-radius: 6px;
}
.menu-editor :deep(.el-select) {
width: 100%;
}
.menu-editor :deep(.el-input-number) {
width: 200px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.menu-editor {
padding: 10px;
}
.editor-content {
max-width: 100%;
}
.card-header {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.editor-actions {
flex-direction: column;
}
.editor-actions .el-button {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,13 @@
/**
* 菜单预览器组件
* 参照yudao-ui-admin的MenuPreviewer组件设计
*/
import MenuPreviewer from './index.vue'
// 组件安装函数
MenuPreviewer.install = function(app) {
app.component(MenuPreviewer.name, MenuPreviewer)
}
export default MenuPreviewer

View File

@ -0,0 +1,297 @@
<template>
<div class="menu-previewer">
<!-- 微信公众号头部 -->
<div class="wechat-header">
<div class="wechat-title">微信公众号</div>
</div>
<!-- 菜单容器 -->
<div class="menu-container">
<!-- 一级菜单 -->
<div class="menu-level-1">
<div
v-for="(menu, index) in menuList"
:key="menu.id || index"
class="menu-item"
:class="{
'active': isMenuActive(index),
'has-children': menu.children && menu.children.length > 0
}"
@click="handleMenuClick(menu, index)"
>
<span class="menu-text">{{ menu.name || '菜单' }}</span>
</div>
<!-- 填充空白菜单项 -->
<div
v-for="i in (3 - menuList.length)"
:key="`empty-${i}`"
class="menu-item empty"
@click="handleAddMenu"
>
<span class="menu-text">+</span>
</div>
</div>
<!-- 二级菜单 -->
<div
v-if="showSubMenu && currentParentMenu && currentParentMenu.children"
class="menu-level-2"
>
<div
v-for="(submenu, subIndex) in currentParentMenu.children"
:key="submenu.id || subIndex"
class="submenu-item"
:class="{ 'active': isSubMenuActive(parentIndex, subIndex) }"
@click="handleSubMenuClick(submenu, parentIndex, subIndex)"
>
<span class="submenu-text">{{ submenu.name || '子菜单' }}</span>
</div>
<!-- 填充空白子菜单项 -->
<div
v-for="i in (5 - currentParentMenu.children.length)"
:key="`empty-sub-${i}`"
class="submenu-item empty"
@click="handleAddSubMenu"
>
<span class="submenu-text">+</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
defineOptions({
name: 'MenuPreviewer'
})
// Props
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
activeIndex: {
type: String,
default: ''
},
parentIndex: {
type: Number,
default: -1
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'menu-clicked', 'submenu-clicked', 'add-menu', 'add-submenu'])
//
const menuList = computed({
get: () => props.modelValue || [],
set: (value) => emit('update:modelValue', value)
})
const showSubMenu = ref(false)
const currentParentMenu = ref(null)
// parentIndex
watch(() => props.parentIndex, (newIndex) => {
if (newIndex >= 0 && newIndex < menuList.value.length) {
currentParentMenu.value = menuList.value[newIndex]
showSubMenu.value = !!(currentParentMenu.value && currentParentMenu.value.children && currentParentMenu.value.children.length > 0)
} else {
showSubMenu.value = false
currentParentMenu.value = null
}
}, { immediate: true })
//
const isMenuActive = (index) => {
if (!props.activeIndex) return false
const activeIndexStr = String(index)
return props.activeIndex === activeIndexStr || props.activeIndex.startsWith(activeIndexStr + '-')
}
//
const isSubMenuActive = (parentIdx, subIdx) => {
return props.activeIndex === `${parentIdx}-${subIdx}`
}
//
const handleMenuClick = (menu, index) => {
emit('menu-clicked', menu, index)
}
//
const handleSubMenuClick = (submenu, parentIdx, subIdx) => {
emit('submenu-clicked', submenu, parentIdx, subIdx)
}
//
const handleAddMenu = () => {
emit('add-menu')
}
//
const handleAddSubMenu = () => {
emit('add-submenu')
}
</script>
<style scoped>
.menu-previewer {
width: 300px;
background: #f5f5f5;
border: 1px solid #e6e6e6;
border-radius: 8px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.wechat-header {
background: #1aad19;
color: white;
text-align: center;
padding: 12px 0;
font-size: 16px;
font-weight: 500;
}
.wechat-title {
margin: 0;
}
.menu-container {
background: white;
position: relative;
}
.menu-level-1 {
display: flex;
border-bottom: 1px solid #e6e6e6;
}
.menu-item {
flex: 1;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid #e6e6e6;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
background: white;
}
.menu-item:last-child {
border-right: none;
}
.menu-item:hover {
background: #f0f0f0;
}
.menu-item.active {
background: #1aad19;
color: white;
}
.menu-item.has-children::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #e6e6e6;
}
.menu-item.active.has-children::after {
border-bottom-color: #1aad19;
}
.menu-item.empty {
background: #fafafa;
color: #999;
border: 2px dashed #ddd;
margin: 5px;
border-radius: 4px;
height: 40px;
}
.menu-item.empty:hover {
background: #f0f0f0;
border-color: #1aad19;
color: #1aad19;
}
.menu-text {
font-size: 14px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.menu-level-2 {
background: white;
border-bottom: 1px solid #e6e6e6;
max-height: 250px;
overflow-y: auto;
}
.submenu-item {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.3s ease;
}
.submenu-item:last-child {
border-bottom: none;
}
.submenu-item:hover {
background: #f8f8f8;
}
.submenu-item.active {
background: #e8f5e8;
color: #1aad19;
}
.submenu-item.empty {
background: #fafafa;
color: #999;
border: 2px dashed #ddd;
margin: 5px 10px;
border-radius: 4px;
height: 34px;
justify-content: center;
}
.submenu-item.empty:hover {
background: #f0f0f0;
border-color: #1aad19;
color: #1aad19;
}
.submenu-text {
font-size: 13px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,204 @@
<template>
<div class="reply-table">
<el-table
v-loading="loading"
:data="data"
style="width: 100%"
empty-text="暂无数据"
>
<el-table-column label="ID" prop="ID" width="80" />
<el-table-column
v-if="showTrigger"
label="触发条件"
width="200"
>
<template #default="scope">
<span v-if="scope.row.type === 1">用户关注</span>
<span v-else-if="scope.row.type === 2">
{{ scope.row.requestMessageType || '所有消息' }}
</span>
<span v-else-if="scope.row.type === 3">
{{ scope.row.requestKeyword }}
</span>
</template>
</el-table-column>
<el-table-column
v-if="showMatch"
label="匹配方式"
width="100"
>
<template #default="scope">
<span v-if="scope.row.type === 3">
<el-tag v-if="scope.row.requestMatch === 1" type="success">完全匹配</el-tag>
<el-tag v-else-if="scope.row.requestMatch === 2" type="warning">部分匹配</el-tag>
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="回复内容" min-width="200">
<template #default="scope">
<div class="reply-preview">
<el-tag
v-if="scope.row.replyData"
:type="getReplyTypeTag(scope.row.replyData.type)"
size="small"
>
{{ getReplyTypeName(scope.row.replyData.type) }}
</el-tag>
<span class="reply-content">
{{ getReplyPreview(scope.row.replyData) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ scope.row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="180">
<template #default="scope">
<el-button
type="primary"
link
icon="Edit"
@click="$emit('edit', scope.row)"
>
编辑
</el-button>
<el-button
type="success"
link
icon="Promotion"
@click="$emit('test', scope.row)"
>
测试
</el-button>
<el-button
type="danger"
link
icon="Delete"
@click="$emit('delete', scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { formatDate } from '@/utils/format'
defineOptions({
name: 'ReplyTable'
})
// Props
defineProps({
loading: {
type: Boolean,
default: false
},
data: {
type: Array,
default: () => []
},
showTrigger: {
type: Boolean,
default: true
},
showMatch: {
type: Boolean,
default: true
}
})
// Emits
defineEmits(['edit', 'delete', 'test'])
//
const getReplyTypeTag = (replyType) => {
const typeMap = {
text: 'primary',
image: 'success',
voice: 'warning',
video: 'info',
news: 'danger',
music: 'purple'
}
return typeMap[replyType] || 'info'
}
//
const getReplyTypeName = (replyType) => {
const typeMap = {
text: '文本',
image: '图片',
voice: '语音',
video: '视频',
news: '图文',
music: '音乐'
}
return typeMap[replyType] || replyType
}
//
const getReplyPreview = (replyData) => {
if (!replyData) return '无内容'
switch (replyData.type) {
case 'text':
return replyData.content || '无内容'
case 'image':
return replyData.mediaId ? '图片消息' : '无图片'
case 'voice':
return replyData.mediaId ? '语音消息' : '无语音'
case 'video':
return replyData.title || '视频消息'
case 'news':
return replyData.articles && replyData.articles.length > 0
? `${replyData.articles[0].title}${replyData.articles.length}篇图文`
: '无图文'
case 'music':
return replyData.title || '音乐消息'
default:
return '未知类型'
}
}
</script>
<style scoped>
.reply-table {
background: white;
}
.reply-preview {
display: flex;
align-items: center;
gap: 8px;
}
.reply-content {
font-size: 13px;
color: #606266;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>