✨ graph api发送邮件
This commit is contained in:
@@ -136,12 +136,35 @@ export const systemPriceApi = {
|
||||
|
||||
// ---- SMTP 配置 ----
|
||||
export interface SmtpConfig {
|
||||
authMode: 'smtp' | 'outlook_oauth2'
|
||||
smtpHost: string
|
||||
smtpPort: number
|
||||
smtpUsername: string
|
||||
smtpPassword: string
|
||||
fromEmail: string
|
||||
fromName: string
|
||||
oauthTenant: string
|
||||
oauthClientId: string
|
||||
oauthClientSecret: string
|
||||
oauthRedirectUrl: string
|
||||
oauthRefreshToken: string
|
||||
oauthScope: string
|
||||
}
|
||||
|
||||
export interface OutlookOAuthAuthorizeURLReq {
|
||||
oauthTenant: string
|
||||
oauthClientId: string
|
||||
oauthClientSecret: string
|
||||
oauthScope: string
|
||||
fromEmail: string
|
||||
frontendOrigin: string
|
||||
callbackBaseUrl: string
|
||||
}
|
||||
|
||||
export interface OutlookOAuthAuthorizeURLResp {
|
||||
authUrl: string
|
||||
callbackUrl: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export const systemSmtpApi = {
|
||||
@@ -151,4 +174,6 @@ export const systemSmtpApi = {
|
||||
request.put<any, ApiResponse>('/v1/system/config/smtp', data),
|
||||
test: (toEmail: string) =>
|
||||
request.post<any, ApiResponse>('/v1/system/config/smtp/test', { toEmail }),
|
||||
getOutlookAuthorizeUrl: (data: OutlookOAuthAuthorizeURLReq) =>
|
||||
request.post<any, ApiResponse<OutlookOAuthAuthorizeURLResp>>('/v1/system/config/smtp/oauth/outlook/authorize-url', data),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { message as toast } from '@/composables/useMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -9,9 +9,18 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { MdEditor } from 'md-editor-v3'
|
||||
import 'md-editor-v3/lib/style.css'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { systemConfigApi, systemPriceApi, systemSmtpApi, type PriceConfig, type SmtpConfig } from '@/api/system'
|
||||
|
||||
// ---- 公告 ----
|
||||
const defaultOAuthScope = 'offline_access https://graph.microsoft.com/Mail.Send'
|
||||
const defaultOAuthRedirectUrl = 'http://localhost:3200/api/v1/mail/oauth/outlook/callback'
|
||||
|
||||
const announcementLoading = ref(false)
|
||||
const announcementSaving = ref(false)
|
||||
const announcementContent = ref('')
|
||||
@@ -24,8 +33,6 @@ async function fetchAnnouncement() {
|
||||
const val = res.data?.value ?? ''
|
||||
announcementContent.value = val
|
||||
originalAnnouncement.value = val
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
announcementLoading.value = false
|
||||
}
|
||||
@@ -42,10 +49,8 @@ async function saveAnnouncement() {
|
||||
value: announcementContent.value,
|
||||
description: '系统公告',
|
||||
})
|
||||
toast.success('公告保存成功')
|
||||
originalAnnouncement.value = announcementContent.value
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
toast.success('公告保存成功')
|
||||
} finally {
|
||||
announcementSaving.value = false
|
||||
}
|
||||
@@ -56,7 +61,6 @@ function resetAnnouncement() {
|
||||
toast.info('已重置')
|
||||
}
|
||||
|
||||
// ---- 注册开关 ----
|
||||
const registerLoading = ref(false)
|
||||
const registerSaving = ref(false)
|
||||
const allowRegister = ref(true)
|
||||
@@ -69,11 +73,8 @@ async function fetchRegisterConfig() {
|
||||
systemConfigApi.get('system.allowRegister'),
|
||||
systemConfigApi.get('system.requireInviteCode').catch(() => null),
|
||||
])
|
||||
const regVal = regRes.data?.value
|
||||
allowRegister.value = regVal === 'true' || regVal === '1'
|
||||
if (invRes?.data?.value) {
|
||||
requireInviteCode.value = invRes.data.value === 'true' || invRes.data.value === '1'
|
||||
}
|
||||
allowRegister.value = ['true', '1'].includes(regRes.data?.value ?? '')
|
||||
requireInviteCode.value = ['true', '1'].includes(invRes?.data?.value ?? '')
|
||||
} catch {
|
||||
allowRegister.value = true
|
||||
requireInviteCode.value = false
|
||||
@@ -96,14 +97,11 @@ async function saveRegisterConfig() {
|
||||
}),
|
||||
])
|
||||
toast.success('注册配置保存成功')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
registerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 费率配置 ----
|
||||
const priceLoading = ref(false)
|
||||
const priceSaving = ref(false)
|
||||
const priceConfig = ref<PriceConfig>({
|
||||
@@ -121,8 +119,6 @@ async function fetchPriceConfig() {
|
||||
if (res.data) {
|
||||
priceConfig.value = { ...priceConfig.value, ...res.data }
|
||||
}
|
||||
} catch {
|
||||
// 使用默认值
|
||||
} finally {
|
||||
priceLoading.value = false
|
||||
}
|
||||
@@ -133,59 +129,170 @@ async function savePriceConfig() {
|
||||
try {
|
||||
await systemPriceApi.update(priceConfig.value)
|
||||
toast.success('费率配置保存成功')
|
||||
} catch {
|
||||
// 全局拦截器已处理错误提示
|
||||
} finally {
|
||||
priceSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SMTP 配置 ----
|
||||
const smtpLoading = ref(false)
|
||||
const smtpSaving = ref(false)
|
||||
const smtpTesting = ref(false)
|
||||
const oauthAuthorizing = ref(false)
|
||||
const testEmail = ref('')
|
||||
const smtpConfig = ref<SmtpConfig>({
|
||||
authMode: 'smtp',
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
smtpUsername: '',
|
||||
smtpPassword: '',
|
||||
fromEmail: '',
|
||||
fromName: '',
|
||||
oauthTenant: 'consumers',
|
||||
oauthClientId: '',
|
||||
oauthClientSecret: '',
|
||||
oauthRedirectUrl: '',
|
||||
oauthRefreshToken: '',
|
||||
oauthScope: defaultOAuthScope,
|
||||
})
|
||||
|
||||
const isOAuthMode = computed(() => smtpConfig.value.authMode === 'outlook_oauth2')
|
||||
|
||||
function resolveApiOrigin() {
|
||||
return new URL(defaultOAuthRedirectUrl).origin
|
||||
}
|
||||
|
||||
function applyModeDefaults(mode: SmtpConfig['authMode']) {
|
||||
if (mode === 'outlook_oauth2') {
|
||||
smtpConfig.value.oauthTenant = 'consumers'
|
||||
smtpConfig.value.oauthScope = defaultOAuthScope
|
||||
smtpConfig.value.oauthRedirectUrl = defaultOAuthRedirectUrl
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSmtpConfig() {
|
||||
smtpLoading.value = true
|
||||
try {
|
||||
const res = await systemSmtpApi.get()
|
||||
if (res.data) {
|
||||
smtpConfig.value = { ...smtpConfig.value, ...res.data }
|
||||
// port 可能是字符串
|
||||
smtpConfig.value.smtpPort = Number(smtpConfig.value.smtpPort) || 587
|
||||
applyModeDefaults(smtpConfig.value.authMode)
|
||||
}
|
||||
} catch {
|
||||
// 使用默认值
|
||||
} finally {
|
||||
smtpLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildSmtpPayload(): SmtpConfig {
|
||||
const payload: SmtpConfig = { ...smtpConfig.value }
|
||||
if (payload.authMode === 'outlook_oauth2') {
|
||||
payload.oauthTenant = 'consumers'
|
||||
payload.oauthScope = defaultOAuthScope
|
||||
payload.oauthRedirectUrl = defaultOAuthRedirectUrl
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
async function saveSmtpConfig() {
|
||||
if (!smtpConfig.value.smtpHost || !smtpConfig.value.smtpUsername) {
|
||||
toast.error('请填写完整的SMTP配置')
|
||||
if (!smtpConfig.value.fromEmail || !smtpConfig.value.fromName) {
|
||||
toast.error('请先填写发件人信息')
|
||||
return
|
||||
}
|
||||
|
||||
if (isOAuthMode.value) {
|
||||
if (!smtpConfig.value.oauthClientId || !smtpConfig.value.oauthClientSecret || !smtpConfig.value.oauthRefreshToken) {
|
||||
toast.error('Outlook OAuth2 模式下请先完成授权或手动填写 Refresh Token')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!smtpConfig.value.smtpHost || !smtpConfig.value.smtpUsername || !smtpConfig.value.smtpPassword) {
|
||||
toast.error('SMTP 模式下请填写服务器、账号和密码')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
smtpSaving.value = true
|
||||
try {
|
||||
await systemSmtpApi.update(smtpConfig.value)
|
||||
toast.success('SMTP配置保存成功')
|
||||
} catch {
|
||||
// 全局拦截器已处理
|
||||
await systemSmtpApi.update(buildSmtpPayload())
|
||||
toast.success('邮件配置保存成功')
|
||||
} finally {
|
||||
smtpSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startOutlookAuthorize() {
|
||||
if (!smtpConfig.value.fromEmail) {
|
||||
toast.error('请先填写发件人邮箱')
|
||||
return
|
||||
}
|
||||
if (!smtpConfig.value.oauthClientId || !smtpConfig.value.oauthClientSecret) {
|
||||
toast.error('请先填写 Client ID 和 Client Secret')
|
||||
return
|
||||
}
|
||||
|
||||
oauthAuthorizing.value = true
|
||||
try {
|
||||
const res = await systemSmtpApi.getOutlookAuthorizeUrl({
|
||||
oauthTenant: 'consumers',
|
||||
oauthClientId: smtpConfig.value.oauthClientId,
|
||||
oauthClientSecret: smtpConfig.value.oauthClientSecret,
|
||||
oauthScope: defaultOAuthScope,
|
||||
fromEmail: smtpConfig.value.fromEmail,
|
||||
frontendOrigin: window.location.origin,
|
||||
callbackBaseUrl: defaultOAuthRedirectUrl,
|
||||
})
|
||||
|
||||
const authUrl = res.data?.authUrl
|
||||
const callbackUrl = res.data?.callbackUrl
|
||||
if (!authUrl || !callbackUrl) {
|
||||
toast.error('未能生成授权地址')
|
||||
return
|
||||
}
|
||||
|
||||
smtpConfig.value.oauthRedirectUrl = callbackUrl
|
||||
const popup = window.open(authUrl, 'outlook-oauth2', 'width=720,height=820')
|
||||
if (!popup) {
|
||||
toast.error('浏览器拦截了弹窗,请允许后重试')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await new Promise<{ success: boolean; message?: string; refreshToken?: string }>((resolve, reject) => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (popup.closed) {
|
||||
window.clearInterval(timer)
|
||||
window.removeEventListener('message', onMessage)
|
||||
reject(new Error('授权窗口已关闭'))
|
||||
}
|
||||
}, 500)
|
||||
|
||||
function onMessage(event: MessageEvent) {
|
||||
const data = event.data
|
||||
if (event.origin !== resolveApiOrigin() || !data || data.source !== 'likedream-outlook-oauth') {
|
||||
return
|
||||
}
|
||||
window.clearInterval(timer)
|
||||
window.removeEventListener('message', onMessage)
|
||||
resolve(data)
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage)
|
||||
})
|
||||
|
||||
if (!result.success || !result.refreshToken) {
|
||||
toast.error(result.message || '授权失败')
|
||||
return
|
||||
}
|
||||
|
||||
smtpConfig.value.oauthRefreshToken = result.refreshToken
|
||||
smtpConfig.value.oauthRedirectUrl = defaultOAuthRedirectUrl
|
||||
toast.success('Outlook 授权成功,Refresh Token 已自动回填')
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '获取 Outlook 授权失败')
|
||||
} finally {
|
||||
oauthAuthorizing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testSmtp() {
|
||||
if (!testEmail.value.trim()) {
|
||||
toast.error('请输入测试收件邮箱')
|
||||
@@ -195,8 +302,6 @@ async function testSmtp() {
|
||||
try {
|
||||
await systemSmtpApi.test(testEmail.value.trim())
|
||||
toast.success('测试邮件发送成功')
|
||||
} catch {
|
||||
// 全局拦截器已处理
|
||||
} finally {
|
||||
smtpTesting.value = false
|
||||
}
|
||||
@@ -214,21 +319,15 @@ onMounted(() => {
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-2xl font-bold tracking-tight">系统配置</h2>
|
||||
|
||||
<!-- 公告编辑 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">系统公告</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div v-if="announcementLoading" class="text-sm text-muted-foreground py-4 text-center">加载中...</div>
|
||||
<div v-if="announcementLoading" class="py-4 text-center text-sm text-muted-foreground">加载中...</div>
|
||||
<template v-else>
|
||||
<MdEditor
|
||||
v-model="announcementContent"
|
||||
language="zh-CN"
|
||||
:preview="true"
|
||||
style="height: 500px"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 mt-3">
|
||||
<MdEditor v-model="announcementContent" language="zh-CN" :preview="true" style="height: 500px" />
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" @click="resetAnnouncement">重置</Button>
|
||||
<Button size="sm" :disabled="announcementSaving" @click="saveAnnouncement">
|
||||
{{ announcementSaving ? '保存中...' : '保存公告' }}
|
||||
@@ -238,14 +337,13 @@ onMounted(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 注册开关 -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">注册开关配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div v-if="registerLoading" class="text-sm text-muted-foreground py-4 text-center">加载中...</div>
|
||||
<div v-if="registerLoading" class="py-4 text-center text-sm text-muted-foreground">加载中...</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>允许用户注册</Label>
|
||||
@@ -266,13 +364,12 @@ onMounted(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 费率配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">费率配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div v-if="priceLoading" class="text-sm text-muted-foreground py-4 text-center">加载中...</div>
|
||||
<div v-if="priceLoading" class="py-4 text-center text-sm text-muted-foreground">加载中...</div>
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -311,51 +408,107 @@ onMounted(() => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- SMTP 邮件配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">SMTP 邮件配置</CardTitle>
|
||||
<CardTitle class="text-base">邮件配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div v-if="smtpLoading" class="text-sm text-muted-foreground py-4 text-center">加载中...</div>
|
||||
<div v-if="smtpLoading" class="py-4 text-center text-sm text-muted-foreground">加载中...</div>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl">
|
||||
<div class="grid max-w-3xl grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>SMTP 服务器</Label>
|
||||
<Input v-model="smtpConfig.smtpHost" placeholder="如:smtp.qq.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>SMTP 端口</Label>
|
||||
<Input v-model.number="smtpConfig.smtpPort" type="number" placeholder="如:587" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>SMTP 用户名</Label>
|
||||
<Input v-model="smtpConfig.smtpUsername" placeholder="SMTP登录用户名" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>SMTP 密码</Label>
|
||||
<Input v-model="smtpConfig.smtpPassword" type="password" placeholder="SMTP登录密码/授权码" />
|
||||
<Label>发信模式</Label>
|
||||
<Select
|
||||
:model-value="smtpConfig.authMode"
|
||||
@update:model-value="(value) => { smtpConfig.authMode = value as SmtpConfig['authMode']; applyModeDefaults(smtpConfig.authMode) }"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择邮件发信模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="smtp">SMTP 密码 / 授权码</SelectItem>
|
||||
<SelectItem value="outlook_oauth2">Outlook OAuth2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>发件人邮箱</Label>
|
||||
<Input v-model="smtpConfig.fromEmail" placeholder="发件人邮箱地址" />
|
||||
<Input v-model="smtpConfig.fromEmail" placeholder="例如 you@outlook.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>发件人名称</Label>
|
||||
<Input v-model="smtpConfig.fromName" placeholder="发件人显示名称" />
|
||||
<Input v-model="smtpConfig.fromName" placeholder="邮件中显示的发件人名称" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isOAuthMode" class="grid max-w-3xl grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>SMTP 服务器</Label>
|
||||
<Input v-model="smtpConfig.smtpHost" placeholder="例如 smtp.qq.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>SMTP 端口</Label>
|
||||
<Input v-model.number="smtpConfig.smtpPort" type="number" placeholder="例如 587" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>SMTP 登录账号</Label>
|
||||
<Input v-model="smtpConfig.smtpUsername" placeholder="SMTP 登录账号" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>SMTP 密码 / 授权码</Label>
|
||||
<Input v-model="smtpConfig.smtpPassword" type="password" placeholder="SMTP 登录密码或授权码" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<p class="font-medium">Outlook OAuth2 + Microsoft Graph 模式</p>
|
||||
<p class="mt-1">
|
||||
OAuth2 模式下系统不会再走 SMTP,而是直接调用 <code>Microsoft Graph /me/sendMail</code>,
|
||||
因此不会再显示 SMTP 服务器、端口和账号输入项,邮件也会以当前完成授权的微软账号身份发送。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid max-w-3xl grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>Client ID</Label>
|
||||
<Input v-model="smtpConfig.oauthClientId" placeholder="Microsoft Entra 应用的 Client ID" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Client Secret</Label>
|
||||
<Input v-model="smtpConfig.oauthClientSecret" type="password" placeholder="应用机密" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label>回调地址</Label>
|
||||
<Input v-model="smtpConfig.oauthRedirectUrl" readonly placeholder="点击下方授权按钮后自动生成" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label>Refresh Token</Label>
|
||||
<Input v-model="smtpConfig.oauthRefreshToken" type="password" placeholder="点击下方按钮自动回填,或手动粘贴" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-3xl items-center gap-3">
|
||||
<Button variant="outline" :disabled="oauthAuthorizing" @click="startOutlookAuthorize">
|
||||
{{ oauthAuthorizing ? '授权中...' : '一键授权并回填 Refresh Token' }}
|
||||
</Button>
|
||||
<span class="text-sm text-muted-foreground">会弹出微软登录窗口,固定使用 <code>consumers</code> 与 <code>Mail.Send</code> 授权并在完成后自动回填。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div class="flex items-end gap-3 max-w-2xl">
|
||||
|
||||
<div class="flex max-w-3xl items-end gap-3">
|
||||
<div class="flex-1 space-y-2">
|
||||
<Label>测试收件邮箱</Label>
|
||||
<Input v-model="testEmail" placeholder="输入邮箱地址发送测试邮件" />
|
||||
<Input v-model="testEmail" placeholder="输入一个邮箱地址发送测试邮件" />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" :disabled="smtpTesting" @click="testSmtp">
|
||||
{{ smtpTesting ? '发送中...' : '发送测试' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex justify-end max-w-2xl">
|
||||
|
||||
<div class="flex max-w-3xl justify-end">
|
||||
<Button size="sm" :disabled="smtpSaving" @click="saveSmtpConfig">
|
||||
{{ smtpSaving ? '保存中...' : '保存配置' }}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user