ForcePilot/web/src/components/channels/access-ops/RouteBindingResolveModal.vue
Kris 37551496af feat: 新增10个业务弹窗组件
新增消息重发、重试策略、路由解析测试、审计日志统计、内容审核统计等10个业务弹窗组件,覆盖消息重发、配置管理、安全审计、路由测试等多个业务场景,统一弹窗交互样式与表单校验逻辑。
2026-07-09 04:24:33 +08:00

371 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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_keysession_key 与 peer_id 均从 match_value 解析
* - peer_idpeer_id 用 match_valuesession_key 自动构造
* - identity_idunified_identity_id 用 match_value
* - chat_typechat_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>