310 lines
8.4 KiB
Vue
310 lines
8.4 KiB
Vue
<script lang="ts" setup>
|
||
import { ref, computed } from 'vue';
|
||
|
||
import { useVbenModal } from '@vben/common-ui';
|
||
|
||
import dayjs from 'dayjs';
|
||
|
||
import {
|
||
Alert,
|
||
Descriptions,
|
||
DescriptionsItem as DescItem,
|
||
Tag,
|
||
Timeline,
|
||
TimelineItem,
|
||
TypographyParagraph,
|
||
TypographyText,
|
||
TypographyTitle,
|
||
} from 'ant-design-vue';
|
||
|
||
/**
|
||
* OA 机器人「测试发送结果」独立弹窗
|
||
*
|
||
* 设计要点:
|
||
* - 顶部 Descriptions:全局上下文(机器人、平台、时间、测试内容、@)
|
||
* - 底部 Timeline:每种消息类型一个节点,展示成功/失败 + 真实请求体 + 响应体
|
||
* - 请求体来自后端 results[].request(渠道 buildPayload 的真实 HTTP body,已脱敏)
|
||
* - 暗色模式全部走 antd 原生组件 + CSS 变量
|
||
*
|
||
* 数据来源:modalApi.getData(),由 test-send-modal.vue 的 onFinished 注入
|
||
*/
|
||
|
||
interface TestResultItem {
|
||
message_type: string;
|
||
name: string;
|
||
success: boolean;
|
||
message: string;
|
||
/** 真实 HTTP 请求体(后端脱敏后) */
|
||
request?: any;
|
||
response: any;
|
||
cost_ms: number;
|
||
}
|
||
|
||
/** 测试结果列表(由外部传入) */
|
||
const testResults = ref<TestResultItem[]>([]);
|
||
/** 测试汇总提示(由外部传入) */
|
||
const testSummary = ref('');
|
||
/** 机器人名称(上下文展示) */
|
||
const robotName = ref('');
|
||
/** 机器人 ID(上下文展示) */
|
||
const robotId = ref<number>();
|
||
/** 平台编码(上下文展示) */
|
||
const platformCode = ref('');
|
||
/** 测试内容(上下文展示) */
|
||
const testContent = ref('');
|
||
/** 是否 @ 全员(上下文展示) */
|
||
const testAtAll = ref(false);
|
||
/** @ 手机号列表(上下文展示) */
|
||
const testMobiles = ref<string[]>([]);
|
||
/** 发送完成时间戳(秒,上下文展示) */
|
||
const sentAt = ref<number>();
|
||
|
||
/**
|
||
* @ 配置摘要(Descriptions 一行展示用)
|
||
*/
|
||
const atSummary = computed(() => {
|
||
if (testAtAll.value) return '@ 全员';
|
||
if (testMobiles.value.length === 0) return '无';
|
||
return testMobiles.value.join('、');
|
||
});
|
||
|
||
/**
|
||
* 格式化时间戳为可读字符串(秒级 → dayjs)
|
||
*/
|
||
function formatTime(ts?: number): string {
|
||
return ts ? dayjs(ts * 1000).format('YYYY-MM-DD HH:mm:ss') : '-';
|
||
}
|
||
|
||
/**
|
||
* 把对象格式化为可读 JSON 字符串(空对象显示「无」)
|
||
* 用于请求体 / 响应体的 <pre> 展示
|
||
*/
|
||
function formatJson(data: any): string {
|
||
if (data === null || data === undefined) return '无';
|
||
if (typeof data === 'string') {
|
||
// 后端偶发已是 JSON 字符串,尝试美化
|
||
try {
|
||
return JSON.stringify(JSON.parse(data), null, 2);
|
||
} catch {
|
||
return data || '无';
|
||
}
|
||
}
|
||
if (typeof data === 'object' && Object.keys(data).length === 0) return '无';
|
||
try {
|
||
return JSON.stringify(data, null, 2);
|
||
} catch {
|
||
return String(data);
|
||
}
|
||
}
|
||
|
||
const [Modal, modalApi] = useVbenModal({
|
||
fullscreenButton: false,
|
||
draggable: true,
|
||
onCancel() {
|
||
modalApi.close();
|
||
},
|
||
onConfirm: async () => {
|
||
modalApi.close();
|
||
},
|
||
onOpenChange(isOpen: boolean) {
|
||
if (isOpen) {
|
||
const data = modalApi.getData<any>();
|
||
testResults.value = data?.results ?? [];
|
||
testSummary.value = data?.summary ?? '';
|
||
robotName.value = data?.robotName ?? '';
|
||
robotId.value = data?.robotId;
|
||
platformCode.value = data?.platformCode ?? '';
|
||
testContent.value = data?.testContent ?? '';
|
||
testAtAll.value = !!data?.testAtAll;
|
||
testMobiles.value = data?.testMobiles ?? [];
|
||
sentAt.value = data?.sentAt;
|
||
const suffix = robotName.value ? ` - ${robotName.value}` : '';
|
||
modalApi.setState({
|
||
title: `测试发送结果${suffix}`,
|
||
confirmText: '知道了',
|
||
});
|
||
}
|
||
},
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<Modal title="测试发送结果" class="w-[56%]">
|
||
<!-- 1. 汇总提示 -->
|
||
<Alert
|
||
v-if="testSummary"
|
||
:type="
|
||
testResults.length === 0
|
||
? 'info'
|
||
: testResults.every((r) => r.success)
|
||
? 'success'
|
||
: testResults.some((r) => r.success)
|
||
? 'warning'
|
||
: 'error'
|
||
"
|
||
:message="testSummary"
|
||
show-icon
|
||
style="margin-bottom: 16px"
|
||
/>
|
||
|
||
<!-- 2. 发送详情(全局上下文) -->
|
||
<Descriptions
|
||
title="发送详情"
|
||
:column="1"
|
||
size="small"
|
||
bordered
|
||
style="margin-bottom: 16px"
|
||
>
|
||
<DescItem label="机器人">
|
||
{{ robotName || '-' }}
|
||
<span v-if="robotId" style="color: var(--ant-color-text-tertiary)">
|
||
(id: {{ robotId }})
|
||
</span>
|
||
</DescItem>
|
||
<DescItem label="平台">
|
||
<Tag v-if="platformCode" color="processing">{{ platformCode }}</Tag>
|
||
<span v-else>-</span>
|
||
</DescItem>
|
||
<DescItem label="发送时间">{{ formatTime(sentAt) }}</DescItem>
|
||
<DescItem label="测试内容">
|
||
<TypographyParagraph
|
||
style="margin: 0; white-space: pre-wrap; word-break: break-all"
|
||
>
|
||
{{ testContent || '-' }}
|
||
</TypographyParagraph>
|
||
</DescItem>
|
||
<DescItem label="@ 配置">{{ atSummary }}</DescItem>
|
||
</Descriptions>
|
||
|
||
<!-- 3. 各类型发送结果(含真实请求体 / 响应体) -->
|
||
<TypographyTitle :level="5" style="margin-bottom: 12px">
|
||
消息类型发送结果
|
||
</TypographyTitle>
|
||
<Timeline v-if="testResults.length > 0">
|
||
<TimelineItem
|
||
v-for="(item, idx) in testResults"
|
||
:key="`${item.message_type}-${idx}`"
|
||
:color="item.success ? 'green' : 'red'"
|
||
>
|
||
<!-- 标题行:名称 + 类型 Tag + 成功/失败 Tag + 耗时 -->
|
||
<div class="timeline-row">
|
||
<span class="timeline-name">{{ item.name }}</span>
|
||
<Tag class="timeline-type">{{ item.message_type }}</Tag>
|
||
<Tag :color="item.success ? 'success' : 'error'">
|
||
{{ item.success ? '成功' : '失败' }}
|
||
</Tag>
|
||
<span v-if="item.cost_ms" class="timeline-cost">
|
||
{{ item.cost_ms }}ms
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 渠道返回文案(成功 errmsg / 失败原因) -->
|
||
<div v-if="item.message" class="timeline-msg" :class="{ error: !item.success }">
|
||
{{ item.message }}
|
||
</div>
|
||
|
||
<!-- 真实请求体(渠道 buildPayload 结果,已脱敏) -->
|
||
<div class="payload-block">
|
||
<TypographyText type="secondary" class="payload-label">
|
||
请求体
|
||
</TypographyText>
|
||
<pre class="payload-pre">{{ formatJson(item.request) }}</pre>
|
||
</div>
|
||
|
||
<!-- 第三方响应体 -->
|
||
<div class="payload-block">
|
||
<TypographyText type="secondary" class="payload-label">
|
||
响应体
|
||
</TypographyText>
|
||
<pre class="payload-pre">{{ formatJson(item.response) }}</pre>
|
||
</div>
|
||
</TimelineItem>
|
||
</Timeline>
|
||
<TypographyText v-else type="secondary">
|
||
无发送结果
|
||
</TypographyText>
|
||
</Modal>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
/*
|
||
* 暗色适配:颜色一律用 antd CSS 变量
|
||
* pre 限高 + overflow,避免超长 JSON(尤其 image base64 截断前)撑爆弹窗
|
||
*/
|
||
.timeline-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.timeline-name {
|
||
font-weight: 500;
|
||
color: var(--ant-color-text, #1d2129);
|
||
}
|
||
|
||
.timeline-type {
|
||
margin: 0;
|
||
}
|
||
|
||
.timeline-cost {
|
||
color: var(--ant-color-text-tertiary, #86909c);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.timeline-msg {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
word-break: break-all;
|
||
color: var(--ant-color-text-secondary, #4e5969);
|
||
|
||
&.error {
|
||
color: var(--ant-color-error, #f53f3f);
|
||
}
|
||
}
|
||
|
||
.payload-block {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.payload-label {
|
||
display: block;
|
||
font-size: 12px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.payload-pre {
|
||
margin: 0;
|
||
padding: 8px 10px;
|
||
max-height: 180px;
|
||
overflow: auto;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||
background: var(--ant-color-fill-quaternary, rgba(0, 0, 0, 0.02));
|
||
color: var(--ant-color-text, #1d2129);
|
||
}
|
||
|
||
/* 暗色:强制浅色字,避免 CSS 变量未注入时落入 #1d2129 黑底黑字 */
|
||
html.dark .payload-pre,
|
||
.dark .payload-pre {
|
||
color: rgba(255, 255, 255, 0.88);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-color: rgba(255, 255, 255, 0.16);
|
||
}
|
||
|
||
html.dark .timeline-name,
|
||
.dark .timeline-name {
|
||
color: rgba(255, 255, 255, 0.88);
|
||
}
|
||
|
||
html.dark .timeline-msg:not(.error),
|
||
.dark .timeline-msg:not(.error) {
|
||
color: rgba(255, 255, 255, 0.65);
|
||
}
|
||
</style>
|