This commit is contained in:
parent
e03259608e
commit
e43d78ddf0
|
|
@ -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:"小程序描述"`
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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("发送消息功能暂未实现")
|
||||
// }
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue