增加短信渠道
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user