ForcePilot/web/src/components/channels/access-ops/RouteBindingResolveModal.vue

371 lines
11 KiB
Vue
Raw Normal View History

<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>