feat: 钉钉、企业微信webbook
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Checkbox,
|
||||
message,
|
||||
TypographyText,
|
||||
TypographyTitle,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getOaChatListByPlatform } from '#/views/system/oa-chat/api';
|
||||
import { updateOaRobotChatBind } from '#/views/system/oa-robot/api';
|
||||
|
||||
/**
|
||||
* OA 机器人「单独绑定群聊」独立弹窗
|
||||
*
|
||||
* 与编辑弹窗的区别:本弹窗只同步群聊绑定关系,不动其他字段
|
||||
* 入口场景:
|
||||
* - 列表「chat_names」列的「未绑定」link 按钮(专为未绑定机器人提供)
|
||||
* - 也可被列表操作列「绑定群聊」按钮复用(如有需要)
|
||||
*/
|
||||
interface ChatOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RobotRow {
|
||||
id: number;
|
||||
platform_code: string;
|
||||
name: string;
|
||||
chat_ids?: number[];
|
||||
}
|
||||
|
||||
/** 当前操作的机器人(由列表行传入) */
|
||||
const currentRobot = ref<RobotRow | null>(null);
|
||||
/** 按平台分组的群聊列表(结构:{ platform_code: [{ id, name }, ...] }) */
|
||||
const chatsByPlatform = ref<Record<string, ChatOption[]>>({});
|
||||
/** 当前已勾选的群聊 ID 列表(回显 + 提交用) */
|
||||
const selectedChatIds = ref<number[]>([]);
|
||||
/** 当前机器人所属平台(弹窗内只读展示,不允许跨平台改) */
|
||||
const currentPlatformCode = ref('');
|
||||
|
||||
/** 当前平台下可绑定的群聊列表 */
|
||||
const chatsOfCurrentPlatform = computed<ChatOption[]>(() => {
|
||||
return chatsByPlatform.value[currentPlatformCode.value] ?? [];
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
if (!currentRobot.value?.id) {
|
||||
message.warning('机器人信息缺失');
|
||||
return;
|
||||
}
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
try {
|
||||
await updateOaRobotChatBind({
|
||||
id: currentRobot.value.id,
|
||||
chat_ids: selectedChatIds.value,
|
||||
});
|
||||
message.success('绑定成功');
|
||||
const grid = modalApi.getData<{ gridApi?: any }>()?.gridApi;
|
||||
grid?.query?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<{ row: RobotRow; gridApi?: any }>();
|
||||
currentRobot.value = data?.row ?? null;
|
||||
// 回显已绑定的群聊
|
||||
const row = currentRobot.value;
|
||||
currentPlatformCode.value = String(row?.platform_code ?? '');
|
||||
selectedChatIds.value = Array.isArray(row?.chat_ids)
|
||||
? row!.chat_ids!.map((id) => Number(id))
|
||||
: [];
|
||||
// 动态设置标题,让用户看到正在为哪个机器人绑定
|
||||
if (row) {
|
||||
modalApi.setState({
|
||||
title: `绑定群聊 - ${row.name}`,
|
||||
confirmLoading: false,
|
||||
});
|
||||
}
|
||||
// 拉取群聊列表
|
||||
loadChatsByPlatform();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 拉取所有平台下的群聊列表(一次拉全量,前端按 platform_code 过滤)
|
||||
*/
|
||||
async function loadChatsByPlatform() {
|
||||
try {
|
||||
const data = await getOaChatListByPlatform();
|
||||
chatsByPlatform.value = data ?? {};
|
||||
} catch (e) {
|
||||
console.error('加载群聊列表失败', e);
|
||||
chatsByPlatform.value = {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="绑定群聊" class="w-[40%]">
|
||||
<!-- 提示:当前机器人所属平台 + 平台锁定说明(不允许跨平台改) -->
|
||||
<Alert
|
||||
type="info"
|
||||
show-icon
|
||||
message="平台已锁定,仅展示当前机器人所属平台下的群聊"
|
||||
:description="`机器人所属平台:${currentPlatformCode || '-'}(如需更换平台,请走「编辑」入口)`"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
|
||||
<!--
|
||||
绑定群聊:去掉自定义背景块改为简单分区(与 modal.vue 保持一致)
|
||||
全部走 antd 原生样式,暗色一致性由 antd 保证
|
||||
必须用具名导入 TypographyTitle/TypographyText,不要用 Typography.Title 命名空间写法
|
||||
-->
|
||||
<div class="chat-bind-section">
|
||||
<TypographyTitle :level="5" class="chat-bind-title">
|
||||
绑定群聊
|
||||
</TypographyTitle>
|
||||
<TypographyText type="secondary" class="chat-bind-hint">
|
||||
仅展示「{{ currentPlatformCode || '请先选平台' }}」平台下的群聊
|
||||
</TypographyText>
|
||||
<TypographyText
|
||||
v-if="chatsOfCurrentPlatform.length === 0"
|
||||
type="secondary"
|
||||
class="chat-empty"
|
||||
>
|
||||
当前平台下暂无群聊,可先到「OA群聊管理」中新增
|
||||
</TypographyText>
|
||||
<Checkbox.Group
|
||||
v-else
|
||||
v-model:value="selectedChatIds"
|
||||
class="chat-checkbox-group"
|
||||
>
|
||||
<Checkbox
|
||||
v-for="chat in chatsOfCurrentPlatform"
|
||||
:key="chat.id"
|
||||
:value="chat.id"
|
||||
>
|
||||
{{ chat.name }}
|
||||
</Checkbox>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/*
|
||||
* 暗色适配:去掉自定义背景块后,仅保留布局间距相关样式
|
||||
* 颜色全部由 antd 原生组件自动适配
|
||||
*/
|
||||
.chat-bind-section {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chat-bind-title {
|
||||
margin-bottom: 4px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.chat-bind-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.chat-checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
449
apps/web-antd/src/views/system/oa-robot/components/modal.vue
Normal file
449
apps/web-antd/src/views/system/oa-robot/components/modal.vue
Normal file
@@ -0,0 +1,449 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
message,
|
||||
Tag,
|
||||
TypographyText,
|
||||
TypographyTitle,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getOaChatListByPlatform } from '#/views/system/oa-chat/api';
|
||||
import { getOaPlatformList } from '#/views/system/oa-platform/api';
|
||||
import {
|
||||
createOaRobot,
|
||||
testSendOaRobot,
|
||||
updateOaRobot,
|
||||
} from '#/views/system/oa-robot/api';
|
||||
import {
|
||||
createFormSchema,
|
||||
editFormSchema,
|
||||
modalFormProps,
|
||||
} from '#/views/system/oa-robot/config/form';
|
||||
|
||||
/**
|
||||
* OA 机器人新增/编辑弹窗
|
||||
*
|
||||
* 关键交互:
|
||||
* - 打开弹窗时组件内直接调 getOaPlatformList,把 options 合并进 schema 后一次性 setState
|
||||
* (禁止事后 updateSchema,避免与 setState(schema) 竞态导致所属平台选项为空)
|
||||
* - 用 update 标志区分新增/编辑:新增走 createFormSchema(含 webhook/secret),编辑走 editFormSchema
|
||||
* - 「绑定群聊」复选框组:按当前选中的 platform_code 过滤,仅展示该平台下的群聊
|
||||
* - 弹窗内有「测试发送」按钮,循环跑该平台支持的所有消息类型
|
||||
*/
|
||||
|
||||
interface TestResultItem {
|
||||
message_type: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
response: any;
|
||||
cost_ms: number;
|
||||
}
|
||||
|
||||
interface ChatOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref();
|
||||
/** 测试发送按钮的 loading 状态(与底部「确定」的 confirmLoading 分离,避免互相干扰) */
|
||||
const testing = ref(false);
|
||||
/** 最近一次测试发送的结果列表(按消息类型分别展示) */
|
||||
const testResults = ref<TestResultItem[]>([]);
|
||||
/** 测试发送结果汇总提示 */
|
||||
const testSummary = ref('');
|
||||
/** 按平台分组的群聊列表(结构:{ platform_code: [{ id, name }, ...] }) */
|
||||
const chatsByPlatform = ref<Record<string, ChatOption[]>>({});
|
||||
/** 当前已勾选的群聊 ID 列表(编辑场景下回显用) */
|
||||
const selectedChatIds = ref<number[]>([]);
|
||||
/** 当前选中的平台编码(控制群聊列表过滤) */
|
||||
const currentPlatformCode = ref('');
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
// 合并群聊绑定数组到提交载荷(后端 OaRobotController::notRequest 已包含 chat_ids)
|
||||
const payload = { ...values, chat_ids: selectedChatIds.value };
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi = isUpdate.value ? updateOaRobot : createOaRobot;
|
||||
submitApi(payload)
|
||||
.then(() => {
|
||||
message.success('保存成功');
|
||||
gridApi.value?.reload();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
});
|
||||
}
|
||||
});
|
||||
await formApi.validateAndSubmitForm();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
// 必须用 update 标志区分,不能用 if (values):新增时 values={} 也是 truthy,会误走编辑分支
|
||||
const data = modalApi.getData<Record<string, any>>() ?? {};
|
||||
const values = data.values;
|
||||
isUpdate.value = !!data.update;
|
||||
// 组件内直接拉平台列表,合并进 schema 后再 setState,避免 updateSchema 竞态
|
||||
applySchemaWithPlatforms(isUpdate.value).then(() => {
|
||||
if (isUpdate.value && values && Object.keys(values).length > 0) {
|
||||
formApi.setValues(values);
|
||||
currentPlatformCode.value = String(values.platform_code ?? '');
|
||||
selectedChatIds.value = Array.isArray(values.chat_ids)
|
||||
? values.chat_ids.map((id: any) => Number(id))
|
||||
: [];
|
||||
} else {
|
||||
currentPlatformCode.value = '';
|
||||
selectedChatIds.value = [];
|
||||
}
|
||||
});
|
||||
loadChatsByPlatform();
|
||||
testResults.value = [];
|
||||
testSummary.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 当前平台下可绑定的群聊列表
|
||||
* 切换平台时自动过滤,仅展示该平台下的群聊
|
||||
*/
|
||||
const chatsOfCurrentPlatform = computed<ChatOption[]>(() => {
|
||||
return chatsByPlatform.value[currentPlatformCode.value] ?? [];
|
||||
});
|
||||
|
||||
/**
|
||||
* 平台切换处理:清理已不属于当前平台的勾选项(避免提交无效绑定)
|
||||
*/
|
||||
function handlePlatformChange(value: string) {
|
||||
currentPlatformCode.value = value;
|
||||
const allowedIds = new Set(chatsOfCurrentPlatform.value.map((c) => c.id));
|
||||
selectedChatIds.value = selectedChatIds.value.filter((id) =>
|
||||
allowedIds.has(id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件内直接调 getOaPlatformList,把平台选项合并进 schema 后一次性 setState
|
||||
* - 显示全部平台(含已禁用的,便于历史数据回显)
|
||||
* - 已禁用平台在 label 上加后缀并禁用选择
|
||||
* - 注入 onChange:点选平台时同步 currentPlatformCode 并过滤群聊勾选
|
||||
*
|
||||
* @param isEdit 是否编辑态(决定用 editFormSchema 还是 createFormSchema)
|
||||
*/
|
||||
async function applySchemaWithPlatforms(isEdit: boolean) {
|
||||
let options: { label: string; value: string; disabled: boolean }[] = [];
|
||||
try {
|
||||
const list = await getOaPlatformList();
|
||||
const rows: any[] = Array.isArray(list) ? list : list?.data || [];
|
||||
options = rows.map((item) => ({
|
||||
label: item.enabled === 1 ? item.name : `${item.name}(已禁用)`,
|
||||
value: item.platform_code,
|
||||
disabled: item.enabled !== 1,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('加载平台列表失败', e);
|
||||
}
|
||||
const base = isEdit ? editFormSchema : createFormSchema;
|
||||
// 深拷贝字段配置,避免污染导出的静态 schema;把 options/onChange 写进 platform_code
|
||||
const schema = base.map((field: any) => {
|
||||
if (field.fieldName !== 'platform_code') {
|
||||
return { ...field };
|
||||
}
|
||||
return {
|
||||
...field,
|
||||
componentProps: {
|
||||
...(field.componentProps || {}),
|
||||
options,
|
||||
// RadioGroup 的 change 事件(antd 原生):e.target.value 取选中值
|
||||
onChange: (e: any) => {
|
||||
const next = e?.target?.value ?? '';
|
||||
if (typeof next === 'string' && next !== '') {
|
||||
handlePlatformChange(next);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
formApi.setState({ schema });
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取所有平台下的群聊列表(一次拉全量,前端按 platform_code 过滤)
|
||||
*/
|
||||
async function loadChatsByPlatform() {
|
||||
try {
|
||||
const data = await getOaChatListByPlatform();
|
||||
chatsByPlatform.value = data ?? {};
|
||||
} catch (e) {
|
||||
console.error('加载群聊列表失败', e);
|
||||
chatsByPlatform.value = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试发送:循环跑该平台支持的所有消息类型
|
||||
*
|
||||
* 入参策略(与 webhook 入口分离后的新逻辑):
|
||||
* - 编辑态:webhook_url/secret 已不在表单中,直接用 row.id 走后端兜底取密钥
|
||||
* - 新增态:用表单填写的 webhook_url/secret 走(保存前验证密钥有效性)
|
||||
*
|
||||
* 单平台最多 8 种类型,需并行调用以缩短等待时间(约 10~20s)
|
||||
* 返回的 results 数组按消息类型分别展示成功/失败
|
||||
*/
|
||||
async function handleTestSend() {
|
||||
const values = await formApi.getValues();
|
||||
if (!values.platform_code) {
|
||||
message.warning('请先选择平台');
|
||||
return;
|
||||
}
|
||||
// 新增态校验 webhook_url;编辑态走 id 兜底,不需要表单 webhook_url
|
||||
if (!isUpdate.value && (!values.webhook_url || values.webhook_url === '****')) {
|
||||
message.warning('请填写 webhook 地址');
|
||||
return;
|
||||
}
|
||||
testing.value = true;
|
||||
// 进入测试态时清空旧结果,避免用户误以为是上次结果
|
||||
testResults.value = [];
|
||||
testSummary.value = '正在发送测试消息,请稍候(最多 8 种类型)...';
|
||||
try {
|
||||
const result = await testSendOaRobot(
|
||||
isUpdate.value
|
||||
? {
|
||||
// 编辑态:传 id,后端按 id 自动取已加密密钥
|
||||
id: values.id,
|
||||
platform_code: values.platform_code,
|
||||
test_content: '萧康云医 OA 测试消息',
|
||||
}
|
||||
: {
|
||||
// 新增态:传表单填写的 webhook_url/secret
|
||||
platform_code: values.platform_code,
|
||||
webhook_url: values.webhook_url,
|
||||
secret: values.secret || '',
|
||||
test_content: '萧康云医 OA 测试消息',
|
||||
},
|
||||
);
|
||||
// 兼容后端两种返回结构:单结果(旧)/ 多结果数组(新)
|
||||
if (Array.isArray(result?.results)) {
|
||||
testResults.value = result.results;
|
||||
const total = result.total ?? result.results.length;
|
||||
const succ = result.success_count ?? result.results.filter((r: any) => r.success).length;
|
||||
const fail = total - succ;
|
||||
testSummary.value = `共 ${total} 种类型,成功 ${succ} 种,失败 ${fail} 种`;
|
||||
if (fail === 0) {
|
||||
message.success(testSummary.value);
|
||||
} else if (succ === 0) {
|
||||
message.error(testSummary.value);
|
||||
} else {
|
||||
message.warning(testSummary.value);
|
||||
}
|
||||
} else if (result?.success !== undefined) {
|
||||
// 兼容老接口:单条结果包装为数组
|
||||
testResults.value = [
|
||||
{
|
||||
message_type: 'text',
|
||||
name: '文本消息',
|
||||
success: result.success,
|
||||
message: result.message || '',
|
||||
response: result.response,
|
||||
cost_ms: 0,
|
||||
},
|
||||
];
|
||||
testSummary.value = result.success ? '测试发送成功' : '测试发送失败';
|
||||
}
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate === true ? '编辑' : '新增'}机器人`"
|
||||
class="w-[40%]"
|
||||
>
|
||||
<Form />
|
||||
|
||||
<!--
|
||||
绑定群聊:去掉自定义背景块改为简单分区
|
||||
- 全部走 antd 原生样式,暗色模式下由 antd 自动保证对比度一致性
|
||||
- 标题用 TypographyTitle,提示用 TypographyText type="secondary"
|
||||
- 必须用具名导入(TypographyTitle/TypographyText),不要用 Typography.Title 命名空间写法
|
||||
(script setup 下命名空间子组件可能解析失败导致整块不渲染)
|
||||
-->
|
||||
<div class="chat-bind-section">
|
||||
<TypographyTitle :level="5" class="chat-bind-title">
|
||||
绑定群聊
|
||||
</TypographyTitle>
|
||||
<TypographyText type="secondary" class="chat-bind-hint">
|
||||
仅展示「{{ currentPlatformCode || '请先选平台' }}」平台下的群聊
|
||||
</TypographyText>
|
||||
<TypographyText
|
||||
v-if="chatsOfCurrentPlatform.length === 0"
|
||||
type="secondary"
|
||||
class="chat-empty"
|
||||
>
|
||||
当前平台下暂无群聊,可先到「OA群聊管理」中新增
|
||||
</TypographyText>
|
||||
<Checkbox.Group
|
||||
v-else
|
||||
v-model:value="selectedChatIds"
|
||||
class="chat-checkbox-group"
|
||||
>
|
||||
<Checkbox
|
||||
v-for="chat in chatsOfCurrentPlatform"
|
||||
:key="chat.id"
|
||||
:value="chat.id"
|
||||
>
|
||||
{{ chat.name }}
|
||||
</Checkbox>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
<!-- 测试发送结果展示区:仅在有过测试时显示 -->
|
||||
<div v-if="testResults.length > 0 || testSummary" class="test-results">
|
||||
<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: 12px"
|
||||
/>
|
||||
|
||||
<div v-if="testResults.length > 0" class="result-list">
|
||||
<div
|
||||
v-for="item in testResults"
|
||||
:key="item.message_type"
|
||||
class="result-item"
|
||||
>
|
||||
<div class="result-header">
|
||||
<Tag :color="item.success ? 'success' : 'error'">
|
||||
{{ item.success ? '成功' : '失败' }}
|
||||
</Tag>
|
||||
<span class="result-name">{{ item.name }}</span>
|
||||
<span class="result-type">({{ item.message_type }})</span>
|
||||
<span v-if="item.cost_ms" class="result-cost">{{ item.cost_ms }}ms</span>
|
||||
</div>
|
||||
<div v-if="!item.success && item.message" class="result-error">
|
||||
{{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试发送按钮塞在默认「取消/确定」按钮组的左侧 -->
|
||||
<template #prepend-footer>
|
||||
<Button type="default" :loading="testing" @click="handleTestSend">
|
||||
测试发送(全部类型)
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/*
|
||||
* 暗色适配:去掉自定义背景块后,仅保留布局间距相关样式
|
||||
* 颜色全部由 antd 原生组件(TypographyTitle / TypographyText / Checkbox)自动适配
|
||||
*/
|
||||
.chat-bind-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.chat-bind-title {
|
||||
/* 覆盖 TypographyTitle 默认大字号,让其更紧凑 */
|
||||
margin-bottom: 4px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.chat-bind-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.chat-checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--ant-color-border-secondary, #e5e6eb);
|
||||
}
|
||||
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--ant-color-fill-quaternary, #f7f8fa);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.result-type {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.result-cost {
|
||||
margin-left: auto;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
}
|
||||
|
||||
.result-error {
|
||||
margin-top: 4px;
|
||||
color: var(--ant-color-error, #f53f3f);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<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-[60%]">
|
||||
<!-- 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 in testResults"
|
||||
:key="item.message_type"
|
||||
: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);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Select, Switch, Textarea } from 'ant-design-vue';
|
||||
|
||||
import { testSendOaRobot } from '#/views/system/oa-robot/api';
|
||||
|
||||
/**
|
||||
* OA 机器人「行级测试发送」参数弹窗
|
||||
*
|
||||
* 与 test-result-modal.vue 配合:
|
||||
* - 本弹窗:负责输入测试参数(test_content、@all、mobiles)和发起发送
|
||||
* - 测试发送结果弹窗:发送完成后接管结果展示
|
||||
* - 拆分后两个弹窗职责单一:本弹窗专注参数输入,结果弹窗专注结果展示
|
||||
*
|
||||
* 数据流:
|
||||
* 1. index.vue 的 openTestModal 注入 onFinished 回调
|
||||
* 2. 用户点击「发送测试」→ 后端按平台循环跑所有消息类型
|
||||
* 3. 发送完成后调用 onFinished({ results, summary, robotName })
|
||||
* 4. 本弹窗关闭,index.vue 中 onFinished 打开 test-result-modal.vue
|
||||
*
|
||||
* 与表单弹窗内测试的区别:
|
||||
* - 表单弹窗内测试:用于「保存前验证密钥」(无 id,直接走 webhook_url/secret)
|
||||
* - 本弹窗(行级测试):用于「列表中已有机器人」的快速冒烟测试(有 id,后端自动取已加密密钥)
|
||||
*/
|
||||
|
||||
interface TestResultItem {
|
||||
message_type: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
response: any;
|
||||
cost_ms: number;
|
||||
}
|
||||
|
||||
interface RobotRow {
|
||||
id: number;
|
||||
platform_code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** onFinished 回调签名(由 index.vue 注入)
|
||||
*
|
||||
* payload 字段说明:
|
||||
* - results/summary:核心结果数据(必传)
|
||||
* - robotName/robotId/platformCode:机器人上下文(结果弹窗展示用)
|
||||
* - testContent/testAtAll/testMobiles:测试参数(结果弹窗「发送详情」展示用)
|
||||
* - sentAt:发送完成时间戳(秒),结果弹窗「发送时间」展示
|
||||
*/
|
||||
interface OnFinishedPayload {
|
||||
results: TestResultItem[];
|
||||
summary: string;
|
||||
robotName?: string;
|
||||
robotId?: number;
|
||||
platformCode?: string;
|
||||
testContent?: string;
|
||||
testAtAll?: boolean;
|
||||
testMobiles?: string[];
|
||||
sentAt?: number;
|
||||
}
|
||||
|
||||
/** 是否飞书平台(飞书 webhook 不支持手机号 @) */
|
||||
const isFeishu = computed(() => currentRobot.value?.platform_code === 'feishu');
|
||||
|
||||
/** 当前测试的机器人信息(由列表行传入) */
|
||||
const currentRobot = ref<RobotRow | null>(null);
|
||||
/** 测试发送 loading */
|
||||
const testing = ref(false);
|
||||
/** 测试内容(默认值预填,用户可改) */
|
||||
const testContent = ref('萧康云医 OA 测试消息');
|
||||
/** @ 所有人开关 */
|
||||
const testAtAll = ref(false);
|
||||
/** @ 手机号列表(antd Select mode='tags',允许用户输入任意手机号) */
|
||||
const testMobiles = ref<string[]>([]);
|
||||
/** onFinished 回调(由外部注入,发送完成后通知父级打开结果弹窗) */
|
||||
const onFinished = ref<((payload: OnFinishedPayload) => void) | null>(null);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
await handleTestSend();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<{
|
||||
row: RobotRow;
|
||||
onFinished?: (payload: OnFinishedPayload) => void;
|
||||
}>();
|
||||
currentRobot.value = data?.row ?? null;
|
||||
onFinished.value = data?.onFinished ?? null;
|
||||
// 每次打开都重置表单,避免上次状态残留
|
||||
testContent.value = '萧康云医 OA 测试消息';
|
||||
testAtAll.value = false;
|
||||
testMobiles.value = [];
|
||||
// 动态设置标题(让用户看到正在测试哪个机器人)
|
||||
if (currentRobot.value) {
|
||||
modalApi.setState({
|
||||
title: `测试发送 - ${currentRobot.value.name}`,
|
||||
confirmLoading: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 发送测试:调用后端 oa-robot/test-send 接口
|
||||
* 后端按 platform_code 循环跑该平台支持的所有消息类型,并注入 @ 配置
|
||||
* 发送完成后调用 onFinished 回调,关闭本弹窗,由父级打开结果弹窗
|
||||
*/
|
||||
async function handleTestSend() {
|
||||
if (!currentRobot.value?.id) {
|
||||
message.warning('机器人信息缺失');
|
||||
return;
|
||||
}
|
||||
if (!testContent.value.trim()) {
|
||||
message.warning('请填写测试内容');
|
||||
return;
|
||||
}
|
||||
testing.value = true;
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const result = await testSendOaRobot({
|
||||
id: currentRobot.value.id,
|
||||
platform_code: currentRobot.value.platform_code,
|
||||
// 行级测试有 id,后端会自动取已加密密钥;webhook_url/secret 不传,避免 mask 字符串覆盖
|
||||
test_content: testContent.value,
|
||||
test_at_all: testAtAll.value,
|
||||
test_mobiles: isFeishu.value ? [] : testMobiles.value,
|
||||
});
|
||||
|
||||
// 构造结果数据
|
||||
let results: TestResultItem[] = [];
|
||||
let summary = '';
|
||||
if (Array.isArray(result?.results)) {
|
||||
results = result.results;
|
||||
const total = result.total ?? result.results.length;
|
||||
const succ =
|
||||
result.success_count ??
|
||||
result.results.filter((r: any) => r.success).length;
|
||||
const fail = total - succ;
|
||||
summary = `共 ${total} 种类型,成功 ${succ} 种,失败 ${fail} 种`;
|
||||
} else if (result?.success !== undefined) {
|
||||
// 兜底兼容老接口结构
|
||||
results = [
|
||||
{
|
||||
message_type: 'text',
|
||||
name: '文本消息',
|
||||
success: result.success,
|
||||
message: result.message || '',
|
||||
response: result.response,
|
||||
cost_ms: 0,
|
||||
},
|
||||
];
|
||||
summary = result.success ? '测试发送成功' : '测试发送失败';
|
||||
}
|
||||
|
||||
// 发送完成:关闭本弹窗,通过 onFinished 回调打开结果弹窗
|
||||
modalApi.close();
|
||||
if (onFinished.value) {
|
||||
onFinished.value({
|
||||
results,
|
||||
summary,
|
||||
// 上下文字段:结果弹窗「发送详情」展示用
|
||||
robotName: currentRobot.value.name,
|
||||
robotId: currentRobot.value.id,
|
||||
platformCode: currentRobot.value.platform_code,
|
||||
testContent: testContent.value,
|
||||
testAtAll: testAtAll.value,
|
||||
testMobiles: isFeishu.value ? [] : testMobiles.value,
|
||||
// 发送完成时间戳(秒)
|
||||
sentAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
testing.value = false;
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="测试发送" class="w-[40%]">
|
||||
<!-- 测试参数表单(专注于参数输入,结果展示移交 test-result-modal.vue) -->
|
||||
<div class="test-form">
|
||||
<div class="form-item">
|
||||
<label class="form-label">测试内容</label>
|
||||
<Textarea
|
||||
v-model:value="testContent"
|
||||
:rows="2"
|
||||
placeholder="请输入测试内容(如:萧康云医 OA 测试消息)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">@ 所有人</label>
|
||||
<Switch v-model:checked="testAtAll" />
|
||||
<span class="form-hint">
|
||||
开启后所有消息类型都按 @ 全员发送
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!isFeishu" class="form-item">
|
||||
<label class="form-label">@ 手机号</label>
|
||||
<Select
|
||||
v-model:value="testMobiles"
|
||||
mode="tags"
|
||||
:disabled="testAtAll"
|
||||
placeholder="输入手机号后回车添加,可多个(@ 所有人开启时无效)"
|
||||
:token-separators="[',', ' ']"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
v-else
|
||||
type="info"
|
||||
show-icon
|
||||
message="飞书 webhook 不支持手机号 @,仅 @ 所有人有效"
|
||||
class="form-alert"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 暗色适配:全部走 antd CSS 变量 */
|
||||
.test-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-alert {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,348 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Alert, Button, InputPassword, message, Modal as AModal, Tag } from 'ant-design-vue';
|
||||
|
||||
import { testSendOaRobot, updateOaRobotWebhook } from '#/views/system/oa-robot/api';
|
||||
|
||||
/**
|
||||
* OA 机器人「单独编辑推送地址」独立弹窗
|
||||
*
|
||||
* 设计意图:把 webhook_url 和 secret 的修改从「编辑基础信息」中剥离,
|
||||
* 避免用户在编辑名称/备注时无意中触发 webhook 重新加密写入。
|
||||
*
|
||||
* 关键交互:
|
||||
* - 顶部 Alert 警示:修改后立即生效,影响所有绑定该机器人的 OA 场景
|
||||
* - webhook_url 必填(不允许 mask)
|
||||
* - secret 选填,placeholder 提示「留空 = 清空加签」
|
||||
* - 内置「测试发送」按钮:保存前先用新地址测一次,避免改错导致线上通知失效
|
||||
* - 提交前 Modal.confirm 二次确认,避免误操作
|
||||
*/
|
||||
|
||||
interface TestResultItem {
|
||||
message_type: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
response: any;
|
||||
cost_ms: number;
|
||||
}
|
||||
|
||||
interface RobotRow {
|
||||
id: number;
|
||||
platform_code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 当前操作的机器人(由列表行传入) */
|
||||
const currentRobot = ref<RobotRow | null>(null);
|
||||
/** webhook_url 输入值 */
|
||||
const webhookUrl = ref('');
|
||||
/** secret 输入值(空字符串表示清空) */
|
||||
const secret = ref('');
|
||||
/** 测试发送 loading */
|
||||
const testing = ref(false);
|
||||
/** 最近一次测试发送的结果列表 */
|
||||
const testResults = ref<TestResultItem[]>([]);
|
||||
/** 测试发送结果汇总提示 */
|
||||
const testSummary = ref('');
|
||||
|
||||
/**
|
||||
* 测试发送:用「当前填写」的 webhook_url/secret 临时验证(不依赖数据库)
|
||||
* 与列表「测试」按钮的区别:列表测试用库里已存的密钥;本弹窗用表单新值
|
||||
*/
|
||||
async function handleTestSend() {
|
||||
if (!currentRobot.value?.id) {
|
||||
message.warning('机器人信息缺失');
|
||||
return;
|
||||
}
|
||||
if (!currentRobot.value.platform_code) {
|
||||
message.warning('平台编码缺失');
|
||||
return;
|
||||
}
|
||||
if (!webhookUrl.value || webhookUrl.value === '****') {
|
||||
message.warning('请填写 webhook 地址');
|
||||
return;
|
||||
}
|
||||
testing.value = true;
|
||||
testResults.value = [];
|
||||
testSummary.value = '正在发送测试消息,请稍候(最多 8 种类型)...';
|
||||
try {
|
||||
const result = await testSendOaRobot({
|
||||
// 注意:这里不传 id,强制用表单新值(不走后端兜底取密钥)
|
||||
platform_code: currentRobot.value.platform_code,
|
||||
webhook_url: webhookUrl.value,
|
||||
secret: secret.value || '',
|
||||
test_content: '萧康云医 OA 测试消息',
|
||||
});
|
||||
if (Array.isArray(result?.results)) {
|
||||
testResults.value = result.results;
|
||||
const total = result.total ?? result.results.length;
|
||||
const succ =
|
||||
result.success_count ??
|
||||
result.results.filter((r: any) => r.success).length;
|
||||
const fail = total - succ;
|
||||
testSummary.value = `共 ${total} 种类型,成功 ${succ} 种,失败 ${fail} 种`;
|
||||
if (fail === 0) {
|
||||
message.success(testSummary.value);
|
||||
} else if (succ === 0) {
|
||||
message.error(testSummary.value);
|
||||
} else {
|
||||
message.warning(testSummary.value);
|
||||
}
|
||||
} else if (result?.success !== undefined) {
|
||||
testResults.value = [
|
||||
{
|
||||
message_type: 'text',
|
||||
name: '文本消息',
|
||||
success: result.success,
|
||||
message: result.message || '',
|
||||
response: result.response,
|
||||
cost_ms: 0,
|
||||
},
|
||||
];
|
||||
testSummary.value = result.success ? '测试发送成功' : '测试发送失败';
|
||||
}
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
if (!currentRobot.value?.id) {
|
||||
message.warning('机器人信息缺失');
|
||||
return;
|
||||
}
|
||||
if (!webhookUrl.value) {
|
||||
message.warning('请填写 Webhook 地址');
|
||||
return;
|
||||
}
|
||||
if (webhookUrl.value === '****') {
|
||||
message.warning('请填写完整的 Webhook 地址(不允许 mask)');
|
||||
return;
|
||||
}
|
||||
// secret 为 mask 时报错(必须重新输入完整密钥或留空清空)
|
||||
if (secret.value && secret.value === '****') {
|
||||
message.warning('请填写完整的加签密钥或留空以清空');
|
||||
return;
|
||||
}
|
||||
// 二次确认:避免误改导致线上 OA 通知失效
|
||||
AModal.confirm({
|
||||
title: '确认修改推送地址?',
|
||||
content:
|
||||
'修改后将立即生效,会影响所有绑定该机器人的 OA 通知场景。是否继续?',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
try {
|
||||
await updateOaRobotWebhook({
|
||||
id: currentRobot.value!.id,
|
||||
webhook_url: webhookUrl.value,
|
||||
secret: secret.value || '',
|
||||
});
|
||||
message.success('修改成功');
|
||||
const grid = modalApi.getData<{ gridApi?: any }>()?.gridApi;
|
||||
grid?.query?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<{ row: RobotRow; gridApi?: any }>();
|
||||
currentRobot.value = data?.row ?? null;
|
||||
// 每次打开都重置表单(不回显原 webhook/secret,强制用户重新输入,避免误操作)
|
||||
webhookUrl.value = '';
|
||||
secret.value = '';
|
||||
testResults.value = [];
|
||||
testSummary.value = '';
|
||||
if (currentRobot.value) {
|
||||
modalApi.setState({
|
||||
title: `编辑推送地址 - ${currentRobot.value.name}`,
|
||||
confirmLoading: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="编辑推送地址" class="w-[40%]">
|
||||
<!-- 顶部警示:修改后立即生效,影响线上 OA 通知 -->
|
||||
<Alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="修改 Webhook 地址后立即生效"
|
||||
description="会影响所有绑定该机器人的 OA 通知场景。建议先点「测试发送」验证新地址可用后再保存。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<!-- 表单:webhook_url 必填 + secret 选填 -->
|
||||
<div class="webhook-form">
|
||||
<div class="form-item">
|
||||
<label class="form-label">
|
||||
Webhook 地址 <span class="required-mark">*</span>
|
||||
</label>
|
||||
<InputPassword
|
||||
v-model:value="webhookUrl"
|
||||
placeholder="请粘贴新的 webhook URL"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="form-hint">
|
||||
为防止误操作,编辑时不回显原地址,需完整粘贴新地址
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">加签密钥</label>
|
||||
<InputPassword
|
||||
v-model:value="secret"
|
||||
placeholder="选填,留空表示清空加签密钥"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="form-hint">
|
||||
若原机器人有加签密钥,请重新输入完整密钥;留空则清空原密钥
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试结果展示区 -->
|
||||
<div v-if="testResults.length > 0 || testSummary" class="test-results">
|
||||
<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: 12px"
|
||||
/>
|
||||
|
||||
<div v-if="testResults.length > 0" class="result-list">
|
||||
<div
|
||||
v-for="item in testResults"
|
||||
:key="item.message_type"
|
||||
class="result-item"
|
||||
>
|
||||
<div class="result-header">
|
||||
<Tag :color="item.success ? 'success' : 'error'">
|
||||
{{ item.success ? '成功' : '失败' }}
|
||||
</Tag>
|
||||
<span class="result-name">{{ item.name }}</span>
|
||||
<span class="result-type">({{ item.message_type }})</span>
|
||||
<span v-if="item.cost_ms" class="result-cost">
|
||||
{{ item.cost_ms }}ms
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!item.success && item.message" class="result-error">
|
||||
{{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮:取消(默认)/ 测试发送(prepend)/ 确定修改(confirm) -->
|
||||
<template #prepend-footer>
|
||||
<Button type="default" :loading="testing" @click="handleTestSend">
|
||||
测试发送(全部类型)
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 暗色适配:全部走 antd CSS 变量 */
|
||||
.webhook-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: var(--ant-color-error, #f53f3f);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--ant-color-border-secondary, #e5e6eb);
|
||||
}
|
||||
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--ant-color-fill-quaternary, #f7f8fa);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: var(--ant-color-text, #1d2129);
|
||||
}
|
||||
|
||||
.result-type {
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.result-cost {
|
||||
margin-left: auto;
|
||||
color: var(--ant-color-text-tertiary, #86909c);
|
||||
}
|
||||
|
||||
.result-error {
|
||||
margin-top: 4px;
|
||||
color: var(--ant-color-error, #f53f3f);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user