新增消息重发、重试策略、路由解析测试、审计日志统计、内容审核统计等10个业务弹窗组件,覆盖消息重发、配置管理、安全审计、路由测试等多个业务场景,统一弹窗交互样式与表单校验逻辑。
371 lines
11 KiB
Vue
371 lines
11 KiB
Vue
<template>
|
||
<a-modal
|
||
v-model:open="visible"
|
||
title="测试路由解析"
|
||
:width="720"
|
||
:destroy-on-close="true"
|
||
:body-style="{ maxHeight: '70vh', overflowY: 'auto' }"
|
||
@cancel="handleClose"
|
||
>
|
||
<div class="ch-route-resolve">
|
||
<div class="ch-route-resolve__section">
|
||
<div class="ch-route-resolve__section-title">解析上下文</div>
|
||
|
||
<a-form
|
||
ref="formRef"
|
||
:model="formState"
|
||
:rules="rules"
|
||
layout="vertical"
|
||
class="ch-route-resolve__form ch-form"
|
||
>
|
||
<a-form-item label="session_key" name="session_key">
|
||
<a-input
|
||
v-model:value="formState.session_key"
|
||
:disabled="loading"
|
||
placeholder="会话键"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="渠道类型" name="channel_type">
|
||
<a-select
|
||
v-model:value="formState.channel_type"
|
||
:options="channelTypeOptions"
|
||
:disabled="loading"
|
||
placeholder="请选择渠道类型"
|
||
show-search
|
||
option-filter-prop="label"
|
||
@change="handleChannelChange"
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="账户 ID" name="account_id">
|
||
<a-select
|
||
v-model:value="formState.account_id"
|
||
:options="accountOptions"
|
||
:loading="accountLoading"
|
||
:disabled="!formState.channel_type || loading"
|
||
placeholder="请先选择渠道,再选择账户"
|
||
show-search
|
||
option-filter-prop="label"
|
||
:not-found-content="
|
||
accountLoading
|
||
? '加载中...'
|
||
: formState.channel_type
|
||
? '无匹配账户'
|
||
: '请先选择渠道'
|
||
"
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="会话类型" name="chat_type">
|
||
<a-radio-group v-model:value="formState.chat_type" :disabled="loading">
|
||
<a-radio value="p2p">单聊</a-radio>
|
||
<a-radio value="group">群聊</a-radio>
|
||
</a-radio-group>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="peer_id" name="peer_id">
|
||
<a-input
|
||
v-model:value="formState.peer_id"
|
||
:disabled="loading"
|
||
placeholder="对端 ID"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="unified_identity_id" name="unified_identity_id">
|
||
<a-input
|
||
v-model:value="formState.unified_identity_id"
|
||
:disabled="loading"
|
||
placeholder="统一身份 ID(可选)"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
|
||
<a-form-item label="conversation_id" name="conversation_id">
|
||
<a-input
|
||
v-model:value="formState.conversation_id"
|
||
:disabled="loading"
|
||
placeholder="会话 ID(可选)"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
|
||
<div class="ch-route-resolve__actions">
|
||
<a-button
|
||
type="primary"
|
||
:loading="loading"
|
||
:disabled="loading"
|
||
@click="handleResolve"
|
||
>
|
||
<template #icon><Route :size="14" /></template>
|
||
解析
|
||
</a-button>
|
||
</div>
|
||
</a-form>
|
||
</div>
|
||
|
||
<a-divider class="ch-route-resolve__divider" />
|
||
|
||
<!-- 命中结果 -->
|
||
<div class="ch-route-resolve__section">
|
||
<div class="ch-route-resolve__section-title">命中结果</div>
|
||
|
||
<template v-if="loading">
|
||
<div class="ch-route-resolve__pending">解析中…</div>
|
||
</template>
|
||
<template v-else-if="error">
|
||
<div class="ch-route-resolve__error">
|
||
<a-alert type="error" :message="error" show-icon />
|
||
</div>
|
||
</template>
|
||
<template v-else-if="result">
|
||
<RouteBindingResolveResult :result="result" :context="formState" />
|
||
</template>
|
||
<div v-else class="ch-route-resolve__empty">尚未发起解析</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<div class="ch-route-resolve__footer">
|
||
<a-button @click="handleClose">关闭</a-button>
|
||
</div>
|
||
</template>
|
||
</a-modal>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, watch } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import { Route } from 'lucide-vue-next'
|
||
import { useRouteBinding } from '@/composables/channels/useRouteBinding'
|
||
import { useChannelTypes } from '@/composables/channels/useChannelTypes'
|
||
import { getAccountListApi } from '@/apis/channels/account_api'
|
||
import { unwrap } from '@/composables/channels/utils'
|
||
import RouteBindingResolveResult from './RouteBindingResolveResult.vue'
|
||
|
||
const ACCOUNT_DROPDOWN_LIMIT = 1000
|
||
|
||
/**
|
||
* 路由解析测试弹窗(RB-05 resolve)
|
||
*
|
||
* 传入 BindingContext 字段(对齐后端 ResolveRouteBindingRequest),
|
||
* 调用 useRouteBinding.resolve 触发 route_binding/resolve 控制面操作,
|
||
* 展示命中绑定的诊断元数据(binding_id / matched_layer / agent_binding / match_value)。
|
||
* 404 时展示「未匹配到路由绑定规则」并附带 trace_id 便于排查。
|
||
*/
|
||
const props = defineProps({
|
||
open: { type: Boolean, default: false },
|
||
initialRecord: { type: Object, default: null }
|
||
})
|
||
|
||
const emit = defineEmits(['update:open', 'close'])
|
||
|
||
const { resolve } = useRouteBinding()
|
||
const { channelTypeOptions } = useChannelTypes()
|
||
|
||
const visible = computed({
|
||
get: () => props.open,
|
||
set: (val) => emit('update:open', val)
|
||
})
|
||
|
||
const formRef = ref(null)
|
||
const loading = ref(false)
|
||
const result = ref(null)
|
||
const error = ref(null)
|
||
|
||
// 账户选项:随渠道动态加载(对齐 RouteBindingFormModal 的 a-select 模式)
|
||
const accountOptions = ref([])
|
||
const accountLoading = ref(false)
|
||
|
||
const DEFAULT_FORM = {
|
||
session_key: '',
|
||
channel_type: undefined,
|
||
account_id: undefined,
|
||
chat_type: 'p2p',
|
||
peer_id: '',
|
||
unified_identity_id: '',
|
||
conversation_id: ''
|
||
}
|
||
|
||
const formState = reactive({ ...DEFAULT_FORM })
|
||
|
||
const rules = {
|
||
session_key: [{ required: true, message: '请输入 session_key', trigger: 'blur' }],
|
||
channel_type: [{ required: true, message: '请选择渠道类型', trigger: 'change' }],
|
||
account_id: [{ required: true, message: '请选择账户', trigger: 'change' }],
|
||
chat_type: [{ required: true, message: '请选择会话类型', trigger: 'change' }],
|
||
peer_id: [{ required: true, message: '请输入 peer_id', trigger: 'blur' }]
|
||
}
|
||
|
||
// 渠道切换:清空账户选择并按渠道加载账户列表
|
||
async function handleChannelChange(type) {
|
||
formState.account_id = undefined
|
||
await loadAccountOptions(type)
|
||
}
|
||
|
||
async function loadAccountOptions(channelTypeValue) {
|
||
if (!channelTypeValue) {
|
||
accountOptions.value = []
|
||
return
|
||
}
|
||
accountLoading.value = true
|
||
try {
|
||
const res = unwrap(await getAccountListApi({ channel_type: channelTypeValue, limit: ACCOUNT_DROPDOWN_LIMIT }))
|
||
accountOptions.value = (res?.accounts ?? []).map((acc) => ({
|
||
label: acc.display_name || acc.account_id,
|
||
value: acc.account_id
|
||
}))
|
||
} catch (e) {
|
||
message.error(e?.message || '账户列表加载失败')
|
||
accountOptions.value = []
|
||
} finally {
|
||
accountLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 提交解析
|
||
async function handleResolve() {
|
||
try {
|
||
await formRef.value?.validate()
|
||
} catch {
|
||
return // 校验未通过,Ant Design 已显示字段级错误
|
||
}
|
||
loading.value = true
|
||
error.value = null
|
||
result.value = null
|
||
try {
|
||
const res = await resolve({ ...formState })
|
||
result.value = res
|
||
} catch (err) {
|
||
const status = err?.status || err?.response?.status
|
||
if (status === 404) {
|
||
error.value = `未匹配到路由绑定规则(trace_id: ${err?.trace_id || '-'})`
|
||
} else {
|
||
error.value = err?.message || '解析失败'
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleClose() {
|
||
visible.value = false
|
||
emit('close')
|
||
}
|
||
|
||
// 弹窗打开时重置表单与结果状态;如有 initialRecord,则一键带入解析上下文
|
||
watch(
|
||
() => props.open,
|
||
async (val) => {
|
||
if (!val) return
|
||
Object.assign(formState, DEFAULT_FORM)
|
||
accountOptions.value = []
|
||
result.value = null
|
||
error.value = null
|
||
formRef.value?.clearValidate?.()
|
||
if (props.initialRecord) {
|
||
await applyInitialRecord(props.initialRecord)
|
||
}
|
||
}
|
||
)
|
||
|
||
/**
|
||
* 根据路由绑定规则预填解析上下文。
|
||
*
|
||
* 按 match_source 智能推导测试参数:
|
||
* - session_key:session_key 与 peer_id 均从 match_value 解析
|
||
* - peer_id:peer_id 用 match_value,session_key 自动构造
|
||
* - identity_id:unified_identity_id 用 match_value
|
||
* - chat_type:chat_type 用 match_value
|
||
* - account / default:仅带入渠道与账户,其余使用占位值
|
||
*/
|
||
async function applyInitialRecord(record) {
|
||
formState.channel_type = record.channel_type
|
||
await loadAccountOptions(record.channel_type)
|
||
formState.account_id = record.account_id
|
||
|
||
const matchValue = record.match_value || ''
|
||
const defaultPeerId = 'test_peer'
|
||
|
||
switch (record.match_source) {
|
||
case 'session_key':
|
||
formState.session_key = matchValue
|
||
formState.peer_id = extractPeerIdFromSessionKey(matchValue) || defaultPeerId
|
||
break
|
||
case 'peer_id':
|
||
formState.peer_id = matchValue
|
||
formState.session_key = `${record.channel_type}:${record.account_id}:${matchValue}`
|
||
break
|
||
case 'identity_id':
|
||
formState.unified_identity_id = matchValue
|
||
formState.peer_id = defaultPeerId
|
||
formState.session_key = `${record.channel_type}:${record.account_id}:${defaultPeerId}`
|
||
break
|
||
case 'chat_type':
|
||
formState.chat_type = matchValue || 'p2p'
|
||
formState.peer_id = defaultPeerId
|
||
formState.session_key = `${record.channel_type}:${record.account_id}:${defaultPeerId}`
|
||
break
|
||
default:
|
||
formState.peer_id = defaultPeerId
|
||
formState.session_key = `${record.channel_type}:${record.account_id}:${defaultPeerId}`
|
||
}
|
||
}
|
||
|
||
function extractPeerIdFromSessionKey(sessionKey) {
|
||
const parts = (sessionKey || '').split(':')
|
||
return parts.length >= 3 ? parts[parts.length - 1] : ''
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.ch-route-resolve {
|
||
&__section {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
&__section-title {
|
||
margin: 0 0 16px;
|
||
padding-left: 8px;
|
||
border-left: 3px solid var(--main-color);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--gray-900);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
&__actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
&__divider {
|
||
margin: 16px 0;
|
||
}
|
||
|
||
&__pending {
|
||
font-size: 13px;
|
||
color: var(--gray-600);
|
||
}
|
||
|
||
&__error {
|
||
:deep(.ant-alert) {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
&__empty {
|
||
font-size: 13px;
|
||
color: var(--gray-400);
|
||
}
|
||
|
||
&__footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
</style>
|