349 lines
10 KiB
Vue
349 lines
10 KiB
Vue
<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>
|