feat(gateway): 新增消息网关及apikey功能
Quality check / Web UI (push) Successful in 9m16s

This commit is contained in:
mei
2026-08-30 21:09:26 +08:00
parent 360169ee64
commit 0d89b7fe8f
18 changed files with 363 additions and 20 deletions
+7
View File
@@ -7,6 +7,7 @@ import type {
DashboardOverview,
ExecutionLog,
LogPage,
MessageGateway,
Providers,
Rule,
RuleRequest,
@@ -81,6 +82,12 @@ export const logApi = {
clear: () => api.delete<string>('/logs/'),
}
export const gatewayApi = {
list: () => api.get<MessageGateway[]>('/gateways/'),
create: (name: string) => api.post<MessageGateway>('/gateways/', { name }),
remove: (id: number) => api.delete<string>(`/gateways/${id}`),
}
export const adminApi = {
getConfig: () => api.get<AdminConfigResponse>('/admin/config'),
updateConfig: (body: AdminConfig) => api.put<AdminConfig>('/admin/config', body),
+6 -1
View File
@@ -10,7 +10,8 @@ export interface UserInfo {
create_at: number
username: string
is_admin: boolean
dingtalk_config: string | null
dingtalk_config: string | null
api_key: string
}
export interface Timer {
@@ -40,6 +41,7 @@ export interface Rule {
id: number
uid: number
timer_id: number
gateway_id: number
name: string
account_id: number
type: string
@@ -48,9 +50,12 @@ export interface Rule {
create_at: number
}
export interface MessageGateway { id: number; uid: number; name: string; token: string; create_at: number }
export interface RuleRequest {
name: string
timer_id: number
gateway_id?: number
account_id: number
type: string
config_json: string
+2
View File
@@ -7,6 +7,7 @@ import {
Timer as TimerIcon,
Operation,
User,
Connection,
Document,
Setting,
SwitchButton,
@@ -38,6 +39,7 @@ const menus = [
{ path: '/timers', title: '定时器', icon: TimerIcon },
{ path: '/rules', title: '规则', icon: Operation },
{ path: '/accounts', title: '账号', icon: User },
{ path: '/gateways', title: '消息网关', icon: Connection },
{ path: '/logs', title: '执行日志', icon: Document },
{ path: '/settings', title: '设置', icon: Setting },
]
+1
View File
@@ -40,6 +40,7 @@ const router = createRouter({
component: () => import('@/views/AccountsView.vue'),
meta: { title: '账号' },
},
{ path: 'gateways', name: 'gateways', component: () => import('@/views/GatewaysView.vue'), meta: { title: '消息网关' } },
{
path: 'logs',
name: 'logs',
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument, Delete, Link, Plus } from '@element-plus/icons-vue'
import { gatewayApi, ruleApi } from '@/api'
import type { MessageGateway, Rule } from '@/api/types'
const gateways = ref<MessageGateway[]>([]), rules = ref<Rule[]>([]), name = ref(''), loading = ref(false), saving = ref(false)
const origin = window.location.origin
async function refresh() { loading.value = true; try { ;[gateways.value, rules.value] = await Promise.all([gatewayApi.list(), ruleApi.list()]) } catch { ElMessage.error('加载消息网关失败') } finally { loading.value = false } }
async function create() { if (!name.value.trim()) { ElMessage.warning('请输入网关名称'); return }; saving.value = true; try { await gatewayApi.create(name.value.trim()); name.value = ''; await refresh(); ElMessage.success('消息网关已创建') } catch (e) { ElMessage.error(e instanceof Error ? e.message : '创建失败') } finally { saving.value = false } }
async function remove(item: MessageGateway) { try { await ElMessageBox.confirm(`删除“${item.name}”?关联规则将无法继续通过此网关触发。`, '删除消息网关', { type: 'warning' }); await gatewayApi.remove(item.id); await refresh() } catch {} }
async function copy(url: string) { await navigator.clipboard?.writeText(url); ElMessage.success('Webhook 地址已复制') }
function endpoint(item: MessageGateway) { return `${origin}/api/v1/gateways/${item.token}/webhook` }
function ruleCount(item: MessageGateway) { return rules.value.filter(r => r.gateway_id === item.id).length }
onMounted(refresh)
</script>
<template><div class="gateway-page" v-loading="loading"><div class="page-header"><div><h2>消息网关</h2><div class="muted">创建入口,让外部系统通过 Webhook 触发通知规则</div></div><el-button type="primary" :icon="Plus">新建网关</el-button></div><el-card class="create-card"><div class="create-copy"><div class="create-icon"><el-icon :size="20"><Link /></el-icon></div><div><strong>新建 Webhook 入口</strong><div class="muted">创建后,在规则编辑页将通知规则关联到此网关</div></div></div><el-form class="create-form" @submit.prevent="create"><el-input v-model="name" size="large" placeholder="例如:生产环境告警" maxlength="64" clearable /><el-button type="primary" size="large" :loading="saving" @click="create">创建网关</el-button></el-form></el-card><div v-if="gateways.length" class="gateway-grid"><el-card v-for="item in gateways" :key="item.id" class="gateway-card"><div class="gateway-top"><div><div class="gateway-name">{{ item.name }}</div><div class="gateway-meta">{{ ruleCount(item) }} 条关联通知规则</div></div><el-button circle text type="danger" :icon="Delete" title="删除网关" @click="remove(item)" /></div><div class="endpoint-label">Webhook 地址</div><div class="endpoint"><code>{{ endpoint(item) }}</code><el-button text :icon="CopyDocument" title="复制地址" @click="copy(endpoint(item))" /></div><div class="payload-hint">POST JSON:<code>{ "message": "...", "title": "..." }</code></div></el-card></div><el-empty v-else-if="!loading" description="还没有消息网关" /></div></template>
<style scoped>.create-card{margin-bottom:20px}.create-copy{display:flex;align-items:center;gap:12px;margin-bottom:16px}.create-copy strong{font-size:15px}.create-icon{width:40px;height:40px;display:grid;place-items:center;border-radius:10px;color:var(--gb-primary-deep);background:var(--el-color-primary-light-9)}.create-form{display:flex;gap:10px;max-width:620px}.create-form .el-input{flex:1}.gateway-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}.gateway-card{min-width:0}.gateway-top{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:22px}.gateway-name{font-size:17px;font-weight:700}.gateway-meta,.endpoint-label,.payload-hint{color:var(--el-text-color-secondary);font-size:12px}.endpoint-label{margin-bottom:6px}.endpoint{display:flex;align-items:center;gap:6px;min-width:0;padding:10px 12px;border-radius:8px;background:var(--gb-bg)}.endpoint code{flex:1;min-width:0;overflow-wrap:anywhere;color:var(--el-text-color-primary);font-size:12px}.payload-hint{margin-top:12px}.payload-hint code{color:var(--el-text-color-primary)}@media(max-width:600px){.create-form{flex-direction:column}.gateway-grid{grid-template-columns:1fr}}</style>
+22 -6
View File
@@ -2,9 +2,9 @@
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, VideoPlay } from '@element-plus/icons-vue'
import { accountApi, ruleApi, timerApi } from '@/api'
import { accountApi, gatewayApi, ruleApi, timerApi } from '@/api'
import { ApiError } from '@/api/client'
import type { Account, Rule, Timer } from '@/api/types'
import type { Account, MessageGateway, Rule, Timer } from '@/api/types'
import { useMetaStore } from '@/stores/meta'
import { formatDateTime } from '@/utils/format'
import ConfigForm from '@/components/ConfigForm.vue'
@@ -16,6 +16,7 @@ const metaStore = useMetaStore()
const rules = ref<Rule[]>([])
const timers = ref<Timer[]>([])
const gateways = ref<MessageGateway[]>([])
const accounts = ref<Account[]>([])
const loading = ref(false)
const testingId = ref<number | null>(null)
@@ -30,6 +31,7 @@ const form = ref<{
name: string
type: string
timer_id: number
gateway_id: number
account_id: number | undefined
enabled: boolean
config_json: string
@@ -37,6 +39,7 @@ const form = ref<{
name: '',
type: '',
timer_id: 0,
gateway_id: 0,
account_id: undefined,
enabled: true,
config_json: '',
@@ -69,14 +72,16 @@ function accountName(id: number): string {
async function refresh() {
loading.value = true
try {
const [ruleList, timerList, accountList] = await Promise.all([
const [ruleList, timerList, accountList, gatewayList] = await Promise.all([
ruleApi.list(),
timerApi.list(),
accountApi.list(),
gatewayApi.list(),
])
rules.value = ruleList
timers.value = timerList
accounts.value = accountList
gateways.value = gatewayList
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
} finally {
@@ -85,7 +90,7 @@ async function refresh() {
}
function openCreate() {
if (timers.value.length === 0) {
if (timers.value.length === 0 && gateways.value.length === 0) {
ElMessage.warning('请先在“定时器”页面创建一个定时器')
return
}
@@ -94,6 +99,7 @@ function openCreate() {
name: '',
type: metaStore.ruleMetas[0]?.type ?? '',
timer_id: timers.value[0]?.id ?? 0,
gateway_id: 0,
account_id: undefined,
enabled: true,
config_json: '',
@@ -107,6 +113,7 @@ function openEdit(rule: Rule) {
name: rule.name,
type: rule.type,
timer_id: rule.timer_id,
gateway_id: rule.gateway_id || 0,
account_id: rule.account_id || undefined,
enabled: rule.enabled,
config_json: rule.config_json,
@@ -133,6 +140,7 @@ async function save() {
name: form.value.name,
type: form.value.type,
timer_id: form.value.timer_id,
gateway_id: form.value.gateway_id,
account_id: form.value.account_id ?? 0,
enabled: form.value.enabled,
config_json: form.value.config_json,
@@ -162,6 +170,7 @@ async function toggleEnabled(rule: Rule) {
name: rule.name,
type: rule.type,
timer_id: rule.timer_id,
gateway_id: rule.gateway_id,
account_id: rule.account_id,
enabled: rule.enabled,
config_json: rule.config_json,
@@ -308,10 +317,14 @@ onMounted(async () => {
</el-select>
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
</el-form-item>
<el-form-item label="关联定时器" required>
<el-select v-model="form.timer_id" style="width: 100%">
<el-form-item label="关联定时器/消息网关" required>
<div class="source-hint muted">规则只能选择一种触发方式</div>
<el-select v-model="form.timer_id" class="source-select" clearable placeholder="定时器触发">
<el-option v-for="t in timers" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
<el-select v-model="form.gateway_id" clearable placeholder="消息网关(可选)" style="width: 100%; margin-top: 8px" @change="form.gateway_id && (form.timer_id = 0)">
<el-option v-for="g in gateways" :key="g.id" :label="g.name" :value="g.id" />
</el-select>
</el-form-item>
<el-form-item v-if="currentMeta?.account_type" label="关联账号" required>
<el-select
@@ -363,6 +376,9 @@ onMounted(async () => {
width: 200px;
}
.source-select { width: 100%; margin-top: 8px; }
.source-hint { margin-bottom: 2px; }
@media (max-width: 768px) {
.header-tools {
flex-wrap: wrap;
+14
View File
@@ -14,6 +14,13 @@ const userStore = useUserStore()
const isAdmin = computed(() => userStore.user?.is_admin === true)
async function copyApiKey() {
const key = userStore.user?.api_key
if (!key) return
await navigator.clipboard?.writeText(key)
ElMessage.success('API Key 已复制')
}
// ---- 系统配置(仅管理员) ----
const adminLoading = ref(false)
const adminSaving = ref(false)
@@ -166,6 +173,13 @@ onMounted(async () => {
<el-descriptions-item label="注册时间">
{{ formatDateTime(userStore.user?.create_at ?? 0) }}
</el-descriptions-item>
<el-descriptions-item label="API Key">
<el-input :model-value="userStore.user?.api_key ?? ''" readonly style="max-width: 380px">
<template #append>
<el-button title="复制 API Key" @click="copyApiKey">复制</el-button>
</template>
</el-input>
</el-descriptions-item>
</el-descriptions>
</el-card>