feat(gateway): 优化gateway相关功能
Quality check / Web UI (push) Successful in 9m19s

This commit is contained in:
mei
2026-09-12 15:32:27 +08:00
parent 0d89b7fe8f
commit 9b656cebee
84 changed files with 2769 additions and 825 deletions
+14 -2
View File
@@ -6,11 +6,14 @@ import type {
AdminConfigResponse,
DashboardOverview,
ExecutionLog,
GatewayRule,
GatewayRuleRequest,
LogPage,
MessageGateway,
Providers,
Rule,
RuleRequest,
AccountDeleteImpact,
SiteInfo,
Timer,
TimerRequest,
@@ -66,13 +69,22 @@ export const ruleApi = {
remove: (id: number) => api.delete<string>(`/rules/${id}`),
}
export const gatewayRuleApi = {
list: (gatewayId?: number) =>
api.get<GatewayRule[]>('/gateway-rules/', gatewayId ? { gateway_id: gatewayId } : undefined),
create: (body: GatewayRuleRequest) => api.post<GatewayRule>('/gateway-rules/', body),
update: (id: number, body: GatewayRuleRequest) => api.put<GatewayRule>(`/gateway-rules/${id}`, body),
test: (id: number) => api.post<string>(`/gateway-rules/${id}/test`),
remove: (id: number) => api.delete<string>(`/gateway-rules/${id}`),
}
export const accountApi = {
list: (type?: string) =>
api.get<Account[]>('/accounts/', type ? { type } : undefined),
create: (body: AccountRequest) => api.post<Account>('/accounts/', body),
update: (id: number, body: AccountRequest) => api.put<Account>(`/accounts/${id}`, body),
test: (id: number) => api.post<string>(`/accounts/${id}/test`),
checkDelete: (id: number) => api.get<Rule[]>(`/accounts/${id}/check`),
checkDelete: (id: number) => api.get<AccountDeleteImpact>(`/accounts/${id}/check`),
remove: (id: number) => api.delete<string>(`/accounts/${id}`),
}
@@ -84,7 +96,7 @@ export const logApi = {
export const gatewayApi = {
list: () => api.get<MessageGateway[]>('/gateways/'),
create: (name: string) => api.post<MessageGateway>('/gateways/', { name }),
create: (name: string, type: string) => api.post<MessageGateway>('/gateways/', { name, type }),
remove: (id: number) => api.delete<string>(`/gateways/${id}`),
}
+51 -3
View File
@@ -41,6 +41,18 @@ export interface Rule {
id: number
uid: number
timer_id: number
name: string
account_id: number
type: string
config_json: string
enabled: boolean
create_at: number
}
/** 消息网关规则:由外部系统投递到网关的消息触发,与定时器规则分开存储 */
export interface GatewayRule {
id: number
uid: number
gateway_id: number
name: string
account_id: number
@@ -50,12 +62,27 @@ export interface Rule {
create_at: number
}
export interface MessageGateway { id: number; uid: number; name: string; token: string; create_at: number }
export interface MessageGateway {
id: number
uid: number
name: string
type: 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
enabled?: boolean
}
export interface GatewayRuleRequest {
name: string
gateway_id: number
account_id: number
type: string
config_json: string
@@ -94,6 +121,8 @@ export interface MetaField {
placeholder?: string
help?: string
secret?: boolean
/** 由消息网关投递的请求内容提供,网关规则页面不需要用户填写 */
gateway_message?: boolean
default?: unknown
}
@@ -115,9 +144,18 @@ export interface RuleMeta {
fields: MetaField[]
}
export interface GatewayMeta {
type: string
label: string
description?: string
docs?: string
payload_hint?: string
}
export interface Providers {
accounts: AccountMeta[]
rules: RuleMeta[]
gateways: GatewayMeta[]
}
export interface ExecutionLog {
@@ -127,7 +165,9 @@ export interface ExecutionLog {
rule_name: string
rule_type: string
timer_id: number
trigger: 'timer' | 'manual' | 'remind'
gateway_id: number
gateway_rule_id: number
trigger: 'timer' | 'manual' | 'remind' | 'gateway'
success: boolean
message: string
create_at: number
@@ -145,6 +185,8 @@ export interface DashboardOverview {
enabled_timers: number
triggered_count: number
rule_count: number
gateway_count: number
gateway_rule_count: number
account_count: number
server_time: number
recent_logs: ExecutionLog[]
@@ -152,6 +194,12 @@ export interface DashboardOverview {
urgent_seconds_left?: number
}
/** 删除账号时会被一并删除的规则 */
export interface AccountDeleteImpact {
rules: Rule[]
gateway_rules: GatewayRule[]
}
export interface SiteInfo {
name: string
enable_registry: boolean
+2
View File
@@ -8,6 +8,7 @@ import {
Operation,
User,
Connection,
Promotion,
Document,
Setting,
SwitchButton,
@@ -40,6 +41,7 @@ const menus = [
{ path: '/rules', title: '规则', icon: Operation },
{ path: '/accounts', title: '账号', icon: User },
{ path: '/gateways', title: '消息网关', icon: Connection },
{ path: '/gateway-rules', title: '网关规则', icon: Promotion },
{ path: '/logs', title: '执行日志', icon: Document },
{ path: '/settings', title: '设置', icon: Setting },
]
+6
View File
@@ -41,6 +41,12 @@ const router = createRouter({
meta: { title: '账号' },
},
{ path: 'gateways', name: 'gateways', component: () => import('@/views/GatewaysView.vue'), meta: { title: '消息网关' } },
{
path: 'gateway-rules',
name: 'gateway-rules',
component: () => import('@/views/GatewayRulesView.vue'),
meta: { title: '网关规则' },
},
{
path: 'logs',
name: 'logs',
+13 -1
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { providerApi } from '@/api'
import type { AccountMeta, Providers, RuleMeta } from '@/api/types'
import type { AccountMeta, GatewayMeta, Providers, RuleMeta } from '@/api/types'
/** 驱动元数据:账号/规则类型的表单描述,登录后拉取一次全局共享 */
export const useMetaStore = defineStore('meta', () => {
@@ -16,6 +16,7 @@ export const useMetaStore = defineStore('meta', () => {
const accountMetas = computed<AccountMeta[]>(() => providers.value?.accounts ?? [])
const ruleMetas = computed<RuleMeta[]>(() => providers.value?.rules ?? [])
const gatewayMetas = computed<GatewayMeta[]>(() => providers.value?.gateways ?? [])
function accountMeta(type: string): AccountMeta | undefined {
return accountMetas.value.find((m) => m.type === type)
@@ -33,14 +34,25 @@ export const useMetaStore = defineStore('meta', () => {
return ruleMeta(type)?.label ?? type
}
function gatewayMeta(type: string): GatewayMeta | undefined {
return gatewayMetas.value.find((m) => m.type === type)
}
function gatewayLabel(type: string): string {
return gatewayMeta(type)?.label ?? type
}
return {
providers,
accountMetas,
ruleMetas,
gatewayMetas,
ensureLoaded,
accountMeta,
ruleMeta,
gatewayMeta,
accountLabel,
ruleLabel,
gatewayLabel,
}
})
+3 -2
View File
@@ -94,9 +94,10 @@ async function test(account: Account) {
async function remove(account: Account) {
try {
const affected = await accountApi.checkDelete(account.id)
const affectedRules = [...affected.rules, ...affected.gateway_rules]
const warning =
affected.length > 0
? `删除后,使用该账号的 ${affected.length} 条规则也会被一并删除:${affected
affectedRules.length > 0
? `删除后,使用该账号的 ${affectedRules.length} 条规则也会被一并删除:${affectedRules
.map((r) => r.name)
.join('、')}`
: '没有规则使用该账号。'
+11 -1
View File
@@ -8,6 +8,7 @@ import {
Timer as TimerIcon,
Operation,
User,
Connection,
Warning,
} from '@element-plus/icons-vue'
import { dashboardApi, timerApi } from '@/api'
@@ -73,13 +74,22 @@ const stats = computed(() => [
},
{
label: '规则',
sub: '触发时执行的动作',
sub: `定时器规则 ${overview.value?.rule_count ?? 0} 条`,
value: overview.value?.rule_count,
icon: Operation,
color: '#6366f1',
bg: 'rgb(99 102 241 / 0.12)',
to: '/rules',
},
{
label: '消息网关',
sub: `网关规则 ${overview.value?.gateway_rule_count ?? 0} 条`,
value: overview.value?.gateway_count,
icon: Connection,
color: '#0ea5e9',
bg: 'rgb(14 165 233 / 0.12)',
to: '/gateways',
},
{
label: '账号',
sub: '第三方凭据',
+436
View File
@@ -0,0 +1,436 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, VideoPlay } from '@element-plus/icons-vue'
import { accountApi, gatewayApi, gatewayRuleApi } from '@/api'
import { ApiError } from '@/api/client'
import type { Account, GatewayRule, MessageGateway } from '@/api/types'
import { useMetaStore } from '@/stores/meta'
import { formatDateTime } from '@/utils/format'
import ConfigForm from '@/components/ConfigForm.vue'
import { useIsMobile } from '@/composables/useBreakpoint'
const isMobile = useIsMobile()
const route = useRoute()
const metaStore = useMetaStore()
const rules = ref<GatewayRule[]>([])
const gateways = ref<MessageGateway[]>([])
const accounts = ref<Account[]>([])
const loading = ref(false)
const testingId = ref<number | null>(null)
const filterGatewayId = ref<number | undefined>(undefined)
// ---- 创建 / 编辑对话框 ----
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
const form = ref<{
name: string
type: string
gateway_id: number
account_id: number | undefined
enabled: boolean
config_json: string
}>({
name: '',
type: '',
gateway_id: 0,
account_id: undefined,
enabled: true,
config_json: '',
})
const dialogTitle = computed(() => (editingId.value === null ? '创建网关规则' : '编辑网关规则'))
const currentMeta = computed(() => metaStore.ruleMeta(form.value.type))
/** 由投递请求提供的字段,页面上不让用户填 */
const gatewayMessageFields = computed(() =>
(currentMeta.value?.fields ?? []).filter((f) => f.gateway_message),
)
/** 需要用户填写的字段 */
const configFields = computed(() => (currentMeta.value?.fields ?? []).filter((f) => !f.gateway_message))
/** 当前规则类型可用的账号(按类型过滤) */
const availableAccounts = computed(() => {
const requiredType = currentMeta.value?.account_type
if (!requiredType) return []
return accounts.value.filter((a) => a.type === requiredType)
})
const filteredRules = computed(() => {
if (!filterGatewayId.value) return rules.value
return rules.value.filter((r) => r.gateway_id === filterGatewayId.value)
})
function gatewayName(id: number): string {
return gateways.value.find((g) => g.id === id)?.name ?? `#${id}`
}
function accountName(id: number): string {
if (!id) return '-'
return accounts.value.find((a) => a.id === id)?.name ?? `#${id}`
}
async function refresh() {
loading.value = true
try {
const [ruleList, gatewayList, accountList] = await Promise.all([
gatewayRuleApi.list(),
gatewayApi.list(),
accountApi.list(),
])
rules.value = ruleList
gateways.value = gatewayList
accounts.value = accountList
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
if (gateways.value.length === 0) {
ElMessage.warning('请先在“消息网关”页面创建一个网关')
return
}
editingId.value = null
form.value = {
name: '',
type: metaStore.ruleMetas[0]?.type ?? '',
gateway_id: filterGatewayId.value ?? gateways.value[0]?.id ?? 0,
account_id: undefined,
enabled: true,
config_json: '',
}
dialogVisible.value = true
}
function openEdit(rule: GatewayRule) {
editingId.value = rule.id
form.value = {
name: rule.name,
type: rule.type,
gateway_id: rule.gateway_id,
account_id: rule.account_id || undefined,
enabled: rule.enabled,
config_json: rule.config_json,
}
dialogVisible.value = true
}
function onTypeChange() {
form.value.config_json = ''
form.value.account_id = undefined
}
async function save() {
if (!form.value.name) {
ElMessage.warning('请填写规则名称')
return
}
if (!form.value.gateway_id) {
ElMessage.warning('请选择消息网关')
return
}
if (currentMeta.value?.account_type && !form.value.account_id) {
ElMessage.warning(`该规则类型需要关联一个「${metaStore.accountLabel(currentMeta.value.account_type)}」账号`)
return
}
const body = {
name: form.value.name,
type: form.value.type,
gateway_id: form.value.gateway_id,
account_id: form.value.account_id ?? 0,
enabled: form.value.enabled,
config_json: form.value.config_json,
}
saving.value = true
try {
if (editingId.value === null) {
await gatewayRuleApi.create(body)
ElMessage.success('网关规则已创建')
} else {
await gatewayRuleApi.update(editingId.value, body)
ElMessage.success('网关规则已更新')
}
dialogVisible.value = false
await refresh()
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '保存失败')
} finally {
saving.value = false
}
}
async function toggleEnabled(rule: GatewayRule) {
try {
await gatewayRuleApi.update(rule.id, {
name: rule.name,
type: rule.type,
gateway_id: rule.gateway_id,
account_id: rule.account_id,
enabled: rule.enabled,
config_json: rule.config_json,
})
ElMessage.success(rule.enabled ? '已启用' : '已停用')
} catch (error) {
rule.enabled = !rule.enabled
ElMessage.error(error instanceof ApiError ? error.message : '操作失败')
}
}
async function test(rule: GatewayRule) {
try {
await ElMessageBox.confirm(
'测试会按规则里保存的内容真实执行一次(发送消息 / 修改仓库等),确定继续吗?',
`测试网关规则「${rule.name}」`,
{ type: 'warning', confirmButtonText: '执行', cancelButtonText: '取消' },
)
} catch {
return
}
testingId.value = rule.id
try {
await gatewayRuleApi.test(rule.id)
ElMessage.success('执行成功,详情见执行日志')
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '执行失败')
} finally {
testingId.value = null
}
}
async function remove(rule: GatewayRule) {
try {
await ElMessageBox.confirm(`确定删除网关规则「${rule.name}」吗?`, '删除网关规则', {
type: 'warning',
confirmButtonText: '删除',
confirmButtonClass: 'el-button--danger',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await gatewayRuleApi.remove(rule.id)
ElMessage.success('已删除')
await refresh()
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '删除失败')
}
}
onMounted(async () => {
// 从「消息网关」页面跳转过来时可以带上要筛选的网关
const fromQuery = Number(route.query.gateway_id)
if (fromQuery) filterGatewayId.value = fromQuery
await metaStore.ensureLoaded()
await refresh()
})
</script>
<template>
<div v-loading="loading">
<div class="page-header">
<div>
<h2>网关规则</h2>
<div class="muted">外部系统往消息网关投递消息时要执行的动作,只需配置「发给谁」,内容来自请求</div>
</div>
<div class="header-tools">
<el-select
v-model="filterGatewayId"
placeholder="按消息网关筛选"
clearable
class="filter-select"
>
<el-option v-for="g in gateways" :key="g.id" :label="g.name" :value="g.id" />
</el-select>
<el-button type="primary" :icon="Plus" @click="openCreate">创建网关规则</el-button>
</div>
</div>
<el-alert class="rule-tip" type="info" :closable="false" show-icon>
<template #title>消息内容由投递请求提供</template>
投递时把请求里的 <code>message</code> / <code>title</code> 填进规则的消息字段,因此规则里不需要预先写好内容。
</el-alert>
<el-empty v-if="!loading && filteredRules.length === 0" description="还没有网关规则">
<el-button type="primary" @click="openCreate">创建网关规则</el-button>
</el-empty>
<el-card v-else class="table-card gb-rise">
<el-table :data="filteredRules" :size="isMobile ? 'small' : 'default'">
<el-table-column prop="name" label="名称" min-width="110" />
<el-table-column label="类型" :width="isMobile ? 120 : 160">
<template #default="{ row }">
<el-tag>{{ metaStore.ruleLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="!isMobile" label="消息网关" min-width="120">
<template #default="{ row }">
<el-tag size="small" type="info" effect="light">{{ gatewayName(row.gateway_id) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="!isMobile" label="账号" min-width="120">
<template #default="{ row }">{{ accountName(row.account_id) }}</template>
</el-table-column>
<el-table-column v-if="!isMobile" label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.create_at) }}</template>
</el-table-column>
<el-table-column label="启用" :width="isMobile ? 60 : 80">
<template #default="{ row }">
<el-switch v-model="row.enabled" size="small" @change="toggleEnabled(row)" />
</template>
</el-table-column>
<el-table-column label="操作" :width="isMobile ? 200 : 240" fixed="right">
<template #default="{ row }">
<el-button
size="small"
:icon="VideoPlay"
:loading="testingId === row.id"
@click="test(row)"
>
测试
</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" plain @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 创建/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form :label-width="isMobile ? 'auto' : '110px'" :label-position="isMobile ? 'top' : 'right'">
<el-form-item label="规则名称" required>
<el-input v-model="form.name" placeholder="给这条规则起个名字" maxlength="64" />
</el-form-item>
<el-form-item label="消息网关" required>
<el-select v-model="form.gateway_id" style="width: 100%" placeholder="选择消息网关">
<el-option v-for="g in gateways" :key="g.id" :label="g.name" :value="g.id" />
</el-select>
<div class="muted">外部系统向这个网关投递消息时触发本规则</div>
</el-form-item>
<el-form-item label="规则类型" required>
<el-select
v-model="form.type"
:disabled="editingId !== null"
style="width: 100%"
@change="onTypeChange"
>
<el-option
v-for="m in metaStore.ruleMetas"
:key="m.type"
:label="m.label"
:value="m.type"
/>
</el-select>
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
<div v-if="currentMeta && !gatewayMessageFields.length" class="muted">
该规则类型没有消息字段,投递只作为触发,按下方的配置执行
</div>
</el-form-item>
<el-form-item v-if="currentMeta?.account_type" label="关联账号" required>
<el-select
v-model="form.account_id"
style="width: 100%"
:placeholder="availableAccounts.length ? '选择账号' : '没有可用账号,请先到“账号”页面添加'"
>
<el-option
v-for="a in availableAccounts"
:key="a.id"
:label="a.name"
:value="a.id"
/>
</el-select>
<div class="muted">
需要「{{ metaStore.accountLabel(currentMeta.account_type) }}」类型的账号
</div>
</el-form-item>
<el-form-item v-if="gatewayMessageFields.length" label="消息内容">
<div class="message-fields">
<el-tag
v-for="f in gatewayMessageFields"
:key="f.key"
size="small"
type="info"
effect="plain"
>
{{ f.label }}
</el-tag>
<div class="muted">
由投递请求的 <code>title</code> / <code>message</code> 自动填充,无需在此填写
</div>
</div>
</el-form-item>
<ConfigForm v-if="currentMeta" v-model="form.config_json" :fields="configFields" />
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.rule-tip {
margin-bottom: 16px;
}
.rule-tip code {
padding: 1px 5px;
border-radius: 4px;
background: var(--gb-bg);
color: var(--el-text-color-primary);
font-size: 12px;
}
.message-fields {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
line-height: 1.6;
}
.message-fields .muted {
width: 100%;
}
.header-tools {
display: flex;
gap: 12px;
}
.filter-select {
width: 200px;
}
@media (max-width: 768px) {
.header-tools {
flex-wrap: wrap;
gap: 10px;
}
.filter-select {
width: 100%;
}
}
</style>
+377 -14
View File
@@ -1,18 +1,381 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
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)
import { Connection, CopyDocument, Delete, Link, Plus, Setting } from '@element-plus/icons-vue'
import { gatewayApi, gatewayRuleApi } from '@/api'
import type { GatewayRule, MessageGateway } from '@/api/types'
import { useMetaStore } from '@/stores/meta'
import { formatDateTime } from '@/utils/format'
const router = useRouter()
const metaStore = useMetaStore()
const gateways = ref<MessageGateway[]>([])
const rules = ref<GatewayRule[]>([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const name = ref('')
const type = ref('')
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)
const currentMeta = computed(() => metaStore.gatewayMeta(type.value))
const enabledRuleCount = computed(() => rules.value.filter((r) => r.enabled).length)
async function refresh() {
loading.value = true
try {
;[gateways.value, rules.value] = await Promise.all([gatewayApi.list(), gatewayRuleApi.list()])
} catch {
ElMessage.error('加载消息网关失败')
} finally {
loading.value = false
}
}
function openCreate() {
name.value = ''
type.value = metaStore.gatewayMetas[0]?.type ?? 'webhook'
dialogVisible.value = true
}
async function create() {
if (!name.value.trim()) {
ElMessage.warning('请输入网关名称')
return
}
saving.value = true
try {
await gatewayApi.create(name.value.trim(), type.value)
dialogVisible.value = false
await refresh()
ElMessage.success('消息网关已创建')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '创建失败')
} finally {
saving.value = false
}
}
async function remove(item: MessageGateway) {
const count = ruleCount(item)
const detail = count > 0 ? `关联的 ${count} 条网关规则也会被一并删除。` : ''
try {
await ElMessageBox.confirm(`删除“${item.name}”?${detail}`, '删除消息网关', { type: 'warning' })
} catch {
return
}
try {
await gatewayApi.remove(item.id)
await refresh()
ElMessage.success('消息网关已删除')
} catch {
ElMessage.error('删除失败')
}
}
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
}
function payloadHint(item: MessageGateway) {
return metaStore.gatewayMeta(item.type)?.payload_hint ?? '{ "message": "..." }'
}
function manageRules(item: MessageGateway) {
router.push({ name: 'gateway-rules', query: { gateway_id: String(item.id) } })
}
onMounted(async () => {
await metaStore.ensureLoaded()
await 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>
<template>
<div v-loading="loading">
<div class="page-header">
<div>
<h2>消息网关</h2>
<div class="muted">为外部系统生成 Webhook 地址,投递消息即可触发绑定在网关上的规则</div>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建网关</el-button>
</div>
<div class="gw-stats gb-rise">
<div class="gw-stat">
<span class="gw-stat-label">网关</span>
<span class="gw-stat-value">{{ gateways.length }}</span>
</div>
<div class="gw-stat">
<span class="gw-stat-label">网关规则</span>
<span class="gw-stat-value">{{ rules.length }}</span>
</div>
<div class="gw-stat">
<span class="gw-stat-label">已启用规则</span>
<span class="gw-stat-value">{{ enabledRuleCount }}</span>
</div>
</div>
<el-empty v-if="!loading && gateways.length === 0" description="还没有消息网关">
<el-button type="primary" :icon="Plus" @click="openCreate">新建网关</el-button>
</el-empty>
<div v-else class="gateway-grid">
<el-card
v-for="(item, i) in gateways"
:key="item.id"
class="gateway-card gb-rise"
:style="{ animationDelay: `${i * 0.05}s` }"
>
<div class="gateway-head">
<div class="gateway-ident">
<div class="gateway-icon">
<el-icon :size="18"><Connection /></el-icon>
</div>
<div class="gateway-titles">
<div class="gateway-name">{{ item.name }}</div>
<div class="gateway-sub">
<el-tag size="small" type="info" effect="light">
{{ metaStore.gatewayLabel(item.type) }}
</el-tag>
<span>{{ ruleCount(item) }} 条网关规则</span>
<span>{{ formatDateTime(item.create_at) }}</span>
</div>
</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="endpoint-label payload-label">请求示例</div>
<pre class="payload"><code>{{ payloadHint(item) }}</code></pre>
<div class="gateway-actions">
<el-button size="small" :icon="Setting" @click="manageRules(item)">管理网关规则</el-button>
</div>
</el-card>
</div>
<!-- 新建网关 -->
<el-dialog v-model="dialogVisible" title="新建消息网关" width="520px">
<el-form label-width="90px">
<el-form-item label="网关类型" required>
<el-select v-model="type" style="width: 100%">
<el-option
v-for="m in metaStore.gatewayMetas"
:key="m.type"
:label="m.label"
:value="m.type"
/>
</el-select>
<div v-if="currentMeta?.description" class="muted field-hint">
{{ currentMeta.description }}
</div>
</el-form-item>
<el-form-item label="网关名称" required>
<el-input v-model="name" placeholder="例如:生产环境告警" maxlength="64" clearable />
</el-form-item>
</el-form>
<div class="dialog-hint">
<el-icon><Link /></el-icon>
<span>创建后到「网关规则」页面,把通知规则关联到这个网关</span>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="create">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.gw-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.gw-stat {
display: flex;
align-items: baseline;
gap: 10px;
padding: 14px 18px;
border-radius: var(--gb-radius-card);
background: var(--gb-card);
box-shadow: var(--gb-shadow-card);
}
.gw-stat-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.gw-stat-value {
margin-left: auto;
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--gb-primary-deep);
}
.gateway-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.gateway-card {
min-width: 0;
}
.gateway-card:hover {
transform: translateY(-3px);
box-shadow: var(--gb-shadow-hover);
}
.gateway-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.gateway-ident {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.gateway-icon {
width: 38px;
height: 38px;
flex: none;
display: grid;
place-items: center;
border-radius: 11px;
color: var(--gb-primary-deep);
background: var(--el-color-primary-light-9);
}
.gateway-titles {
min-width: 0;
}
.gateway-name {
font-size: 16px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gateway-sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.endpoint-label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.endpoint {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
margin-top: 6px;
padding: 8px 10px;
border-radius: 10px;
background: var(--gb-bg);
}
.endpoint code {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
color: var(--el-text-color-primary);
font-size: 12px;
}
.payload-label {
margin-top: 14px;
}
.payload {
margin: 6px 0 0;
padding: 10px 12px;
border-radius: 10px;
background: var(--gb-bg);
overflow-x: auto;
}
.payload code {
color: var(--el-text-color-regular);
font-size: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.gateway-actions {
margin-top: 16px;
}
.field-hint {
line-height: 1.5;
margin-top: 4px;
}
.dialog-hint {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
padding: 10px 12px;
border-radius: 10px;
background: var(--el-color-primary-light-9);
color: var(--el-text-color-regular);
font-size: 12px;
}
@media (max-width: 600px) {
.gateway-grid {
grid-template-columns: 1fr;
}
}
</style>
+1
View File
@@ -21,6 +21,7 @@ const triggerLabels: Record<string, string> = {
timer: '定时触发',
manual: '手动测试',
remind: '提醒',
gateway: '网关投递',
}
async function refresh() {
+6 -18
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, gatewayApi, ruleApi, timerApi } from '@/api'
import { accountApi, ruleApi, timerApi } from '@/api'
import { ApiError } from '@/api/client'
import type { Account, MessageGateway, Rule, Timer } from '@/api/types'
import type { Account, Rule, Timer } from '@/api/types'
import { useMetaStore } from '@/stores/meta'
import { formatDateTime } from '@/utils/format'
import ConfigForm from '@/components/ConfigForm.vue'
@@ -16,7 +16,6 @@ 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)
@@ -31,7 +30,6 @@ const form = ref<{
name: string
type: string
timer_id: number
gateway_id: number
account_id: number | undefined
enabled: boolean
config_json: string
@@ -39,7 +37,6 @@ const form = ref<{
name: '',
type: '',
timer_id: 0,
gateway_id: 0,
account_id: undefined,
enabled: true,
config_json: '',
@@ -72,16 +69,14 @@ function accountName(id: number): string {
async function refresh() {
loading.value = true
try {
const [ruleList, timerList, accountList, gatewayList] = await Promise.all([
const [ruleList, timerList, accountList] = 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 {
@@ -90,7 +85,7 @@ async function refresh() {
}
function openCreate() {
if (timers.value.length === 0 && gateways.value.length === 0) {
if (timers.value.length === 0) {
ElMessage.warning('请先在“定时器”页面创建一个定时器')
return
}
@@ -99,7 +94,6 @@ 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: '',
@@ -113,7 +107,6 @@ 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,
@@ -140,7 +133,6 @@ 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,
@@ -170,7 +162,6 @@ 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,
@@ -317,14 +308,11 @@ onMounted(async () => {
</el-select>
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
</el-form-item>
<el-form-item label="关联定时器/消息网关" required>
<div class="source-hint muted">规则只能选择一种触发方式</div>
<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