1567 lines
57 KiB
Vue
1567 lines
57 KiB
Vue
<template>
|
||
<div class="notification-settings">
|
||
<div class="settings-content">
|
||
<el-tabs v-model="activeSubTab" tab-position="left" class="sub-tabs-container">
|
||
|
||
<!-- 邮箱 -->
|
||
<el-tab-pane name="email">
|
||
<template #label>
|
||
<span class="tab-label-item">
|
||
<el-icon><Message /></el-icon>
|
||
<span>邮箱配置</span>
|
||
</span>
|
||
</template>
|
||
<div class="tab-pane-content">
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>邮箱通知配置</span>
|
||
</div>
|
||
</template>
|
||
<el-form :model="formData.email" label-width="140px" label-position="right">
|
||
<el-form-item label="启用状态">
|
||
<el-switch v-model="formData.email.enabled" />
|
||
</el-form-item>
|
||
<el-form-item label="发件人邮箱" required>
|
||
<el-input v-model="formData.email.fromAddress" placeholder="例如:noreply@example.com" clearable />
|
||
</el-form-item>
|
||
<el-form-item label="发件人名称">
|
||
<el-input v-model="formData.email.fromName" placeholder="例如:官方网站" clearable />
|
||
</el-form-item>
|
||
<el-form-item label="SMTP 服务器" required>
|
||
<el-input v-model="formData.email.host" placeholder="例如:smtp.qq.com" clearable />
|
||
</el-form-item>
|
||
<el-form-item label="SMTP 端口" required>
|
||
<el-input v-model="formData.email.port" placeholder="例如:465 或 587" clearable />
|
||
</el-form-item>
|
||
<el-form-item label="授权码/密码" required>
|
||
<el-input v-model="formData.email.password" type="password" show-password placeholder="邮箱授权码或登录密码" clearable />
|
||
</el-form-item>
|
||
<el-form-item label="加密方式">
|
||
<el-radio-group v-model="formData.email.encryption">
|
||
<el-radio label="ssl">SSL</el-radio>
|
||
<el-radio label="tls">TLS</el-radio>
|
||
<el-radio label="none">无加密</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="超时时间(秒)">
|
||
<el-input-number v-model="formData.email.timeout" :min="1" :max="300" controls-position="right" />
|
||
</el-form-item>
|
||
<el-form-item label="测试收件邮箱">
|
||
<el-input v-model="formData.email.testEmail" placeholder="输入邮箱地址用于测试发信" clearable />
|
||
<div style="margin-top: 8px;">
|
||
<el-button type="success" plain size="small" :loading="emailTestLoading" @click="handleSendEmailTest">
|
||
发送测试邮件
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- 短信 -->
|
||
<el-tab-pane name="sms">
|
||
<template #label>
|
||
<span class="tab-label-item">
|
||
<el-icon><Cellphone /></el-icon>
|
||
<span>短信配置</span>
|
||
</span>
|
||
</template>
|
||
<div class="tab-pane-content">
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>短信配置</span>
|
||
</div>
|
||
</template>
|
||
<el-form :model="formData.sms" label-width="140px" label-position="right">
|
||
<el-form-item label="启用状态">
|
||
<el-switch v-model="formData.sms.enabled" />
|
||
</el-form-item>
|
||
<el-form-item label="服务商">
|
||
<el-radio-group v-model="formData.sms.provider">
|
||
<el-radio label="aliyun">阿里云短信</el-radio>
|
||
<el-radio label="tencent">腾讯云短信</el-radio>
|
||
<el-radio label="custom">自定义</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
|
||
<!-- 阿里云/腾讯云服务商配置 -->
|
||
<template v-if="formData.sms.provider !== 'custom'">
|
||
<el-form-item label="AccessKey ID">
|
||
<el-input v-model="formData.sms.access_key" placeholder="请输入 AccessKey ID" />
|
||
</el-form-item>
|
||
<el-form-item label="AccessKey Secret">
|
||
<el-input v-model="formData.sms.secret_key" type="password" show-password placeholder="请输入 AccessKey Secret" />
|
||
</el-form-item>
|
||
<el-form-item label="短信签名">
|
||
<el-input v-model="formData.sms.sign_name" placeholder="例如:云泽科技" />
|
||
</el-form-item>
|
||
<el-form-item label="短信模板 Code">
|
||
<el-input v-model="formData.sms.template_code" placeholder="例如:SMS_200300400" />
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 自定义网关配置 -->
|
||
<template v-else>
|
||
<el-divider content-position="left">自定义短信网关</el-divider>
|
||
<el-form-item label="短信网关地址" required>
|
||
<el-input
|
||
v-model="formData.sms.backendUrl"
|
||
placeholder="例如:https://yzsms.yunzer.cn"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="短信API KEY" required>
|
||
<el-input
|
||
v-model="formData.sms.apiKey"
|
||
placeholder="请输入 API_KEY"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="测试手机号">
|
||
<el-input
|
||
v-model="formData.sms.testPhone"
|
||
placeholder="国际格式号码,例如 +8613712345678"
|
||
clearable
|
||
/>
|
||
<div style="margin-top: 8px;">
|
||
<el-button type="success" plain size="small" :loading="testLoading" @click="handleSendTest">
|
||
发送测试短信
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</template>
|
||
</el-form>
|
||
</el-card>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
|
||
<!-- 站内信配置 -->
|
||
<el-tab-pane name="sitemsg">
|
||
<template #label>
|
||
<span class="tab-label-item">
|
||
<el-icon><Bell /></el-icon>
|
||
<span>站内信配置</span>
|
||
</span>
|
||
</template>
|
||
<div class="tab-pane-content">
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>站内信通知配置</span>
|
||
</div>
|
||
</template>
|
||
<el-form :model="formData.sitemsg" label-width="140px" label-position="right">
|
||
<!--
|
||
<el-form-item label="启用状态">
|
||
<el-switch v-model="formData.sitemsg.enabled" disabled />
|
||
<span class="form-tip-inline" style="margin-left: 10px;">站内信功能为系统核心功能,必须开启</span>
|
||
</el-form-item>
|
||
-->
|
||
<el-form-item label="保留天数">
|
||
<el-input-number v-model="formData.sitemsg.retention_days" :min="1" :max="365" controls-position="right" />
|
||
<span class="form-tip-inline">天(过期消息将自动清理)</span>
|
||
</el-form-item>
|
||
<el-form-item label="自动标记已读">
|
||
<el-switch v-model="formData.sitemsg.auto_read" />
|
||
<div class="form-tip">用户打开消息中心列表时是否自动将所有消息标记为已读</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- 钉钉配置 -->
|
||
<el-tab-pane name="dingtalk">
|
||
<template #label>
|
||
<span class="tab-label-item">
|
||
<el-icon><ChatDotSquare /></el-icon>
|
||
<span>钉钉配置</span>
|
||
</span>
|
||
</template>
|
||
<div class="tab-pane-content">
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>钉钉群机器人配置</span>
|
||
</div>
|
||
</template>
|
||
<el-form :model="formData.dingtalk" label-width="140px" label-position="right">
|
||
<el-form-item label="启用状态">
|
||
<el-switch v-model="formData.dingtalk.enabled" />
|
||
</el-form-item>
|
||
<el-form-item label="Webhook 地址">
|
||
<el-input v-model="formData.dingtalk.webhook_url" type="textarea" :rows="3" placeholder="请输入钉钉机器人的 Webhook 地址 (https://oapi.dingtalk.com/robot/send?access_token=...)" />
|
||
</el-form-item>
|
||
<el-form-item label="加签密钥">
|
||
<el-input v-model="formData.dingtalk.secret" type="password" show-password placeholder="请输入安全配置中加签的密钥(可选)" />
|
||
<div class="form-tip">如果机器人的安全配置开启了“加签”,请在此处填写对应的秘钥</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- Webhook配置 -->
|
||
<el-tab-pane name="WebHook配置">
|
||
<template #label>
|
||
<span class="tab-label-item">
|
||
<el-icon><Connection /></el-icon>
|
||
<span>WebHook配置</span>
|
||
</span>
|
||
</template>
|
||
<div class="tab-pane-content">
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>HTTP Webhook 配置</span>
|
||
</div>
|
||
</template>
|
||
<el-form :model="formData.webhook" label-width="140px" label-position="right">
|
||
<el-form-item label="启用状态">
|
||
<el-switch v-model="formData.webhook.enabled" />
|
||
</el-form-item>
|
||
<el-form-item label="推送 URL">
|
||
<el-input v-model="formData.webhook.url" type="textarea" :rows="2" placeholder="请输入通知推送的 Webhook 目标 URL" />
|
||
</el-form-item>
|
||
<el-form-item label="请求方式">
|
||
<el-select v-model="formData.webhook.method" style="width: 120px">
|
||
<el-option label="POST" value="POST" />
|
||
<el-option label="GET" value="GET" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="签名密钥 (Token)">
|
||
<el-input v-model="formData.webhook.token" type="password" show-password placeholder="请输入用于验证的 Token 密钥" />
|
||
<div class="form-tip">该 Token 将被包含在请求 Header 的 X-Webhook-Token 中以供接收方校验</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- 飞书配置 -->
|
||
<el-tab-pane name="feishu">
|
||
<template #label>
|
||
<span class="tab-label-item">
|
||
<el-icon><Coordinate /></el-icon>
|
||
<span>飞书配置</span>
|
||
</span>
|
||
</template>
|
||
<div class="tab-pane-content">
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>飞书机器人配置</span>
|
||
</div>
|
||
</template>
|
||
<el-form :model="formData.feishu" label-width="140px" label-position="right">
|
||
<el-form-item label="启用状态">
|
||
<el-switch v-model="formData.feishu.enabled" />
|
||
</el-form-item>
|
||
<el-form-item label="Webhook 地址">
|
||
<el-input v-model="formData.feishu.webhook_url" type="textarea" :rows="3" placeholder="请输入飞书机器人的 Webhook 地址 (https://open.feishu.cn/open-apis/bot/v2/hook/...)" />
|
||
</el-form-item>
|
||
<el-form-item label="加签密钥">
|
||
<el-input v-model="formData.feishu.secret" type="password" show-password placeholder="请输入安全配置中加签的密钥(可选)" />
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- Bark配置 -->
|
||
<el-tab-pane name="bark">
|
||
<template #label>
|
||
<span class="tab-label-item">
|
||
<el-icon><ChatRound /></el-icon>
|
||
<span>Bark配置</span>
|
||
</span>
|
||
</template>
|
||
<div class="tab-pane-content">
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>Bark 推送配置</span>
|
||
</div>
|
||
</template>
|
||
<el-form :model="formData.bark" label-width="140px" label-position="right">
|
||
<el-form-item label="启用状态">
|
||
<el-switch v-model="formData.bark.enabled" />
|
||
</el-form-item>
|
||
<el-form-item label="推送服务器">
|
||
<el-input v-model="formData.bark.server_url" placeholder="默认官方服务:https://api.day.app" />
|
||
<div class="form-tip">支持自建 Bark 服务,如:https://bark.yourdomain.com</div>
|
||
</el-form-item>
|
||
<el-form-item label="设备 Key">
|
||
<el-input v-model="formData.bark.device_key" placeholder="请输入您的 Bark Device Key" />
|
||
</el-form-item>
|
||
<el-form-item label="测试推送">
|
||
<el-button type="success" plain size="small" :loading="barkTestLoading" @click="handleSendBarkTest">
|
||
发送测试推送
|
||
</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- 微信配置 -->
|
||
<el-tab-pane name="wechat">
|
||
<template #label>
|
||
<span class="tab-label-item">
|
||
<el-icon><ChatDotRound /></el-icon>
|
||
<span>微信配置</span>
|
||
</span>
|
||
</template>
|
||
<div class="tab-pane-content">
|
||
<!-- 服务号配置 -->
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>微信公众号(服务号)配置</span>
|
||
<el-tag v-if="Number(wechatCfg.verified) === 1" type="success" size="small">已认证</el-tag>
|
||
<el-tag v-else type="warning" size="small" class="ml8">未认证:可关注取码,认证后可推送</el-tag>
|
||
</div>
|
||
</template>
|
||
<el-form label-width="140px" label-position="right">
|
||
<el-form-item label="启用状态">
|
||
<el-switch v-model="wechatCfg.enabled" :active-value="1" :inactive-value="0" />
|
||
</el-form-item>
|
||
<el-form-item label="AppID" required>
|
||
<el-input v-model="wechatCfg.app_id" placeholder="公众号后台「设置与开发 → 基本配置」中的 AppID" clearable />
|
||
</el-form-item>
|
||
<el-form-item label="AppSecret">
|
||
<el-input v-model="wechatCfg.app_secret" type="password" show-password placeholder="已保存时显示掩码,保持不变则无需修改" clearable />
|
||
</el-form-item>
|
||
<el-form-item label="服务器 Token">
|
||
<el-input v-model="wechatCfg.token" placeholder="3-32 位字母/数字,两端保持一致" clearable>
|
||
<template #append>
|
||
<el-button @click="wechatCfg.token = randomWechatToken(24)">随机生成</el-button>
|
||
</template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-form-item label="EncodingAESKey">
|
||
<el-input v-model="wechatCfg.aes_key" placeholder="43 位;安全/兼容模式必填" clearable>
|
||
<template #append>
|
||
<el-button @click="wechatCfg.aes_key = randomWechatToken(43)">随机生成</el-button>
|
||
</template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-form-item label="消息加解密方式">
|
||
<el-radio-group v-model="wechatCfg.encrypt_mode">
|
||
<el-radio label="plain">明文模式</el-radio>
|
||
<el-radio label="compatible">兼容模式</el-radio>
|
||
<el-radio label="safe">安全模式</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="公众号已认证">
|
||
<el-switch v-model="wechatCfg.verified" :active-value="1" :inactive-value="0" />
|
||
<span class="form-tip-inline">认证后才可主动推送(模板消息/订阅通知)</span>
|
||
</el-form-item>
|
||
<el-form-item label="模板消息ID">
|
||
<el-input v-model="wechatCfg.template_id" placeholder="认证后在公众号后台申请,用于提醒/公告推送" clearable />
|
||
</el-form-item>
|
||
<el-form-item label="关注二维码">
|
||
<div class="qr-upload">
|
||
<template v-if="wechatCfg.follow_qrcode">
|
||
<img :src="wechatCfg.follow_qrcode" class="qr-preview" alt="关注二维码" />
|
||
<el-button type="danger" plain size="small" @click="removeFollowQrcode">删除</el-button>
|
||
</template>
|
||
<el-upload
|
||
v-else
|
||
:show-file-list="false"
|
||
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
|
||
:http-request="handleFollowQrcodeUpload"
|
||
>
|
||
<el-button :loading="followQrcodeUploading">上传二维码图片</el-button>
|
||
</el-upload>
|
||
</div>
|
||
<div class="form-tip">
|
||
上传公众号的「关注二维码」(公众号后台「账号设置 → 二维码」下载,2MB 内图片)。<br />
|
||
用户在租户端「绑定微信」页扫码进入公众号会话 → 发送「验证码」→ 回页填码完成绑定(未认证公众号无法生成带参二维码,统一使用此图片)。
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="服务器地址">
|
||
<el-input v-model="wechatCfg.callback_url" readonly>
|
||
<template #append>
|
||
<el-button @click="copyWechatText(wechatCfg.callback_url)">复制</el-button>
|
||
</template>
|
||
</el-input>
|
||
<div class="form-tip">
|
||
① 微信后台(公众平台 / 微信开发者平台)「设置与开发 → 基本配置」→ 复制 AppID / AppSecret 填入上方,保存并启用;<br />
|
||
② 将本地址连同上方 Token / EncodingAESKey 填入微信后台「服务器配置」(加解密方式保持一致)——提交时微信会立即请求本地址校验,需本页已保存并启用;<br />
|
||
③ 若「连接测试」提示 40164,请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP(服务器执行 curl ifconfig.me 可查看)。
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" :loading="wechatSaving" @click="saveWechatConfig">保存配置</el-button>
|
||
<el-button :loading="wechatTesting" @click="testWechatConfig">连接测试</el-button>
|
||
<el-button type="warning" plain :loading="wechatMenuPublishing" @click="publishWechatMenu">
|
||
发布「扫码登录」菜单
|
||
</el-button>
|
||
<div class="form-tip">
|
||
在公众号底部菜单追加「扫码登录」(点击事件,键值 LOGIN):用户点一下即可收到确认登录链接,无需输入。<br />
|
||
菜单接口需公众号已认证;未认证会提示 errcode=48001,此时用「公众号内发送登录」的兜底路径。
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
|
||
<!-- 绑定我的微信 -->
|
||
<!-- <el-card shadow="never" class="settings-card bind-card">
|
||
<template #header>
|
||
<div class="card-header"><span>绑定我的微信(接收提醒 / 公告)</span></div>
|
||
</template>
|
||
<template v-if="wechatBinding.bound">
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="微信昵称">{{ wechatBinding.nickname || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="关注状态">
|
||
<el-tag size="small" :type="Number(wechatBinding.subscribe) === 1 ? 'success' : 'danger'">
|
||
{{ Number(wechatBinding.subscribe) === 1 ? "已关注" : "已取关" }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="绑定时间">
|
||
{{ wechatBinding.bind_time ? formatTime(wechatBinding.bind_time) : "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="openid">{{ wechatBinding.openid }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div class="bind-actions">
|
||
<el-button type="primary" plain size="small" :loading="wechatPushTesting" @click="sendWechatTestPush">
|
||
发送测试推送
|
||
</el-button>
|
||
<el-button type="danger" plain size="small" @click="unbindMyWechat">解除绑定</el-button>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<el-steps :active="bindStep" simple class="bind-steps">
|
||
<el-step title="关注公众号" />
|
||
<el-step title="获取验证码(扫码或发送「验证码」)" />
|
||
<el-step title="输入验证码完成绑定" />
|
||
</el-steps>
|
||
<div class="bind-area">
|
||
<div class="bind-qr">
|
||
<img v-if="bindQrUrl" :src="bindQrUrl" class="qr-img" alt="公众号二维码" />
|
||
<el-button v-else type="primary" :loading="bindStarting" @click="startBind">获取绑定二维码</el-button>
|
||
</div>
|
||
<div class="bind-form">
|
||
<el-input v-model="bindCode" placeholder="输入公众号回复的验证码" maxlength="8" class="bind-input" />
|
||
<el-button type="primary" :loading="bindConfirming" @click="confirmBind">完成绑定</el-button>
|
||
<p class="form-tip">两种方式任选:① 微信扫描左侧二维码(未关注会自动关注);② 在公众号内直接发送「验证码」。都会回复 6 位验证码({{ bindTtlText }}),在上方输入即可完成绑定。</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-card> -->
|
||
|
||
<!-- 粉丝列表 -->
|
||
<!-- <el-card shadow="never" class="settings-card bind-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>关注用户(粉丝)</span>
|
||
<div class="follower-tools">
|
||
<el-input v-model="followerQuery.keyword" size="small" placeholder="昵称 / openid / 备注" clearable @keyup.enter="loadFollowers" />
|
||
<el-select v-model="followerQuery.bind_type" size="small" placeholder="绑定类型" clearable class="follower-select">
|
||
<el-option label="平台用户" value="platform_user" />
|
||
<el-option label="租户用户" value="tenant_user" />
|
||
</el-select>
|
||
<el-button size="small" @click="loadFollowers">查询</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<el-table :data="followers" size="small" v-loading="followersLoading">
|
||
<el-table-column label="用户" min-width="140">
|
||
<template #default="{ row }">{{ row.nickname || "-" }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="openid" label="openid" min-width="180" show-overflow-tooltip />
|
||
<el-table-column label="关注状态" width="90" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="Number(row.subscribe) === 1 ? 'success' : 'info'" effect="plain">
|
||
{{ Number(row.subscribe) === 1 ? "已关注" : "已取关" }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="绑定对象" min-width="140">
|
||
<template #default="{ row }">
|
||
<span v-if="row.bind_name">{{ row.bind_name }}</span>
|
||
<span v-else class="muted">未绑定</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="关注时间" width="160" align="center">
|
||
<template #default="{ row }">{{ row.subscribe_time ? formatTime(row.subscribe_time) : "-" }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="170" align="center">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="primary" link @click="editFollowerRemark(row)">备注</el-button>
|
||
<el-button v-if="row.bind_name" size="small" type="danger" link @click="unbindFollower(row)">
|
||
解除绑定
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-pagination
|
||
class="follower-pager"
|
||
layout="total, prev, pager, next"
|
||
:total="followerQuery.total"
|
||
:current-page="followerQuery.page"
|
||
:page-size="followerQuery.pageSize"
|
||
@current-change="handleFollowerPage"
|
||
/>
|
||
</el-card> -->
|
||
|
||
<!-- 消息推送 -->
|
||
<!-- <el-card shadow="never" class="settings-card bind-card">
|
||
<template #header>
|
||
<div class="card-header"><span>消息推送(提醒 / 公告)</span></div>
|
||
</template>
|
||
<el-alert
|
||
v-if="Number(wechatCfg.verified) !== 1"
|
||
type="warning"
|
||
show-icon
|
||
:closable="false"
|
||
class="push-alert"
|
||
title="公众号未认证:微信不允许主动推送。认证后配置「模板消息ID」即可使用;当前可先积累关注用户。"
|
||
/>
|
||
<el-form label-width="120px" label-position="right">
|
||
<el-form-item label="推送范围">
|
||
<el-select v-model="pushForm.bind_type" style="width: 200px">
|
||
<el-option label="全部已关注粉丝" value="" />
|
||
<el-option label="平台用户" value="platform_user" />
|
||
<el-option label="租户用户" value="tenant_user" />
|
||
</el-select>
|
||
<span class="form-tip-inline">单次最多 500 人</span>
|
||
</el-form-item>
|
||
<el-form-item label="标题">
|
||
<el-input v-model="pushForm.title" maxlength="50" placeholder="如:系统维护通知" />
|
||
</el-form-item>
|
||
<el-form-item label="内容">
|
||
<el-input v-model="pushForm.content" type="textarea" :rows="3" maxlength="200" placeholder="推送正文" />
|
||
</el-form-item>
|
||
<el-form-item label="备注 / 跳转">
|
||
<el-input v-model="pushForm.remark" maxlength="50" placeholder="备注(可选)" />
|
||
<el-input v-model="pushForm.url" class="push-url" placeholder="点击消息跳转地址(可选,如 https://www.yunzer.cn)" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button
|
||
type="primary"
|
||
:loading="pushSending"
|
||
:disabled="Number(wechatCfg.verified) !== 1"
|
||
@click="sendPush"
|
||
>
|
||
发送推送
|
||
</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card> -->
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- Telegram配置 -->
|
||
<el-tab-pane name="Telegram配置">
|
||
<template #label>
|
||
<span class="tab-label-item">
|
||
<el-icon><Promotion /></el-icon>
|
||
<span>Telegram配置</span>
|
||
</span>
|
||
</template>
|
||
<div class="tab-pane-content">
|
||
<el-card shadow="never" class="settings-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>Telegram Bot 推送</span>
|
||
</div>
|
||
</template>
|
||
<el-form :model="formData.telegram" label-width="140px" label-position="right">
|
||
<el-form-item label="启用状态">
|
||
<el-switch v-model="formData.telegram.enabled" />
|
||
</el-form-item>
|
||
<el-form-item label="Bot Token">
|
||
<el-input v-model="formData.telegram.bot_token" type="password" show-password placeholder="例如:123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ" />
|
||
</el-form-item>
|
||
<el-form-item label="Chat ID">
|
||
<el-input v-model="formData.telegram.chat_id" placeholder="例如:123456789 或 @my_channel" />
|
||
<div class="form-tip">支持个人 Chat ID、群组 ID (通常为负数) 或公开频道 Username</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
</el-tabs>
|
||
</div>
|
||
|
||
<!-- 底部操作按钮 -->
|
||
<div class="footer-actions">
|
||
<el-button @click="handleReset">重置</el-button>
|
||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||
保存配置
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { reactive, ref, watch, onMounted } from 'vue';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import {
|
||
Message,
|
||
Cellphone,
|
||
Bell,
|
||
ChatDotSquare,
|
||
Connection,
|
||
Coordinate,
|
||
ChatRound,
|
||
ChatDotRound,
|
||
Promotion
|
||
} from "@element-plus/icons-vue";
|
||
import { getSmsInfo, editSmsInfo, sendTestSms } from "@/api/sms";
|
||
import { getEmailInfo, editEmailInfo, sendTestEmail } from "@/api/email";
|
||
import { getSiteReminderConfig, saveSiteReminderConfig } from "@/api/sitereminder";
|
||
import { getBarkConfig, saveBarkConfig, sendTestBark } from "@/api/sitesettings";
|
||
import { formatTime } from "@/utils/datetime";
|
||
import {
|
||
getWechatMpConfig,
|
||
saveWechatMpConfig,
|
||
testWechatMpConnection,
|
||
publishWechatMpMenu,
|
||
uploadWechatFollowQrcode,
|
||
deleteWechatFollowQrcode,
|
||
startWechatMpBind,
|
||
getWechatMpBindStatus,
|
||
confirmWechatMpBind,
|
||
getMyWechatMpBinding,
|
||
unbindWechatMp,
|
||
getWechatMpFollowers,
|
||
setWechatMpFollowerRemark,
|
||
unbindWechatMpFollower,
|
||
pushWechatMpMessage,
|
||
pushWechatMpTest
|
||
} from "@/api/wechatMp";
|
||
|
||
const STORAGE_KEY = "notification_settings_draft";
|
||
const activeSubTab = ref("email");
|
||
const submitting = ref(false);
|
||
const barkTestLoading = ref(false);
|
||
|
||
const formData = reactive({
|
||
email: {
|
||
enabled: false,
|
||
fromAddress: "",
|
||
fromName: "",
|
||
host: "",
|
||
port: "",
|
||
password: "",
|
||
encryption: "ssl",
|
||
timeout: 30,
|
||
testEmail: "",
|
||
},
|
||
sms: {
|
||
enabled: false,
|
||
provider: "aliyun",
|
||
access_key: "",
|
||
secret_key: "",
|
||
sign_name: "",
|
||
template_code: "",
|
||
backendUrl: "",
|
||
apiKey: "",
|
||
testPhone: "",
|
||
},
|
||
sitemsg: {
|
||
enabled: true,
|
||
retention_days: 30,
|
||
auto_read: false,
|
||
},
|
||
dingtalk: {
|
||
enabled: false,
|
||
webhook_url: "",
|
||
secret: "",
|
||
},
|
||
webhook: {
|
||
enabled: false,
|
||
url: "",
|
||
method: "POST",
|
||
token: "",
|
||
},
|
||
feishu: {
|
||
enabled: false,
|
||
webhook_url: "",
|
||
secret: "",
|
||
},
|
||
bark: {
|
||
enabled: false,
|
||
server_url: "https://api.day.app",
|
||
device_key: "",
|
||
},
|
||
telegram: {
|
||
enabled: false,
|
||
bot_token: "",
|
||
chat_id: "",
|
||
}
|
||
});
|
||
|
||
const testLoading = ref(false);
|
||
|
||
const emailTestLoading = ref(false);
|
||
|
||
const loadDraft = () => {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (!raw) return;
|
||
try {
|
||
const data = JSON.parse(raw);
|
||
for (const key in data) {
|
||
if (formData[key] && typeof data[key] === 'object') {
|
||
Object.assign(formData[key], data[key]);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("加载本地通知配置失败", e);
|
||
}
|
||
};
|
||
|
||
const loadEmailConfig = async () => {
|
||
try {
|
||
const res = await getEmailInfo();
|
||
if (res.code === 200 && Array.isArray(res.data) && res.data.length > 0) {
|
||
const item = res.data[0];
|
||
formData.email.fromAddress = item.from_address || "";
|
||
formData.email.fromName = item.from_name || "";
|
||
formData.email.host = item.host || "";
|
||
formData.email.port = item.port != null ? String(item.port) : "";
|
||
formData.email.password = item.password || "";
|
||
formData.email.encryption = item.encryption || "ssl";
|
||
formData.email.timeout = item.timeout != null ? item.timeout : 30;
|
||
formData.email.enabled = Number(item.status ?? item.enabled ?? 0) === 1;
|
||
}
|
||
} catch (error) {
|
||
console.error("加载邮箱配置失败:", error);
|
||
}
|
||
};
|
||
|
||
const loadSmsConfig = async () => {
|
||
try {
|
||
const res = await getSmsInfo();
|
||
if (res.code === 200 && Array.isArray(res.data) && res.data.length > 0) {
|
||
const item = res.data[0];
|
||
formData.sms.backendUrl = item.backend_url || item.backendUrl || "";
|
||
formData.sms.apiKey = item.api_key || item.apiKey || "";
|
||
}
|
||
} catch (error) {
|
||
console.error("加载自定义短信配置失败:", error);
|
||
}
|
||
};
|
||
|
||
const loadSiteReminderConfig = async () => {
|
||
try {
|
||
const res = await getSiteReminderConfig();
|
||
if (res.code === 200 && res.data) {
|
||
formData.sitemsg.retention_days = res.data.retention_days ?? 30;
|
||
formData.sitemsg.auto_read = res.data.auto_read === 1;
|
||
formData.sitemsg.enabled = true;
|
||
}
|
||
} catch (error) {
|
||
console.error("加载站内信配置失败:", error);
|
||
}
|
||
};
|
||
|
||
const saveDraft = () => {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(formData));
|
||
};
|
||
|
||
const handleReset = () => {
|
||
const defaults = {
|
||
email: { enabled: false, fromAddress: "", fromName: "", host: "", port: "", password: "", encryption: "ssl", timeout: 30, testEmail: "" },
|
||
sms: { enabled: false, provider: "aliyun", access_key: "", secret_key: "", sign_name: "", template_code: "", backendUrl: "", apiKey: "", testPhone: "" },
|
||
sitemsg: { enabled: true, retention_days: 30, auto_read: false },
|
||
dingtalk: { enabled: false, webhook_url: "", secret: "" },
|
||
webhook: { enabled: false, url: "", method: "POST", token: "" },
|
||
feishu: { enabled: false, webhook_url: "", secret: "" },
|
||
bark: { enabled: false, server_url: "https://api.day.app", device_key: "" },
|
||
telegram: { enabled: false, bot_token: "", chat_id: "" },
|
||
};
|
||
|
||
const currentTab = activeSubTab.value;
|
||
Object.assign(formData[currentTab], defaults[currentTab]);
|
||
saveDraft();
|
||
ElMessage.success("已重置当前配置");
|
||
};
|
||
|
||
const handleSendEmailTest = async () => {
|
||
if (!formData.email.testEmail) {
|
||
ElMessage.warning("请先输入测试收件邮箱");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
emailTestLoading.value = true;
|
||
const payload = {
|
||
fromAddress: formData.email.fromAddress,
|
||
fromName: formData.email.fromName,
|
||
host: formData.email.host,
|
||
port: formData.email.port,
|
||
password: formData.email.password,
|
||
encryption: formData.email.encryption,
|
||
timeout: formData.email.timeout,
|
||
testEmail: formData.email.testEmail
|
||
};
|
||
const res = await sendTestEmail(payload);
|
||
if (res.code === 200) {
|
||
ElMessage.success("测试邮件发送成功");
|
||
} else {
|
||
ElMessage.error(res.msg || "测试邮件发送失败,请稍后重试");
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error?.message || "测试邮件发送失败,请稍后重试");
|
||
} finally {
|
||
emailTestLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleSendTest = async () => {
|
||
const phone = (formData.sms.testPhone || "").trim();
|
||
if (!phone) {
|
||
ElMessage.warning("请先输入测试收件手机号");
|
||
return;
|
||
}
|
||
if (!/^\+\d{6,15}$/.test(phone)) {
|
||
ElMessage.warning("请使用国际格式号码(以 + 开头,后面为数字)");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
testLoading.value = true;
|
||
const res = await sendTestSms({
|
||
backendUrl: formData.sms.backendUrl,
|
||
apiKey: formData.sms.apiKey,
|
||
phone
|
||
});
|
||
if (res.code === 200) {
|
||
ElMessage.success(res.msg || "短信测试任务入队成功");
|
||
} else {
|
||
ElMessage.error(res.msg || "短信测试失败,请稍后重试");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "短信测试失败,请稍后重试");
|
||
} finally {
|
||
testLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
// 微信配置使用独立接口保存(页面内有独立的「保存配置」按钮)
|
||
if (activeSubTab.value === 'wechat') {
|
||
await saveWechatConfig();
|
||
return;
|
||
}
|
||
submitting.value = true;
|
||
try {
|
||
if (activeSubTab.value === 'email') {
|
||
if (!formData.email.fromAddress || !formData.email.host || !formData.email.port) {
|
||
ElMessage.error("发件人邮箱、SMTP 服务器和 SMTP 端口不能为空");
|
||
submitting.value = false;
|
||
return;
|
||
}
|
||
const res = await editEmailInfo({
|
||
fromAddress: formData.email.fromAddress,
|
||
fromName: formData.email.fromName,
|
||
host: formData.email.host,
|
||
port: formData.email.port,
|
||
password: formData.email.password,
|
||
encryption: formData.email.encryption,
|
||
timeout: formData.email.timeout,
|
||
status: formData.email.enabled ? 1 : 0
|
||
});
|
||
if (res.code === 200) {
|
||
ElMessage.success("邮箱配置保存成功");
|
||
} else {
|
||
throw new Error(res.msg || "保存邮箱配置失败");
|
||
}
|
||
} else if (activeSubTab.value === 'sms' && formData.sms.provider === 'custom') {
|
||
if (!formData.sms.backendUrl || !formData.sms.apiKey) {
|
||
ElMessage.error("自定义短信网关地址和API KEY不能为空");
|
||
submitting.value = false;
|
||
return;
|
||
}
|
||
const res = await editSmsInfo({
|
||
backendUrl: formData.sms.backendUrl,
|
||
apiKey: formData.sms.apiKey
|
||
});
|
||
if (res.code === 200) {
|
||
ElMessage.success("自定义短信网关配置保存成功");
|
||
} else {
|
||
throw new Error(res.msg || "保存自定义短信配置失败");
|
||
}
|
||
} else if (activeSubTab.value === 'sitemsg') {
|
||
const res = await saveSiteReminderConfig({
|
||
retention_days: formData.sitemsg.retention_days,
|
||
auto_read: formData.sitemsg.auto_read ? 1 : 0
|
||
});
|
||
if (res.code === 200) {
|
||
ElMessage.success("站内信配置保存成功");
|
||
} else {
|
||
throw new Error(res.msg || "保存站内信配置失败");
|
||
}
|
||
} else if (activeSubTab.value === 'bark') {
|
||
const res = await saveBarkConfig({
|
||
enabled: formData.bark.enabled,
|
||
server_url: formData.bark.server_url,
|
||
device_key: formData.bark.device_key
|
||
});
|
||
if (res.code === 200) {
|
||
ElMessage.success("Bark 配置保存成功");
|
||
} else {
|
||
throw new Error(res.msg || "保存 Bark 配置失败");
|
||
}
|
||
}
|
||
|
||
saveDraft();
|
||
ElMessage.success("保存配置成功");
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "保存配置失败");
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
};
|
||
|
||
const loadBarkConfig = async () => {
|
||
try {
|
||
const res = await getBarkConfig();
|
||
if (res.code === 200 && res.data) {
|
||
formData.bark.enabled = res.data.enabled;
|
||
formData.bark.server_url = res.data.server_url || "https://api.day.app";
|
||
formData.bark.device_key = res.data.device_key || "";
|
||
}
|
||
} catch (error) {
|
||
console.error("加载 Bark 配置失败:", error);
|
||
}
|
||
};
|
||
|
||
const handleSendBarkTest = async () => {
|
||
const key = (formData.bark.device_key || "").trim();
|
||
if (!key) {
|
||
ElMessage.warning("请先输入 Bark 设备 Key");
|
||
return;
|
||
}
|
||
try {
|
||
barkTestLoading.value = true;
|
||
const res = await sendTestBark({
|
||
server_url: formData.bark.server_url,
|
||
device_key: key
|
||
});
|
||
if (res.code === 200) {
|
||
ElMessage.success(res.msg || "测试推送发送成功");
|
||
} else {
|
||
ElMessage.error(res.msg || "测试推送失败,请稍后重试");
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error?.message || "测试推送失败,请稍后重试");
|
||
} finally {
|
||
barkTestLoading.value = false;
|
||
}
|
||
};
|
||
|
||
/* ==================== 微信公众号(服务号) ==================== */
|
||
|
||
const wechatCfg = reactive({
|
||
app_id: "",
|
||
app_secret: "",
|
||
token: "",
|
||
aes_key: "",
|
||
encrypt_mode: "plain",
|
||
template_id: "",
|
||
verified: 0,
|
||
enabled: 0,
|
||
callback_url: "",
|
||
follow_qrcode: "",
|
||
configured: false,
|
||
});
|
||
const wechatSaving = ref(false);
|
||
const wechatTesting = ref(false);
|
||
|
||
// 生成随机串(字母+数字):Token 取 24 位、EncodingAESKey 取 43 位。
|
||
// 生成后需原样填入微信后台「服务器配置」,保持两端一致(微信侧提交时会请求回调地址校验)。
|
||
const randomWechatToken = (len = 43) => {
|
||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||
const buf = new Uint8Array(len);
|
||
window.crypto.getRandomValues(buf);
|
||
let out = "";
|
||
for (let i = 0; i < len; i++) out += chars[buf[i] % chars.length];
|
||
return out;
|
||
};
|
||
|
||
const wechatBinding = ref({ bound: false });
|
||
const wechatPushTesting = ref(false);
|
||
|
||
const bindScene = ref("");
|
||
const bindQrUrl = ref("");
|
||
const bindCode = ref("");
|
||
const bindStarting = ref(false);
|
||
const bindConfirming = ref(false);
|
||
const bindStep = ref(0);
|
||
const bindTtlText = ref("5 分钟内有效");
|
||
let bindPollTimer = null;
|
||
|
||
const followers = ref([]);
|
||
const followersLoading = ref(false);
|
||
const followerQuery = reactive({ keyword: "", bind_type: "", page: 1, pageSize: 10, total: 0 });
|
||
|
||
const pushForm = reactive({ bind_type: "", title: "", content: "", remark: "", url: "" });
|
||
const pushSending = ref(false);
|
||
|
||
const loadWechatConfig = async () => {
|
||
try {
|
||
const res = await getWechatMpConfig();
|
||
if (res.code === 200 && res.data) {
|
||
Object.assign(wechatCfg, res.data);
|
||
}
|
||
} catch (e) {
|
||
console.error("加载微信配置失败:", e);
|
||
}
|
||
};
|
||
|
||
const saveWechatConfig = async () => {
|
||
if (!wechatCfg.app_id) {
|
||
ElMessage.warning("请先填写 AppID");
|
||
return;
|
||
}
|
||
wechatSaving.value = true;
|
||
try {
|
||
const res = await saveWechatMpConfig({
|
||
app_id: wechatCfg.app_id,
|
||
app_secret: wechatCfg.app_secret,
|
||
token: wechatCfg.token,
|
||
aes_key: wechatCfg.aes_key,
|
||
encrypt_mode: wechatCfg.encrypt_mode,
|
||
template_id: wechatCfg.template_id,
|
||
verified: Number(wechatCfg.verified) === 1,
|
||
enabled: Number(wechatCfg.enabled) === 1,
|
||
});
|
||
if (res.code === 200) {
|
||
ElMessage.success("微信配置保存成功");
|
||
await loadWechatConfig();
|
||
} else {
|
||
ElMessage.error(res.msg || "保存失败");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "保存失败");
|
||
} finally {
|
||
wechatSaving.value = false;
|
||
}
|
||
};
|
||
|
||
// 关注二维码上传/删除(存 data URL,供租户端「绑定微信」页展示扫码)
|
||
const followQrcodeUploading = ref(false);
|
||
const handleFollowQrcodeUpload = async ({ file }) => {
|
||
followQrcodeUploading.value = true;
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
const res = await uploadWechatFollowQrcode(fd);
|
||
if (res.code === 200) {
|
||
wechatCfg.follow_qrcode = res.data?.follow_qrcode || "";
|
||
ElMessage.success("二维码上传成功");
|
||
} else {
|
||
ElMessage.error(res.msg || "上传失败");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "上传失败");
|
||
} finally {
|
||
followQrcodeUploading.value = false;
|
||
}
|
||
};
|
||
const removeFollowQrcode = async () => {
|
||
try {
|
||
const res = await deleteWechatFollowQrcode();
|
||
if (res.code === 200) {
|
||
wechatCfg.follow_qrcode = "";
|
||
ElMessage.success("已删除");
|
||
} else {
|
||
ElMessage.error(res.msg || "删除失败");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "删除失败");
|
||
}
|
||
};
|
||
|
||
const wechatMenuPublishing = ref(false);
|
||
const publishWechatMenu = async () => {
|
||
wechatMenuPublishing.value = true;
|
||
try {
|
||
const res = await publishWechatMpMenu();
|
||
if (res.code === 200) {
|
||
ElMessage.success(res.msg || "菜单发布成功");
|
||
} else {
|
||
ElMessage.error(res.msg || "发布失败");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "发布失败");
|
||
} finally {
|
||
wechatMenuPublishing.value = false;
|
||
}
|
||
};
|
||
|
||
const testWechatConfig = async () => {
|
||
wechatTesting.value = true;
|
||
try {
|
||
const res = await testWechatMpConnection();
|
||
if (res.code === 200) {
|
||
ElMessage.success(res.msg || "连接成功");
|
||
} else {
|
||
ElMessage.error(res.msg || "连接失败");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "连接失败");
|
||
} finally {
|
||
wechatTesting.value = false;
|
||
}
|
||
};
|
||
|
||
const copyWechatText = async (text) => {
|
||
if (!text) return;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
ElMessage.success("已复制");
|
||
} catch {
|
||
ElMessage.warning("复制失败,请手动选择复制");
|
||
}
|
||
};
|
||
|
||
const loadWechatBinding = async () => {
|
||
try {
|
||
const res = await getMyWechatMpBinding();
|
||
if (res.code === 200) {
|
||
wechatBinding.value = res.data || { bound: false };
|
||
}
|
||
} catch {
|
||
wechatBinding.value = { bound: false };
|
||
}
|
||
};
|
||
|
||
const stopBindPoll = () => {
|
||
if (bindPollTimer) {
|
||
clearInterval(bindPollTimer);
|
||
bindPollTimer = null;
|
||
}
|
||
};
|
||
|
||
const startBind = async () => {
|
||
bindStarting.value = true;
|
||
try {
|
||
const res = await startWechatMpBind();
|
||
if (res.code !== 200) {
|
||
ElMessage.error(res.msg || "获取二维码失败");
|
||
return;
|
||
}
|
||
bindScene.value = res.data?.scene || "";
|
||
bindQrUrl.value = res.data?.qrcode_url || "";
|
||
bindStep.value = 0;
|
||
const ttl = Number(res.data?.expires_in || 300);
|
||
bindTtlText.value = ttl >= 60 ? `${Math.floor(ttl / 60)} 分钟内有效` : `${ttl} 秒内有效`;
|
||
stopBindPoll();
|
||
bindPollTimer = setInterval(async () => {
|
||
if (!bindScene.value) return;
|
||
try {
|
||
const st = await getWechatMpBindStatus(bindScene.value);
|
||
const status = Number(st?.data?.status ?? 0);
|
||
if (status === 1) {
|
||
bindStep.value = 2; // 已扫码,公众号已回复验证码,等待输入
|
||
}
|
||
if (status === 3) {
|
||
stopBindPoll();
|
||
bindQrUrl.value = "";
|
||
ElMessage.warning("二维码已过期,请重新获取");
|
||
}
|
||
} catch {
|
||
// 轮询失败忽略
|
||
}
|
||
}, 3000);
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "获取二维码失败");
|
||
} finally {
|
||
bindStarting.value = false;
|
||
}
|
||
};
|
||
|
||
const confirmBind = async () => {
|
||
// scene 可为空:在公众号内发送「验证码」获取时无需二维码,后端按验证码认领会话
|
||
if (!bindCode.value.trim()) {
|
||
ElMessage.warning("请输入公众号回复的验证码");
|
||
return;
|
||
}
|
||
bindConfirming.value = true;
|
||
try {
|
||
const res = await confirmWechatMpBind({ scene: bindScene.value, code: bindCode.value.trim() });
|
||
if (res.code === 200) {
|
||
ElMessage.success("绑定成功");
|
||
stopBindPoll();
|
||
bindQrUrl.value = "";
|
||
bindCode.value = "";
|
||
bindScene.value = "";
|
||
bindStep.value = 0;
|
||
await loadWechatBinding();
|
||
await loadFollowers();
|
||
} else {
|
||
ElMessage.error(res.msg || "绑定失败");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "绑定失败");
|
||
} finally {
|
||
bindConfirming.value = false;
|
||
}
|
||
};
|
||
|
||
const unbindMyWechat = async () => {
|
||
try {
|
||
await ElMessageBox.confirm("确定解除当前账号的微信绑定吗?解除后将无法接收微信推送。", "提示", {
|
||
type: "warning",
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await unbindWechatMp();
|
||
if (res.code === 200) {
|
||
ElMessage.success("已解除绑定");
|
||
await loadWechatBinding();
|
||
await loadFollowers();
|
||
} else {
|
||
ElMessage.error(res.msg || "解绑失败");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "解绑失败");
|
||
}
|
||
};
|
||
|
||
const sendWechatTestPush = async () => {
|
||
wechatPushTesting.value = true;
|
||
try {
|
||
const res = await pushWechatMpTest({});
|
||
if (res.code === 200) {
|
||
ElMessage.success(res.msg || "测试消息已发送");
|
||
} else {
|
||
ElMessage.error(res.msg || "发送失败");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "发送失败");
|
||
} finally {
|
||
wechatPushTesting.value = false;
|
||
}
|
||
};
|
||
|
||
const loadFollowers = async () => {
|
||
followersLoading.value = true;
|
||
try {
|
||
const res = await getWechatMpFollowers({
|
||
page: followerQuery.page,
|
||
pageSize: followerQuery.pageSize,
|
||
keyword: followerQuery.keyword,
|
||
bind_type: followerQuery.bind_type,
|
||
});
|
||
if (res.code === 200) {
|
||
followers.value = res.data?.list || [];
|
||
followerQuery.total = Number(res.data?.total || 0);
|
||
} else {
|
||
followers.value = [];
|
||
}
|
||
} catch {
|
||
followers.value = [];
|
||
} finally {
|
||
followersLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleFollowerPage = (page) => {
|
||
followerQuery.page = page;
|
||
loadFollowers();
|
||
};
|
||
|
||
const editFollowerRemark = async (row) => {
|
||
try {
|
||
const { value } = await ElMessageBox.prompt("设置备注(仅平台可见)", "粉丝备注", {
|
||
inputValue: row.remark || "",
|
||
inputPlaceholder: "如:张三(XX公司)",
|
||
inputValidator: (v) => (v || "").length <= 100 || "备注过长",
|
||
});
|
||
const res = await setWechatMpFollowerRemark({ id: row.id, remark: value || "" });
|
||
if (res.code === 200) {
|
||
ElMessage.success("已保存");
|
||
await loadFollowers();
|
||
} else {
|
||
ElMessage.error(res.msg || "保存失败");
|
||
}
|
||
} catch {
|
||
// 取消
|
||
}
|
||
};
|
||
|
||
const unbindFollower = async (row) => {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定解除「${row.bind_name || row.openid}」的绑定吗?(粉丝记录保留)`,
|
||
"提示",
|
||
{ type: "warning" }
|
||
);
|
||
} catch {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await unbindWechatMpFollower({ id: row.id });
|
||
if (res.code === 200) {
|
||
ElMessage.success("已解除绑定");
|
||
await loadFollowers();
|
||
} else {
|
||
ElMessage.error(res.msg || "解绑失败");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "解绑失败");
|
||
}
|
||
};
|
||
|
||
const sendPush = async () => {
|
||
if (!pushForm.title && !pushForm.content) {
|
||
ElMessage.warning("请填写推送标题或内容");
|
||
return;
|
||
}
|
||
pushSending.value = true;
|
||
try {
|
||
const res = await pushWechatMpMessage({ ...pushForm });
|
||
if (res.code === 200) {
|
||
ElMessage.success(res.msg || "推送完成");
|
||
} else {
|
||
ElMessage.error(res.msg || "推送失败");
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || "推送失败");
|
||
} finally {
|
||
pushSending.value = false;
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
loadDraft();
|
||
loadSmsConfig();
|
||
loadEmailConfig();
|
||
loadSiteReminderConfig();
|
||
loadBarkConfig();
|
||
loadWechatConfig();
|
||
loadWechatBinding();
|
||
});
|
||
|
||
// 切换到「微信配置」页时刷新配置 / 绑定 / 粉丝
|
||
watch(activeSubTab, (tab) => {
|
||
if (tab === "wechat") {
|
||
loadWechatConfig();
|
||
loadWechatBinding();
|
||
loadFollowers();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.notification-settings {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.settings-content {
|
||
background-color: var(--el-bg-color-overlay);
|
||
border: 1px solid var(--el-border-color-light);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Make tabs look extra sleek */
|
||
:deep(.el-tabs--left .el-tabs__header.is-left) {
|
||
margin-right: 0;
|
||
border-right: 1px solid var(--el-border-color-light);
|
||
background-color: var(--el-fill-color-blank);
|
||
width: 180px;
|
||
}
|
||
|
||
:deep(.el-tabs--left .el-tabs__nav-wrap.is-left) {
|
||
padding-top: 10px;
|
||
padding-bottom: 10px;
|
||
}
|
||
|
||
:deep(.el-tabs__item) {
|
||
height: 44px;
|
||
line-height: 44px;
|
||
font-weight: 500;
|
||
padding: 0 20px !important;
|
||
color: var(--el-text-color-regular);
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
:deep(.el-tabs__item:hover) {
|
||
color: var(--el-color-primary);
|
||
background-color: var(--el-fill-color-light);
|
||
}
|
||
|
||
:deep(.el-tabs__item.is-active) {
|
||
color: var(--el-color-primary);
|
||
background-color: var(--el-color-primary-light-9);
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.el-tabs__active-bar) {
|
||
width: 3px !important;
|
||
}
|
||
|
||
.tab-label-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.tab-label-item .el-icon {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.tab-pane-content {
|
||
padding: 24px;
|
||
height: 100%;
|
||
box-sizing: border-box;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.settings-card {
|
||
border: none;
|
||
}
|
||
|
||
:deep(.el-card__header) {
|
||
padding: 0 0 16px 0;
|
||
border-bottom: 1px dashed var(--el-border-color-light);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.card-header {
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
color: var(--el-text-color-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.form-tip {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
margin-top: 4px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.form-tip-inline {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
margin-left: 12px;
|
||
}
|
||
|
||
.footer-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
padding: 10px 0;
|
||
}
|
||
|
||
/* ===== 微信公众号(服务号) ===== */
|
||
.bind-card {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.bind-actions {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.bind-steps {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.bind-area {
|
||
display: flex;
|
||
gap: 24px;
|
||
align-items: flex-start;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.bind-qr {
|
||
width: 180px;
|
||
height: 180px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px dashed var(--el-border-color);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.qr-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.bind-form {
|
||
flex: 1;
|
||
min-width: 240px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.bind-input {
|
||
width: 220px;
|
||
}
|
||
|
||
.follower-tools {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.follower-select {
|
||
width: 140px;
|
||
}
|
||
|
||
.follower-pager {
|
||
margin-top: 12px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.push-alert {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.qr-upload {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.qr-preview {
|
||
width: 120px;
|
||
height: 120px;
|
||
object-fit: contain;
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 6px;
|
||
padding: 4px;
|
||
background: #fff;
|
||
}
|
||
|
||
.push-url {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.ml8 {
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.muted {
|
||
color: var(--el-text-color-secondary);
|
||
font-size: 12px;
|
||
}
|
||
</style>
|