增加短信渠道

This commit is contained in:
Yvan
2026-09-29 18:15:35 +08:00
parent a16b8f8e89
commit 89cc7761b5
38 changed files with 1469 additions and 39 deletions
+6
View File
@@ -1,5 +1,11 @@
import service from '@/utils/request'
export const sendSMS = (provider, data) => service({
url: `/integration/sms/${encodeURIComponent(provider)}/send`,
method: 'post',
data
})
export const getIntegrationConfigs = (kind) => service({
url: `/integration/configs/${encodeURIComponent(kind)}`,
method: 'get'
+1
View File
@@ -59,6 +59,7 @@
"/src/view/systemTools/logViewer/index.vue": "LogViewer",
"/src/view/systemTools/payment/config.vue": "PaymentConfig",
"/src/view/systemTools/payment/orders.vue": "PaymentOrders",
"/src/view/systemTools/sms/index.vue": "SmsService",
"/src/view/systemTools/sysError/sysError.vue": "SysError",
"/src/view/systemTools/system/system.vue": "Config",
"/src/view/systemTools/timedTask/index.vue": "TimedTask",
+485
View File
@@ -0,0 +1,485 @@
<template>
<div class="kra-table-box integration-page">
<header class="page-heading">
<div><h2>短信服务</h2><p>配置短信渠道,通过已审核的模板发送通知。</p></div>
<el-button :icon="Refresh" :loading="loading" :disabled="busy" @click="refresh">刷新</el-button>
</header>
<div v-loading="loading" class="integration-layout">
<aside class="provider-panel" aria-label="短信服务列表">
<div class="panel-heading"><span>服务</span><span>{{ items.length }}</span></div>
<button v-for="item in items" :key="item.provider" type="button" class="provider-item"
:class="{ active: provider === item.provider }" :aria-pressed="provider === item.provider"
:disabled="busy" @click="provider = item.provider">
<span class="provider-icon" aria-hidden="true"><el-icon><Message /></el-icon></span>
<span class="provider-copy"><strong>{{ item.name }}</strong><small>{{ item.provider === 'aliyun' ? 'Aliyun SMS' : 'Tencent Cloud SMS' }}</small></span>
<span class="provider-state" :class="{ enabled: item.enabled }">{{ item.enabled ? '已启用' : '已停用' }}</span>
</button>
</aside>
<section v-if="selected" class="editor-panel">
<header class="editor-heading">
<div class="editor-title-group">
<div class="editor-title-row"><h3>{{ selected.name }}</h3><el-tag v-if="dirty" type="warning" effect="plain">未保存</el-tag></div>
<p>{{ selected.description }}</p>
</div>
<div class="enable-control">
<span>{{ selected.enabled ? '已启用' : '已停用' }}</span>
<el-switch v-model="selected.enabled" :disabled="busy" aria-label="启用短信服务" />
</div>
</header>
<el-alert v-if="hasMaskedSecret" class="secret-alert" type="info" :closable="false" show-icon
title="密钥已脱敏;保留 ****** 将继续使用当前密钥。" />
<el-form :model="selected.config" label-position="top" :disabled="busy" class="config-form" @submit.prevent>
<div class="field-grid">
<el-form-item v-for="field in selected.fields" :key="field.key" :label="field.label" :required="field.required">
<el-input v-model="selected.config[field.key]" :type="field.secret ? 'password' : 'text'"
class="field-control" :show-password="field.secret" :maxlength="512" autocomplete="off" />
</el-form-item>
</div>
</el-form>
<footer class="editor-actions">
<span class="save-state">{{ selected.configured ? '配置已创建' : '尚未保存配置' }}</span>
<el-button :icon="Message" :disabled="busy || !canSend" @click="testVisible = true">测试发送</el-button>
<el-button type="primary" :icon="Check" :loading="saving" :disabled="busy || (!dirty && selected.configured)" @click="save">保存配置</el-button>
</footer>
</section>
<el-empty v-else-if="!loading" description="暂无短信配置" />
</div>
<el-dialog v-model="testVisible" :title="`${selected?.name || '短信'} · 测试发送`" width="560px"
:close-on-click-modal="false" :close-on-press-escape="!sending" :show-close="!sending">
<template v-if="selected">
<el-alert type="warning" :closable="false" show-icon title="会发送真实短信,并按服务商规则计费。请使用你有权联系的测试号码。" />
<p class="field-description test-description">使用已保存且启用的签名与模板。提交成功表示服务商已受理,实际送达请查看服务商控制台。</p>
<el-form label-position="top" :disabled="busy">
<el-form-item label="接收号码" required>
<el-input v-model="draft.phone" placeholder="例如 +8613800138000" maxlength="16" />
</el-form-item>
<el-form-item label="模板参数(JSON)" required>
<el-input v-model="draft.params" type="textarea" :rows="5" :maxlength="12000" spellcheck="false" />
<p class="field-description">{{ provider === 'aliyun' ? '填写命名参数对象,例如 {"code":"123456"};无参数时填写 {}。' : '按模板占位符顺序填写字符串数组,例如 ["123456","5"];无参数时填写 []。' }}</p>
</el-form-item>
</el-form>
<el-alert v-if="draft.result" type="success" :closable="false" title="服务商已受理">
<p>请求 ID:{{ draft.result.requestId || '未返回' }}</p>
<p>短信流水号:{{ draft.result.messageId || '未返回' }}</p>
</el-alert>
</template>
<template #footer>
<el-button :disabled="sending" @click="testVisible = false">关闭</el-button>
<el-button type="primary" :icon="Message" :loading="sending" :disabled="busy || !canSend" @click="send">发送测试短信</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Message, Refresh } from '@element-plus/icons-vue'
import { getIntegrationConfigs, saveIntegrationConfig, sendSMS } from '@/api/integration'
defineOptions({ name: 'SmsService' })
const loading = ref(false)
const saving = ref(false)
const sending = ref(false)
const testVisible = ref(false)
const busy = computed(() => loading.value || saving.value || sending.value)
const provider = ref('aliyun')
const items = ref([])
const drafts = reactive({
aliyun: { phone: '', params: '{}', result: null },
tencent: { phone: '', params: '[]', result: null }
})
const selected = computed(() => items.value.find(item => item.provider === provider.value))
const draft = computed(() => drafts[provider.value])
const snapshot = item => JSON.stringify({ enabled: item.enabled, config: item.config })
const dirty = computed(() => selected.value && snapshot(selected.value) !== selected.value.saved)
const canSend = computed(() => selected.value?.configured && selected.value.enabled && !dirty.value)
const hasMaskedSecret = computed(() => selected.value?.fields.some(field => field.secret && selected.value.config[field.key] === '******'))
async function load() {
loading.value = true
try {
const res = await getIntegrationConfigs('sms')
if (res.code !== 0) return
items.value = (res.data || []).filter(item => drafts[item.provider]).map(item => ({ ...item, saved: snapshot(item) }))
} catch { /* Request layer displays errors. */ } finally { loading.value = false }
}
async function refresh() {
if (items.value.some(item => snapshot(item) !== item.saved)) {
try { await ElMessageBox.confirm('刷新会丢弃未保存的配置修改,是否继续?', '刷新配置', { type: 'warning' }) } catch { return }
}
await load()
}
async function save() {
const item = selected.value
if (!item || busy.value) return
if (item.enabled) {
const missing = item.fields.find(field => field.required && !String(item.config[field.key] || '').trim())
if (missing) return ElMessage.warning(`请填写${missing.label}`)
}
saving.value = true
try {
const res = await saveIntegrationConfig('sms', item.provider, { enabled: item.enabled, config: { ...item.config } })
if (res.code !== 0) return
for (const field of item.fields) {
if (field.secret && item.config[field.key]) item.config[field.key] = '******'
}
item.configured = true
item.saved = snapshot(item)
ElMessage.success('短信配置已保存')
} catch { /* Request layer displays errors. */ } finally { saving.value = false }
}
async function send() {
const item = selected.value
if (busy.value || !item?.configured || !item.enabled || dirty.value) return
const current = drafts[item.provider]
const phone = current.phone.trim()
if (!/^\+[1-9]\d{6,14}$/.test(phone)) return ElMessage.warning('请填写包含国家区号的号码,例如 +8613800138000')
if (item.provider === 'aliyun' && !/^\+861\d{10}$/.test(phone)) return ElMessage.warning('阿里云短信请填写中国大陆手机号')
let params
try {
params = JSON.parse(current.params)
const valid = item.provider === 'aliyun'
? params !== null && typeof params === 'object' && !Array.isArray(params) && Object.values(params).every(v => typeof v === 'string')
: Array.isArray(params) && params.every(v => typeof v === 'string')
if (!valid) throw new Error('invalid')
} catch { return ElMessage.warning('模板参数格式不正确,请按下方示例填写字符串参数') }
sending.value = true
current.result = null
try {
try {
await ElMessageBox.confirm(`将通过${item.name}向 ${phone} 发送一条真实短信,可能产生费用。是否发送?`, '确认发送', { confirmButtonText: '发送一条短信', cancelButtonText: '取消', type: 'warning' })
} catch { return }
const res = await sendSMS(item.provider, { phone, params })
if (res.code === 0) {
current.result = res.data
ElMessage.success('短信已提交服务商')
}
} catch { /* Do not retry: a timed-out send may already have been accepted. */ } finally { sending.value = false }
}
onMounted(load)
</script>
<style scoped>
.integration-page {
min-height: 640px;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.page-heading h2 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 20px;
font-weight: 600;
letter-spacing: 0;
}
.page-heading p {
margin: 5px 0 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.integration-layout {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 560px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
}
.provider-panel {
padding: 12px 9px;
border-right: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-blank);
}
.panel-heading {
display: flex;
justify-content: space-between;
padding: 4px 10px 11px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.provider-item {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
width: 100%;
min-height: 58px;
padding: 8px 9px;
border: 0;
border-left: 3px solid transparent;
border-radius: 4px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.provider-item:hover {
background: var(--el-fill-color-light);
}
.provider-item.active {
border-left-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.provider-icon {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--el-fill-color);
color: var(--el-text-color-regular);
font-size: 17px;
}
.provider-item.active .provider-icon {
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
}
.provider-copy {
display: grid;
min-width: 0;
gap: 3px;
}
.provider-copy strong {
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-copy small {
color: var(--el-text-color-secondary);
font-size: 11px;
}
.provider-state {
color: var(--el-text-color-placeholder);
font-size: 11px;
white-space: nowrap;
}
.provider-state.enabled {
color: var(--el-color-success);
}
.editor-panel {
display: flex;
min-width: 0;
flex-direction: column;
padding: 22px 28px 20px;
}
.editor-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding-bottom: 18px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.editor-title-group {
min-width: 0;
}
.editor-title-row {
display: flex;
align-items: center;
gap: 9px;
}
.editor-title-row h3 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 18px;
font-weight: 600;
letter-spacing: 0;
}
.editor-title-group p {
margin: 6px 0 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.enable-control {
display: flex;
align-items: center;
gap: 10px;
min-height: 32px;
color: var(--el-text-color-regular);
font-size: 13px;
white-space: nowrap;
}
.secret-alert {
margin-top: 18px;
}
.config-form {
flex: 1;
padding-top: 20px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 22px;
}
.field-label {
display: inline-flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.field-label small {
overflow: hidden;
color: var(--el-text-color-placeholder);
font-size: 11px;
font-weight: 400;
text-overflow: ellipsis;
}
.field-control {
width: 100%;
}
.field-description {
width: 100%;
margin: 5px 0 0;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.copy-example {
margin-top: 8px;
}
.editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
.save-state {
margin-right: auto;
color: var(--el-text-color-secondary);
font-size: 12px;
}
@media (max-width: 900px) {
.integration-layout {
grid-template-columns: 1fr;
}
.provider-panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
border-right: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.panel-heading {
display: none;
}
.provider-item {
grid-template-columns: 30px minmax(0, 1fr);
min-height: 54px;
}
.provider-icon {
width: 28px;
height: 28px;
}
.provider-state {
display: none;
}
.editor-panel {
padding: 20px;
}
.field-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.integration-page {
min-height: 0;
}
.page-heading {
align-items: flex-start;
}
.provider-panel {
grid-template-columns: 1fr;
}
.provider-item {
grid-template-columns: 30px minmax(0, 1fr) auto;
}
.provider-state {
display: inline;
}
.editor-panel {
padding: 18px 14px;
}
.editor-heading {
align-items: stretch;
flex-direction: column;
gap: 14px;
}
.enable-control {
justify-content: space-between;
}
.editor-actions {
align-items: stretch;
flex-direction: column;
}
.save-state {
margin-right: 0;
}
}
.test-description { margin: 14px 0 20px; }
</style>