Files
yunzerwebsiteallinone/platform/src/views/system/platformsettings/components/notificationSettings.vue
T
2026-09-30 00:57:36 +08:00

1567 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>