feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互
This commit is contained in:
@@ -42,6 +42,14 @@ const pageSize = ref(24);
|
||||
const pageSizeOptions = ['12', '24', '48'];
|
||||
const selected = ref<string[]>([]);
|
||||
|
||||
/** 按 acceptTypes 隔离 localStorage,避免图片/视频选择器互相污染 */
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`file-picker-type-${
|
||||
props.acceptTypes?.length ? props.acceptTypes.join('-') : 'all'
|
||||
}`,
|
||||
);
|
||||
|
||||
const {
|
||||
activeType,
|
||||
keyword,
|
||||
@@ -50,12 +58,10 @@ const {
|
||||
buildListParams,
|
||||
fileTypes,
|
||||
typesLoading,
|
||||
} = useFileGalleryFilter('file-picker-active-type');
|
||||
} = useFileGalleryFilter(() => storageKey.value);
|
||||
|
||||
/**
|
||||
* 实际渲染的 tabs(按 acceptTypes 过滤)
|
||||
* - acceptTypes 为空:返回 tabs 原始值(含「全部」+ 所有类型)
|
||||
* - acceptTypes 非空:只保留匹配的类型 tab(不含「全部」,强制选具体类型)
|
||||
*/
|
||||
const visibleTabs = computed(() => {
|
||||
if (!props.acceptTypes || props.acceptTypes.length === 0) {
|
||||
@@ -67,30 +73,40 @@ const visibleTabs = computed(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* acceptTypes 变化时锁定到第一个匹配的类型(避免停留在「全部」导致看到非目标类型文件)
|
||||
* 强制锁定到 acceptTypes 首项(打开弹窗 / 类型字典就绪时调用)
|
||||
*/
|
||||
watch(
|
||||
() => props.acceptTypes,
|
||||
(arr) => {
|
||||
if (!arr || arr.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (arr.includes(activeType.value)) {
|
||||
return;
|
||||
}
|
||||
function lockAcceptType() {
|
||||
const arr = props.acceptTypes;
|
||||
if (!arr || arr.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (activeType.value !== arr[0]) {
|
||||
setActiveType(arr[0]!);
|
||||
page.value = 1;
|
||||
void load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/**
|
||||
* 是否允许拉列表:有 acceptTypes 时需等类型字典加载完且 visibleTabs 非空
|
||||
*/
|
||||
function canLoadList() {
|
||||
if (typesLoading.value) {
|
||||
return false;
|
||||
}
|
||||
if (props.acceptTypes && props.acceptTypes.length > 0) {
|
||||
return visibleTabs.value.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!canLoadList()) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getFileGalleryList(buildListParams(page.value, pageSize.value));
|
||||
const res = await getFileGalleryList(
|
||||
buildListParams(page.value, pageSize.value),
|
||||
);
|
||||
const data = (res as any)?.data ?? res;
|
||||
items.value = data?.items ?? [];
|
||||
total.value = data?.total ?? 0;
|
||||
@@ -99,21 +115,37 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.acceptTypes,
|
||||
(arr) => {
|
||||
if (!arr || arr.length === 0) {
|
||||
return;
|
||||
}
|
||||
lockAcceptType();
|
||||
page.value = 1;
|
||||
void load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
selected.value = [];
|
||||
page.value = 1;
|
||||
if (!typesLoading.value) {
|
||||
void load();
|
||||
}
|
||||
// 打开时再次按 acceptTypes 重锁,避免沿用其它选择器的视频 tab
|
||||
lockAcceptType();
|
||||
void load();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(typesLoading, (val) => {
|
||||
if (!val && props.open) {
|
||||
lockAcceptType();
|
||||
void load();
|
||||
}
|
||||
});
|
||||
@@ -184,6 +216,11 @@ function onSearch(value: string) {
|
||||
function itemIcon(item: FileGalleryItem) {
|
||||
return item.type_icon || resolveTypeIcon(item.type, fileTypes.value);
|
||||
}
|
||||
|
||||
/** 图片类型展示缩略图(xk_file_type:1=图片) */
|
||||
function isImageItem(item: FileGalleryItem) {
|
||||
return Number(item.type) === 1 || Number(item.type) === 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -217,7 +254,7 @@ function itemIcon(item: FileGalleryItem) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
<Spin :spinning="loading || typesLoading">
|
||||
<div v-if="items.length" class="gallery-grid">
|
||||
<div
|
||||
v-for="item in items"
|
||||
@@ -226,10 +263,17 @@ function itemIcon(item: FileGalleryItem) {
|
||||
:class="{ active: isSelected(item.url) }"
|
||||
@click="toggleSelect(item.url)"
|
||||
>
|
||||
<img v-if="item.type === 0" :src="item.url" alt="" class="gallery-thumb" />
|
||||
<img
|
||||
v-if="isImageItem(item)"
|
||||
:src="item.url"
|
||||
alt=""
|
||||
class="gallery-thumb"
|
||||
/>
|
||||
<div v-else class="gallery-file-icon">
|
||||
<MIcon :icon="itemIcon(item)" size="28" />
|
||||
<span class="file-name">{{ item.file_name || item.original_name || '未命名' }}</span>
|
||||
<span class="file-name">{{
|
||||
item.file_name || item.original_name || '未命名'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="gallery-meta">
|
||||
<div class="file-title">
|
||||
|
||||
@@ -24,12 +24,17 @@ interface Props {
|
||||
modelValue: string[];
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
/**
|
||||
* 素材库可选类型(透传 GalleryPickLink),默认仅图片
|
||||
*/
|
||||
acceptTypes?: number[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
acceptTypes: () => [1],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -184,6 +189,7 @@ const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.val
|
||||
v-if="showUploadButton"
|
||||
:multiple="multiple"
|
||||
:max-count="galleryRemainCount"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 文件上传(单文件 URL 字符串)
|
||||
* 可选:从素材库选择(acceptTypes 过滤,如文件=6、语音=3)
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { Button, Upload } from 'ant-design-vue';
|
||||
|
||||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
@@ -17,11 +23,17 @@ interface FileItem {
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
maxCount?: number;
|
||||
/** 素材库类型过滤,如 [6] 文件、[3] 语音 */
|
||||
acceptTypes?: number[];
|
||||
/** input accept,如 .pdf,.doc 或 audio/* */
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
maxCount: 1,
|
||||
acceptTypes: () => [6],
|
||||
accept: '.pdf,.doc,.docx,.zip,.rar,.txt,application/pdf,audio/*,*/*',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -131,26 +143,50 @@ function handleRemove() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function onGallerySelect(urls: string[]) {
|
||||
const url = urls[0] || '';
|
||||
if (!url) return;
|
||||
syncFileListFromValue(url);
|
||||
updateModelValue();
|
||||
}
|
||||
|
||||
const showUploadButton = computed(
|
||||
() => fileList.value.filter((item) => item.status !== 'error').length === 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Upload
|
||||
:file-list="fileList"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:max-count="maxCount"
|
||||
list-type="text"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,image/*,application/pdf"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<Button v-if="showUploadButton">
|
||||
上传文件
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:cloud-upload-outlined" />
|
||||
</template>
|
||||
</Button>
|
||||
</Upload>
|
||||
<div class="upload-oss-file-wrap">
|
||||
<Upload
|
||||
:file-list="fileList"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="customRequest"
|
||||
:max-count="maxCount"
|
||||
list-type="text"
|
||||
:accept="accept"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<Button v-if="showUploadButton">
|
||||
上传文件
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:cloud-upload-outlined" />
|
||||
</template>
|
||||
</Button>
|
||||
</Upload>
|
||||
<GalleryPickLink
|
||||
v-if="showUploadButton"
|
||||
:max-count="1"
|
||||
:accept-types="acceptTypes"
|
||||
@select="onGallerySelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-oss-file-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
import type { FileTypeItem } from '#/api/core/file-gallery';
|
||||
import { getFileTypes } from '#/api/core/file-gallery';
|
||||
import type { FileGroupOptionItem } from '#/api/core/file-group';
|
||||
import { useFileGroupCache } from '#/composables/use-file-group-cache';
|
||||
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
|
||||
|
||||
export const ALL_TYPE_VALUE = -1;
|
||||
|
||||
export const ALL_GROUP_VALUE = 0;
|
||||
|
||||
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
|
||||
|
||||
export interface FileGalleryTab {
|
||||
key: string;
|
||||
value: number;
|
||||
@@ -18,7 +17,13 @@ export interface FileGalleryTab {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export function useFileGalleryFilter(storageKey: string) {
|
||||
/**
|
||||
* 文件库筛选(类型 tab + 分组 + 关键字)
|
||||
* @param storageKeyInput localStorage 键;支持动态 getter,便于按 acceptTypes 隔离记忆
|
||||
*/
|
||||
export function useFileGalleryFilter(
|
||||
storageKeyInput: MaybeRefOrGetter<string> = 'file-picker-active-type',
|
||||
) {
|
||||
const fileTypes = ref<FileTypeItem[]>([]);
|
||||
const activeType = ref<number>(ALL_TYPE_VALUE);
|
||||
const activeGroupId = ref<number>(ALL_GROUP_VALUE);
|
||||
@@ -27,6 +32,10 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
|
||||
const { groupOptions, loadGroupOptions } = useFileGroupCache();
|
||||
|
||||
function resolveStorageKey() {
|
||||
return toValue(storageKeyInput);
|
||||
}
|
||||
|
||||
const tabs = computed<FileGalleryTab[]>(() => [
|
||||
{ key: 'all', value: ALL_TYPE_VALUE, label: '全部', icon: ALL_TYPE_ICON },
|
||||
...fileTypes.value.map((item) => ({
|
||||
@@ -48,7 +57,7 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
]);
|
||||
|
||||
function restoreActiveType() {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
const saved = localStorage.getItem(resolveStorageKey());
|
||||
if (saved === null) {
|
||||
activeType.value = ALL_TYPE_VALUE;
|
||||
return;
|
||||
@@ -64,7 +73,7 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
|
||||
function setActiveType(value: number) {
|
||||
activeType.value = value;
|
||||
localStorage.setItem(storageKey, String(value));
|
||||
localStorage.setItem(resolveStorageKey(), String(value));
|
||||
}
|
||||
|
||||
function setActiveGroupId(value: number) {
|
||||
|
||||
@@ -160,6 +160,8 @@ function platformName(code: string): string {
|
||||
*/
|
||||
function platformTip(code: string): string {
|
||||
if (code === 'work_wechat') return '企业微信 userid(在通讯录中查看,组织内唯一)';
|
||||
if (code === 'work_wechat_app')
|
||||
return '企业微信 userid(应用 API @ 群成员可选;员工 @ 主路径仍用手机号)';
|
||||
if (code === 'dingtalk') return '钉钉 userid(管理后台 → 通讯录 → 成员详情)';
|
||||
if (code === 'feishu') return '飞书 open_id(ou_ 开头)';
|
||||
return '该平台的账号标识';
|
||||
|
||||
@@ -60,3 +60,17 @@ export async function deleteOaChat(data: Record<string, any>) {
|
||||
export async function getOaChatListByPlatform() {
|
||||
return requestClient.get<any>(`${prefix}list-by-platform`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从企微 92120 同步客户群(按名称匹配本地记录)
|
||||
*/
|
||||
export async function syncOaCustomerGroups(data: { platform_code?: string } = {}) {
|
||||
return requestClient.post<any>(`${prefix}sync-customer-groups`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用企微 appchat/create 创建应用群并落库
|
||||
*/
|
||||
export async function createOaAppChat(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create-app-chat`, data);
|
||||
}
|
||||
|
||||
@@ -50,14 +50,37 @@ export const modalFormProps: VbenFormProps = {
|
||||
label: '群聊名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
// 1=应用群聊(实时 appchat) 2=客户群(群发需确认)
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '客户群', value: 2 },
|
||||
{ label: '应用群聊', value: 1 },
|
||||
],
|
||||
},
|
||||
defaultValue: 2,
|
||||
fieldName: 'chat_kind',
|
||||
label: '群类型',
|
||||
rules: 'selectRequired',
|
||||
help: '客户群走群发任务(需员工确认);应用群聊实时推送',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '选填,预留后期接入「获取群成员」接口时填入',
|
||||
placeholder: '企微 chat_id / 客户群 chat_id,同步后会自动回填',
|
||||
},
|
||||
fieldName: 'external_id',
|
||||
label: '平台原生群聊 ID',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '客户群群主 userid,群发时可作 sender 兜底',
|
||||
},
|
||||
fieldName: 'owner_userid',
|
||||
label: '群主 userid',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
|
||||
@@ -39,7 +39,15 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
slots: { default: 'platform_code' },
|
||||
},
|
||||
{ field: 'name', align: 'left', title: '群聊名称' },
|
||||
{ field: 'external_id', align: 'left', title: '外部群 ID', width: 200 },
|
||||
{
|
||||
field: 'chat_kind',
|
||||
align: 'left',
|
||||
title: '群类型',
|
||||
width: 110,
|
||||
slots: { default: 'chat_kind' },
|
||||
},
|
||||
{ field: 'external_id', align: 'left', title: '外部群 ID', width: 180 },
|
||||
{ field: 'owner_userid', align: 'left', title: '群主', width: 120 },
|
||||
{ field: 'sort', align: 'left', title: '排序', width: 80 },
|
||||
{
|
||||
field: 'status',
|
||||
@@ -70,7 +78,6 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'auto',
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
search: true,
|
||||
|
||||
@@ -1,31 +1,47 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* OA 群聊管理页
|
||||
* Tab:员工(组织架构)| 群聊;localStorage 记住上次选中的 Tab
|
||||
*/
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Tag } from 'ant-design-vue';
|
||||
import { message, Tabs, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { getOaPlatformList } from '#/views/system/oa-platform/api';
|
||||
|
||||
import { deleteOaChat } from './api';
|
||||
import { deleteOaChat, syncOaCustomerGroups } from './api';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import CreateAppChatModalDemo from './components/create-app-chat-modal.vue';
|
||||
import EmployeePanel from './components/employee-panel.vue';
|
||||
|
||||
/**
|
||||
* OA 群聊管理列表页
|
||||
* 顶部支持按名称/平台编码/状态搜索
|
||||
* 操作:新增、编辑、删除(单个/批量)
|
||||
*/
|
||||
defineOptions({ name: 'OaChat' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
/** Tab 记忆:employee=员工 chat=群聊 */
|
||||
const TAB_STORAGE_KEY = 'oa_chat_active_tab';
|
||||
const activeKey = ref('chat');
|
||||
|
||||
/** 平台编码 → 显示名映射,用于表格中渲染平台列 */
|
||||
function readStoredTab() {
|
||||
const stored = localStorage.getItem(TAB_STORAGE_KEY);
|
||||
if (stored === 'employee' || stored === 'chat') {
|
||||
activeKey.value = stored;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(key: string | number) {
|
||||
activeKey.value = String(key);
|
||||
localStorage.setItem(TAB_STORAGE_KEY, String(key));
|
||||
}
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const syncLoading = ref(false);
|
||||
const platformMap = ref<Record<string, any>>({});
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
@@ -49,9 +65,10 @@ const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModalDemo,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开新增/编辑弹窗
|
||||
*/
|
||||
const [CreateAppChatModal, createAppChatModalApi] = useVbenModal({
|
||||
connectedComponent: CreateAppChatModalDemo,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
values: data,
|
||||
@@ -61,9 +78,30 @@ const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
const showCreateAppChat = () => {
|
||||
createAppChatModalApi.setData({ gridApi });
|
||||
createAppChatModalApi.open();
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除群聊(支持单个和批量)
|
||||
* 同步企微客户群(92120)
|
||||
*/
|
||||
const handleSyncCustomerGroups = async () => {
|
||||
syncLoading.value = true;
|
||||
try {
|
||||
const res = await syncOaCustomerGroups({ platform_code: 'work_wechat_app' });
|
||||
const data = res?.data ?? res ?? {};
|
||||
message.success(
|
||||
`同步完成:匹配 ${data.matched ?? 0},新建 ${data.created ?? 0},更新 ${data.updated ?? 0},跳过 ${data.skipped ?? 0}`,
|
||||
);
|
||||
gridApi.query();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '同步失败');
|
||||
} finally {
|
||||
syncLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteApi = (row: any) => {
|
||||
let ids: any[] = [];
|
||||
if (row) {
|
||||
@@ -77,9 +115,6 @@ const deleteApi = (row: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载平台列表(用于表格中 platform_code 列展示成中文名)
|
||||
*/
|
||||
async function loadPlatforms() {
|
||||
try {
|
||||
const list = await getOaPlatformList();
|
||||
@@ -96,73 +131,100 @@ async function loadPlatforms() {
|
||||
}
|
||||
}
|
||||
|
||||
loadPlatforms();
|
||||
onMounted(() => {
|
||||
readStoredTab();
|
||||
loadPlatforms();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="OA 群聊管理">
|
||||
<Page title="OA 群聊管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: showModal.bind(null),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '批量删除',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
ifShow: hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, false),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<!-- 平台列:渲染成中文平台名 -->
|
||||
<template #platform_code="{ row }">
|
||||
<Tag v-if="platformMap[row.platform_code]" color="processing">
|
||||
{{ platformMap[row.platform_code].name }}
|
||||
</Tag>
|
||||
<span v-else>{{ row.platform_code }}</span>
|
||||
</template>
|
||||
<!-- 状态列 -->
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'success' : 'default'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
<CreateAppChatModal />
|
||||
<Tabs :active-key="activeKey" @change="handleTabChange">
|
||||
<Tabs.TabPane key="employee" tab="员工">
|
||||
<EmployeePanel v-if="activeKey === 'employee'" />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="chat" tab="群聊">
|
||||
<Grid v-if="activeKey === 'chat'">
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: showModal.bind(null),
|
||||
},
|
||||
{
|
||||
label: '同步客户群',
|
||||
type: 'default',
|
||||
icon: 'ant-design:cloud-sync-outlined',
|
||||
loading: syncLoading,
|
||||
onClick: handleSyncCustomerGroups,
|
||||
},
|
||||
{
|
||||
label: '创建应用群',
|
||||
type: 'default',
|
||||
icon: 'ant-design:usergroup-add-outlined',
|
||||
onClick: showCreateAppChat,
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '批量删除',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
ifShow: hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, false),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #platform_code="{ row }">
|
||||
<Tag v-if="platformMap[row.platform_code]" color="processing">
|
||||
{{ platformMap[row.platform_code].name }}
|
||||
</Tag>
|
||||
<span v-else>{{ row.platform_code }}</span>
|
||||
</template>
|
||||
<template #chat_kind="{ row }">
|
||||
<Tag :color="row.chat_kind === 1 ? 'blue' : 'orange'">
|
||||
{{ row.chat_kind === 1 ? '应用群聊' : '客户群' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'success' : 'default'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -22,3 +22,23 @@ export async function getOaPlatformList() {
|
||||
export async function updateOaPlatformEnabled(id: number, enabled: number) {
|
||||
return requestClient.post<any>(`${prefix}update-enabled`, { id, enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台凭证详情(解密明文)
|
||||
*/
|
||||
export async function getOaPlatformCredential(id: number) {
|
||||
return requestClient.get<any>(`${prefix}credential-detail`, { params: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新平台级应用凭证
|
||||
*/
|
||||
export async function updateOaPlatformCredential(data: {
|
||||
id: number;
|
||||
corp_id?: string;
|
||||
agent_id?: number;
|
||||
app_secret?: string;
|
||||
default_sender?: string;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}update-credential`, data);
|
||||
}
|
||||
|
||||
@@ -55,13 +55,36 @@ export async function testSendOaRobot(data: {
|
||||
platform_code: string;
|
||||
webhook_url?: string;
|
||||
secret?: string;
|
||||
corp_id?: string;
|
||||
chat_id?: string;
|
||||
agent_id?: number;
|
||||
use_platform_credential?: number;
|
||||
test_content?: string;
|
||||
test_at_all?: boolean;
|
||||
test_mobiles?: string[];
|
||||
test_userids?: string[];
|
||||
/** 应用 API:接收员工 userid */
|
||||
test_userid?: string;
|
||||
/** 应用 API:本地群聊 id(xk_oa_chat.id) */
|
||||
test_chat_id?: number;
|
||||
/** 应用 API:客户群确认发送人 */
|
||||
test_sender_userid?: string;
|
||||
/** 按绑定场景发送(只发场景配置的一种类型) */
|
||||
scene_id?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}test-send`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询机器人已绑定的场景列表(测试发送下拉)
|
||||
*/
|
||||
export async function getOaRobotBoundScenes(id: number) {
|
||||
return requestClient.get<{ id: number; scene_code: string; scene_name: string }[]>(
|
||||
`${prefix}list-bound-scenes`,
|
||||
{ params: { id } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅同步机器人-群聊绑定关系(用于「未绑定」link 入口和「绑定群聊」按钮)
|
||||
* 与 updateOaRobot 区分:本接口不动 name / status / remark 等基础信息
|
||||
@@ -75,9 +98,6 @@ export async function updateOaRobotChatBind(data: {
|
||||
|
||||
/**
|
||||
* 仅更新 webhook_url 和 secret(用于「改地址」入口)
|
||||
* 与 updateOaRobot 区分:本接口不动任何其他字段
|
||||
* - webhook_url 必填(非空、不允许 mask)
|
||||
* - secret 可选,传空字符串表示清空加签
|
||||
*/
|
||||
export async function updateOaRobotWebhook(data: {
|
||||
id: number;
|
||||
@@ -86,3 +106,50 @@ export async function updateOaRobotWebhook(data: {
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}update-webhook`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅更新应用 API 凭证(corp_id / secret / chat_id / agent_id / use_platform_credential)
|
||||
*/
|
||||
export async function updateOaRobotAppCredential(data: {
|
||||
id: number;
|
||||
corp_id?: string;
|
||||
secret?: string;
|
||||
chat_id: string;
|
||||
agent_id?: number;
|
||||
use_platform_credential?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}update-app-credential`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆机器人(复制平台与凭证策略,不复制场景绑定)
|
||||
*/
|
||||
export async function cloneOaRobot(data: { id: number; name?: string }) {
|
||||
return requestClient.post<any>(`${prefix}clone-robot`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按平台分组的机器人下拉(克隆源选择用)
|
||||
*/
|
||||
export async function getOaRobotGroupedByPlatform() {
|
||||
return requestClient.get<Record<string, { id: number; name: string }[]>>(
|
||||
`${prefix}list-grouped-by-platform`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动同步应用群聊成员
|
||||
*/
|
||||
export async function syncOaRobotChatMembers(data: { id: number }) {
|
||||
return requestClient.post<any>(`${prefix}sync-chat-members`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询已缓存群成员(单个 id 或批量 robot_ids)
|
||||
*/
|
||||
export async function getOaRobotChatMembers(params: {
|
||||
id?: number;
|
||||
robot_ids?: number[] | string;
|
||||
}) {
|
||||
return requestClient.get<any>(`${prefix}chat-members`, { params });
|
||||
}
|
||||
|
||||
@@ -18,12 +18,17 @@ import { getOaChatListByPlatform } from '#/views/system/oa-chat/api';
|
||||
import { getOaPlatformList } from '#/views/system/oa-platform/api';
|
||||
import {
|
||||
createOaRobot,
|
||||
getOaRobotGroupedByPlatform,
|
||||
getOaRobotInfo,
|
||||
syncOaRobotChatMembers,
|
||||
testSendOaRobot,
|
||||
updateOaRobot,
|
||||
} from '#/views/system/oa-robot/api';
|
||||
import {
|
||||
createFormSchema,
|
||||
editFormSchema,
|
||||
isUsePlatformCredential,
|
||||
isWorkWechatApp,
|
||||
modalFormProps,
|
||||
} from '#/views/system/oa-robot/config/form';
|
||||
|
||||
@@ -35,6 +40,7 @@ import {
|
||||
* (禁止事后 updateSchema,避免与 setState(schema) 竞态导致所属平台选项为空)
|
||||
* - 用 update 标志区分新增/编辑:新增走 createFormSchema(含 webhook/secret),编辑走 editFormSchema
|
||||
* - 「绑定群聊」复选框组:按当前选中的 platform_code 过滤,仅展示该平台下的群聊
|
||||
* - 应用 API:凭证来源 Radio(平台默认 / 自定义)+ 可选「克隆机器人」回填
|
||||
* - 弹窗内有「测试发送」按钮,循环跑该平台支持的所有消息类型
|
||||
*/
|
||||
|
||||
@@ -66,6 +72,19 @@ const chatsByPlatform = ref<Record<string, ChatOption[]>>({});
|
||||
const selectedChatIds = ref<number[]>([]);
|
||||
/** 当前选中的平台编码(控制群聊列表过滤) */
|
||||
const currentPlatformCode = ref('');
|
||||
/** 按平台分组的机器人列表(克隆源下拉用) */
|
||||
const robotsByPlatform = ref<Record<string, { id: number; name: string }[]>>(
|
||||
{},
|
||||
);
|
||||
/** 同步群成员 loading */
|
||||
const syncingMembers = ref(false);
|
||||
/** 最近一次同步的成员数量提示 */
|
||||
const memberSyncHint = ref('');
|
||||
|
||||
/** 应用 API 平台不展示 N:N 群聊绑定 */
|
||||
const showChatBind = computed(
|
||||
() => currentPlatformCode.value !== '' && !isWorkWechatApp(currentPlatformCode.value),
|
||||
);
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
@@ -111,9 +130,16 @@ const [Modal, modalApi] = useVbenModal({
|
||||
selectedChatIds.value = Array.isArray(values.chat_ids)
|
||||
? values.chat_ids.map((id: any) => Number(id))
|
||||
: [];
|
||||
const memberCount = Array.isArray(values.members)
|
||||
? values.members.length
|
||||
: 0;
|
||||
memberSyncHint.value = isWorkWechatApp(currentPlatformCode.value)
|
||||
? `已缓存群成员 ${memberCount} 人`
|
||||
: '';
|
||||
} else {
|
||||
currentPlatformCode.value = '';
|
||||
selectedChatIds.value = [];
|
||||
memberSyncHint.value = '';
|
||||
}
|
||||
});
|
||||
loadChatsByPlatform();
|
||||
@@ -140,6 +166,89 @@ function handlePlatformChange(value: string) {
|
||||
selectedChatIds.value = selectedChatIds.value.filter((id) =>
|
||||
allowedIds.has(id),
|
||||
);
|
||||
if (isWorkWechatApp(value)) {
|
||||
selectedChatIds.value = [];
|
||||
// 切换到应用 API 时刷新克隆源下拉(按平台过滤)
|
||||
refreshCloneOptions(value);
|
||||
} else {
|
||||
formApi.setValues({ clone_from_id: undefined });
|
||||
}
|
||||
memberSyncHint.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择克隆源:拉详情回填凭证策略与 chat_id(不覆盖用户已改的名称,除非名称为空)
|
||||
*/
|
||||
async function handleCloneFromChange(cloneId: number | undefined) {
|
||||
if (!cloneId) return;
|
||||
try {
|
||||
const detail = await getOaRobotInfo(Number(cloneId));
|
||||
if (!detail) return;
|
||||
const current = await formApi.getValues();
|
||||
formApi.setValues({
|
||||
platform_code: detail.platform_code,
|
||||
use_platform_credential: Number(detail.use_platform_credential ?? 1),
|
||||
corp_id: detail.corp_id || '',
|
||||
secret: detail.secret || '',
|
||||
agent_id: Number(detail.agent_id || 0) || undefined,
|
||||
chat_id: detail.chat_id || '',
|
||||
remark: detail.remark || '',
|
||||
// 名称:空则用「源名+副本」,已有内容不强制覆盖
|
||||
name:
|
||||
current.name && String(current.name).trim() !== ''
|
||||
? current.name
|
||||
: `${detail.name || '机器人'}副本`,
|
||||
clone_from_id: Number(cloneId),
|
||||
});
|
||||
currentPlatformCode.value = String(detail.platform_code ?? '');
|
||||
} catch (e) {
|
||||
console.error('加载克隆源失败', e);
|
||||
message.error('加载克隆源失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前平台刷新「克隆机器人」下拉 options
|
||||
*/
|
||||
function refreshCloneOptions(platformCode: string) {
|
||||
const options = (robotsByPlatform.value[platformCode] ?? []).map((r) => ({
|
||||
label: r.name,
|
||||
value: r.id,
|
||||
}));
|
||||
formApi.updateSchema([
|
||||
{
|
||||
fieldName: 'clone_from_id',
|
||||
componentProps: {
|
||||
options,
|
||||
placeholder: '可选,选择后回填凭证与群聊(不含强制改名)',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
optionFilterProp: 'label',
|
||||
onChange: (val: number | undefined) => {
|
||||
handleCloneFromChange(val);
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑态手动同步群成员(需已保存凭证)
|
||||
*/
|
||||
async function handleSyncMembers() {
|
||||
const values = await formApi.getValues();
|
||||
if (!values.id) {
|
||||
message.warning('请先保存机器人后再同步成员');
|
||||
return;
|
||||
}
|
||||
syncingMembers.value = true;
|
||||
try {
|
||||
const data = await syncOaRobotChatMembers({ id: Number(values.id) });
|
||||
memberSyncHint.value = `已同步 ${data?.member_count ?? 0} 人${data?.chat_name ? `(${data.chat_name})` : ''}`;
|
||||
message.success(memberSyncHint.value);
|
||||
} finally {
|
||||
syncingMembers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,26 +272,48 @@ async function applySchemaWithPlatforms(isEdit: boolean) {
|
||||
} catch (e) {
|
||||
console.error('加载平台列表失败', e);
|
||||
}
|
||||
// 新增态预拉克隆源列表(按平台分组)
|
||||
if (!isEdit) {
|
||||
try {
|
||||
const grouped = await getOaRobotGroupedByPlatform();
|
||||
robotsByPlatform.value = grouped ?? {};
|
||||
} catch (e) {
|
||||
console.error('加载机器人分组失败', e);
|
||||
robotsByPlatform.value = {};
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (field.fieldName === 'platform_code') {
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
if (field.fieldName === 'clone_from_id') {
|
||||
return {
|
||||
...field,
|
||||
componentProps: {
|
||||
...(field.componentProps || {}),
|
||||
options: [],
|
||||
onChange: (val: number | undefined) => {
|
||||
handleCloneFromChange(val);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ...field };
|
||||
});
|
||||
formApi.setState({ schema });
|
||||
}
|
||||
@@ -216,33 +347,55 @@ async function handleTestSend() {
|
||||
message.warning('请先选择平台');
|
||||
return;
|
||||
}
|
||||
// 新增态校验 webhook_url;编辑态走 id 兜底,不需要表单 webhook_url
|
||||
if (!isUpdate.value && (!values.webhook_url || values.webhook_url === '****')) {
|
||||
message.warning('请填写 webhook 地址');
|
||||
return;
|
||||
const isApp = isWorkWechatApp(values.platform_code);
|
||||
if (!isUpdate.value) {
|
||||
if (isApp) {
|
||||
if (!values.chat_id) {
|
||||
message.warning('请填写群聊 ID');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isUsePlatformCredential(values) &&
|
||||
(!values.corp_id || !values.secret || !values.agent_id)
|
||||
) {
|
||||
message.warning('自定义凭证请填写 corp_id、Secret、agent_id');
|
||||
return;
|
||||
}
|
||||
} else if (!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 测试消息',
|
||||
},
|
||||
: isApp
|
||||
? {
|
||||
platform_code: values.platform_code,
|
||||
use_platform_credential: Number(
|
||||
values.use_platform_credential ?? 1,
|
||||
),
|
||||
corp_id: values.corp_id || '',
|
||||
secret: values.secret || '',
|
||||
agent_id: Number(values.agent_id || 0),
|
||||
chat_id: values.chat_id,
|
||||
test_content: '萧康云医 OA 测试消息',
|
||||
}
|
||||
: {
|
||||
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;
|
||||
@@ -257,7 +410,6 @@ async function handleTestSend() {
|
||||
message.warning(testSummary.value);
|
||||
}
|
||||
} else if (result?.success !== undefined) {
|
||||
// 兼容老接口:单条结果包装为数组
|
||||
testResults.value = [
|
||||
{
|
||||
message_type: 'text',
|
||||
@@ -283,14 +435,8 @@ async function handleTestSend() {
|
||||
>
|
||||
<Form />
|
||||
|
||||
<!--
|
||||
绑定群聊:去掉自定义背景块改为简单分区
|
||||
- 全部走 antd 原生样式,暗色模式下由 antd 自动保证对比度一致性
|
||||
- 标题用 TypographyTitle,提示用 TypographyText type="secondary"
|
||||
- 必须用具名导入(TypographyTitle/TypographyText),不要用 Typography.Title 命名空间写法
|
||||
(script setup 下命名空间子组件可能解析失败导致整块不渲染)
|
||||
-->
|
||||
<div class="chat-bind-section">
|
||||
<!-- webhook 平台:N:N 绑定群聊;应用 API 目标群即 chat_id,不展示本区 -->
|
||||
<div v-if="showChatBind" class="chat-bind-section">
|
||||
<TypographyTitle :level="5" class="chat-bind-title">
|
||||
绑定群聊
|
||||
</TypographyTitle>
|
||||
@@ -319,6 +465,35 @@ async function handleTestSend() {
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
<!-- 应用 API:同步群成员入口(编辑态有 id 时可用) -->
|
||||
<div
|
||||
v-if="isWorkWechatApp(currentPlatformCode)"
|
||||
class="chat-bind-section"
|
||||
>
|
||||
<TypographyTitle :level="5" class="chat-bind-title">
|
||||
群成员
|
||||
</TypographyTitle>
|
||||
<TypographyText type="secondary" class="chat-bind-hint">
|
||||
保存凭证后可同步 appchat 成员,供测试发送与场景 @ 勾选
|
||||
</TypographyText>
|
||||
<div class="member-sync-row">
|
||||
<Button
|
||||
v-if="isUpdate"
|
||||
size="small"
|
||||
:loading="syncingMembers"
|
||||
@click="handleSyncMembers"
|
||||
>
|
||||
同步群成员
|
||||
</Button>
|
||||
<TypographyText v-if="memberSyncHint" type="secondary">
|
||||
{{ memberSyncHint }}
|
||||
</TypographyText>
|
||||
<TypographyText v-else-if="!isUpdate" type="secondary">
|
||||
首次保存后会自动同步成员
|
||||
</TypographyText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试发送结果展示区:仅在有过测试时显示 -->
|
||||
<div v-if="testResults.length > 0 || testSummary" class="test-results">
|
||||
<Alert
|
||||
@@ -358,8 +533,8 @@ async function handleTestSend() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试发送按钮塞在默认「取消/确定」按钮组的左侧 -->
|
||||
<template #prepend-footer>
|
||||
<!-- webhook 平台保留表单内测试;应用 API 请到列表「测试」独立弹窗选人/选群 -->
|
||||
<template v-if="!isWorkWechatApp(currentPlatformCode)" #prepend-footer>
|
||||
<Button type="default" :loading="testing" @click="handleTestSend">
|
||||
测试发送(全部类型)
|
||||
</Button>
|
||||
@@ -399,6 +574,13 @@ async function handleTestSend() {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.member-sync-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
|
||||
@@ -129,7 +129,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="测试发送结果" class="w-[60%]">
|
||||
<Modal title="测试发送结果" class="w-[56%]">
|
||||
<!-- 1. 汇总提示 -->
|
||||
<Alert
|
||||
v-if="testSummary"
|
||||
@@ -182,8 +182,8 @@ const [Modal, modalApi] = useVbenModal({
|
||||
</TypographyTitle>
|
||||
<Timeline v-if="testResults.length > 0">
|
||||
<TimelineItem
|
||||
v-for="item in testResults"
|
||||
:key="item.message_type"
|
||||
v-for="(item, idx) in testResults"
|
||||
:key="`${item.message_type}-${idx}`"
|
||||
:color="item.success ? 'green' : 'red'"
|
||||
>
|
||||
<!-- 标题行:名称 + 类型 Tag + 成功/失败 Tag + 耗时 -->
|
||||
@@ -288,4 +288,22 @@ const [Modal, modalApi] = useVbenModal({
|
||||
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>
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
/**
|
||||
* OA 机器人行级测试发送弹窗
|
||||
* work_wechat_app:必须选接收员工或群聊;客户群需再选确认发送员工
|
||||
* webhook:沿用 @ 全员 / 手机号
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Select, Switch, Textarea } from 'ant-design-vue';
|
||||
import { Alert, message, Radio, RadioGroup, 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,后端自动取已加密密钥)
|
||||
*/
|
||||
import { getOaChatListByPlatform } from '#/views/system/oa-chat/api';
|
||||
import { getOaWwUserList } from '#/views/system/oa-chat/api/org';
|
||||
import {
|
||||
getOaRobotBoundScenes,
|
||||
testSendOaRobot,
|
||||
} from '#/views/system/oa-robot/api';
|
||||
import { isWorkWechatApp } from '#/views/system/oa-robot/config/form';
|
||||
|
||||
interface TestResultItem {
|
||||
message_type: string;
|
||||
@@ -41,14 +33,6 @@ interface RobotRow {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** onFinished 回调签名(由 index.vue 注入)
|
||||
*
|
||||
* payload 字段说明:
|
||||
* - results/summary:核心结果数据(必传)
|
||||
* - robotName/robotId/platformCode:机器人上下文(结果弹窗展示用)
|
||||
* - testContent/testAtAll/testMobiles:测试参数(结果弹窗「发送详情」展示用)
|
||||
* - sentAt:发送完成时间戳(秒),结果弹窗「发送时间」展示
|
||||
*/
|
||||
interface OnFinishedPayload {
|
||||
results: TestResultItem[];
|
||||
summary: string;
|
||||
@@ -61,22 +45,37 @@ interface OnFinishedPayload {
|
||||
sentAt?: number;
|
||||
}
|
||||
|
||||
/** 是否飞书平台(飞书 webhook 不支持手机号 @) */
|
||||
const isFeishu = computed(() => currentRobot.value?.platform_code === 'feishu');
|
||||
const isApp = computed(() =>
|
||||
isWorkWechatApp(currentRobot.value?.platform_code),
|
||||
);
|
||||
|
||||
/** 当前测试的机器人信息(由列表行传入) */
|
||||
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 回调(由外部注入,发送完成后通知父级打开结果弹窗) */
|
||||
/** 应用 API:person | group */
|
||||
const targetMode = ref<'person' | 'group'>('person');
|
||||
const testUserid = ref<string | undefined>(undefined);
|
||||
const testChatId = ref<number | undefined>(undefined);
|
||||
const testSenderUserid = ref<string | undefined>(undefined);
|
||||
const userOptions = ref<{ label: string; value: string }[]>([]);
|
||||
const chatOptions = ref<
|
||||
{ label: string; value: number; chat_kind: number }[]
|
||||
>([]);
|
||||
/** 该机器人已绑定的场景(可选:选中则只发场景配置类型) */
|
||||
const boundScenes = ref<{ id: number; scene_code: string; scene_name: string }[]>(
|
||||
[],
|
||||
);
|
||||
const selectedSceneId = ref<number | undefined>(undefined);
|
||||
const onFinished = ref<((payload: OnFinishedPayload) => void) | null>(null);
|
||||
|
||||
const selectedChatKind = computed(() => {
|
||||
const hit = chatOptions.value.find((c) => c.value === testChatId.value);
|
||||
return hit?.chat_kind ?? 0;
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
@@ -94,26 +93,86 @@ const [Modal, modalApi] = useVbenModal({
|
||||
}>();
|
||||
currentRobot.value = data?.row ?? null;
|
||||
onFinished.value = data?.onFinished ?? null;
|
||||
// 每次打开都重置表单,避免上次状态残留
|
||||
testContent.value = '萧康云医 OA 测试消息';
|
||||
testAtAll.value = false;
|
||||
testMobiles.value = [];
|
||||
// 动态设置标题(让用户看到正在测试哪个机器人)
|
||||
targetMode.value = 'person';
|
||||
testUserid.value = undefined;
|
||||
testChatId.value = undefined;
|
||||
testSenderUserid.value = undefined;
|
||||
selectedSceneId.value = undefined;
|
||||
boundScenes.value = [];
|
||||
if (currentRobot.value) {
|
||||
modalApi.setState({
|
||||
title: `测试发送 - ${currentRobot.value.name}`,
|
||||
confirmLoading: false,
|
||||
});
|
||||
loadBoundScenes(currentRobot.value.id);
|
||||
if (isWorkWechatApp(currentRobot.value.platform_code)) {
|
||||
loadAppOptions();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 发送测试:调用后端 oa-robot/test-send 接口
|
||||
* 后端按 platform_code 循环跑该平台支持的所有消息类型,并注入 @ 配置
|
||||
* 发送完成后调用 onFinished 回调,关闭本弹窗,由父级打开结果弹窗
|
||||
*/
|
||||
watch(testAtAll, (val) => {
|
||||
if (val) {
|
||||
testMobiles.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
watch(targetMode, () => {
|
||||
testUserid.value = undefined;
|
||||
testChatId.value = undefined;
|
||||
testSenderUserid.value = undefined;
|
||||
});
|
||||
|
||||
async function loadBoundScenes(robotId: number) {
|
||||
try {
|
||||
const res = await getOaRobotBoundScenes(robotId);
|
||||
const list = Array.isArray(res) ? res : (res as any)?.data || [];
|
||||
boundScenes.value = (Array.isArray(list) ? list : []).map((s: any) => ({
|
||||
id: Number(s.id),
|
||||
scene_code: String(s.scene_code || ''),
|
||||
scene_name: String(s.scene_name || ''),
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
boundScenes.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppOptions() {
|
||||
try {
|
||||
const [userRes, chatRes] = await Promise.all([
|
||||
getOaWwUserList({
|
||||
platform_code: 'work_wechat_app',
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
}),
|
||||
getOaChatListByPlatform(),
|
||||
]);
|
||||
const userData = userRes?.data ?? userRes ?? {};
|
||||
const items = Array.isArray(userData.items) ? userData.items : [];
|
||||
userOptions.value = items.map((u: any) => ({
|
||||
label: u.name ? `${u.name}(${u.userid})` : u.userid,
|
||||
value: String(u.userid),
|
||||
}));
|
||||
const grouped = chatRes?.data ?? chatRes ?? {};
|
||||
const list = grouped.work_wechat_app || [];
|
||||
chatOptions.value = (Array.isArray(list) ? list : []).map((c: any) => ({
|
||||
label: `${c.name}${Number(c.chat_kind) === 2 ? '(客户群·需确认)' : '(应用群)'}`,
|
||||
value: Number(c.id),
|
||||
chat_kind: Number(c.chat_kind ?? 2),
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
userOptions.value = [];
|
||||
chatOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestSend() {
|
||||
if (!currentRobot.value?.id) {
|
||||
message.warning('机器人信息缺失');
|
||||
@@ -123,19 +182,52 @@ async function handleTestSend() {
|
||||
message.warning('请填写测试内容');
|
||||
return;
|
||||
}
|
||||
if (isApp.value) {
|
||||
// 选了场景时后端优先用场景 targets,可不强制弹窗选人/选群
|
||||
if (!selectedSceneId.value) {
|
||||
if (targetMode.value === 'person' && !testUserid.value) {
|
||||
message.warning('请选择接收员工');
|
||||
return;
|
||||
}
|
||||
if (targetMode.value === 'group' && !testChatId.value) {
|
||||
message.warning('请选择接收群聊');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
targetMode.value === 'group' &&
|
||||
selectedChatKind.value === 2 &&
|
||||
!testSenderUserid.value
|
||||
) {
|
||||
message.warning('客户群请选择确认发送员工');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
testing.value = true;
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const result = await testSendOaRobot({
|
||||
const payload: Record<string, any> = {
|
||||
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,
|
||||
});
|
||||
};
|
||||
if (selectedSceneId.value) {
|
||||
payload.scene_id = selectedSceneId.value;
|
||||
}
|
||||
if (isApp.value) {
|
||||
if (targetMode.value === 'person') {
|
||||
payload.test_userid = testUserid.value;
|
||||
} else {
|
||||
payload.test_chat_id = testChatId.value;
|
||||
if (selectedChatKind.value === 2) {
|
||||
payload.test_sender_userid = testSenderUserid.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await testSendOaRobot(payload);
|
||||
|
||||
// 构造结果数据
|
||||
let results: TestResultItem[] = [];
|
||||
let summary = '';
|
||||
if (Array.isArray(result?.results)) {
|
||||
@@ -145,9 +237,10 @@ async function handleTestSend() {
|
||||
result.success_count ??
|
||||
result.results.filter((r: any) => r.success).length;
|
||||
const fail = total - succ;
|
||||
summary = `共 ${total} 种类型,成功 ${succ} 种,失败 ${fail} 种`;
|
||||
summary = selectedSceneId.value
|
||||
? `按场景发送:共 ${total} 条,成功 ${succ},失败 ${fail}`
|
||||
: `共 ${total} 种类型,成功 ${succ} 种,失败 ${fail} 种`;
|
||||
} else if (result?.success !== undefined) {
|
||||
// 兜底兼容老接口结构
|
||||
results = [
|
||||
{
|
||||
message_type: 'text',
|
||||
@@ -161,20 +254,17 @@ async function handleTestSend() {
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -186,8 +276,7 @@ async function handleTestSend() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="测试发送" class="w-[40%]">
|
||||
<!-- 测试参数表单(专注于参数输入,结果展示移交 test-result-modal.vue) -->
|
||||
<Modal title="测试发送" class="w-[42%]">
|
||||
<div class="test-form">
|
||||
<div class="form-item">
|
||||
<label class="form-label">测试内容</label>
|
||||
@@ -198,62 +287,151 @@ async function handleTestSend() {
|
||||
/>
|
||||
</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>
|
||||
<!-- 绑定场景:选中后只发场景配置的消息类型,不循环全类型 -->
|
||||
<div v-if="boundScenes.length > 0" class="form-item">
|
||||
<label class="form-label">按场景发送(可选)</label>
|
||||
<Select
|
||||
v-model:value="testMobiles"
|
||||
mode="tags"
|
||||
:disabled="testAtAll"
|
||||
placeholder="输入手机号后回车添加,可多个(@ 所有人开启时无效)"
|
||||
:token-separators="[',', ' ']"
|
||||
v-model:value="selectedSceneId"
|
||||
allow-clear
|
||||
show-search
|
||||
class="w-full"
|
||||
placeholder="不选则测试该平台全部消息类型"
|
||||
:options="
|
||||
boundScenes.map((s) => ({
|
||||
label: `${s.scene_name}(${s.scene_code})`,
|
||||
value: s.id,
|
||||
}))
|
||||
"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
String(option?.label || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
"
|
||||
/>
|
||||
<div v-if="selectedSceneId" class="form-hint">
|
||||
将只发送该场景已配置的消息类型与内容(可用上方测试内容覆盖正文)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
v-else
|
||||
type="info"
|
||||
show-icon
|
||||
message="飞书 webhook 不支持手机号 @,仅 @ 所有人有效"
|
||||
class="form-alert"
|
||||
/>
|
||||
<!-- 应用 API:必选投递目标(选场景时可作兜底) -->
|
||||
<template v-if="isApp && !selectedSceneId">
|
||||
<div class="form-item">
|
||||
<label class="form-label">接收对象</label>
|
||||
<RadioGroup v-model:value="targetMode">
|
||||
<Radio value="person">员工(个人消息)</Radio>
|
||||
<Radio value="group">群聊</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div v-if="targetMode === 'person'" class="form-item">
|
||||
<label class="form-label">接收员工</label>
|
||||
<Select
|
||||
v-model:value="testUserid"
|
||||
show-search
|
||||
allow-clear
|
||||
:options="userOptions"
|
||||
placeholder="选择企微员工 userid"
|
||||
class="w-full"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
String(option?.label || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="form-item">
|
||||
<label class="form-label">接收群聊</label>
|
||||
<Select
|
||||
v-model:value="testChatId"
|
||||
show-search
|
||||
allow-clear
|
||||
:options="chatOptions"
|
||||
placeholder="选择群聊"
|
||||
class="w-full"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
String(option?.label || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="targetMode === 'group' && selectedChatKind === 2" class="form-item">
|
||||
<label class="form-label">确认发送员工</label>
|
||||
<Select
|
||||
v-model:value="testSenderUserid"
|
||||
show-search
|
||||
allow-clear
|
||||
:options="userOptions"
|
||||
placeholder="客户群群发需员工在企微确认"
|
||||
class="w-full"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
String(option?.label || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
"
|
||||
/>
|
||||
<span class="form-hint">对应企微 add_msg_template.sender,非实时</span>
|
||||
</div>
|
||||
<Alert
|
||||
type="info"
|
||||
show-icon
|
||||
message="应用 API 测试请在本弹窗选择接收对象;保存机器人仅配置凭证"
|
||||
class="form-alert"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<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-if="isFeishu"
|
||||
type="info"
|
||||
show-icon
|
||||
message="飞书 webhook 不支持手机号 @,仅 @ 所有人有效"
|
||||
class="form-alert"
|
||||
/>
|
||||
</template>
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
/**
|
||||
* 机器人表单 Schema 拆分说明(基础信息与 webhook 编辑入口分离)
|
||||
* 机器人表单 Schema 拆分说明
|
||||
*
|
||||
* - createFormSchema:新增机器人用,包含 webhook_url、secret(创建时必填 webhook)
|
||||
* - editFormSchema:编辑基础信息用,**不含** webhook_url 和 secret(这两个字段由「改地址」入口单独管理)
|
||||
* - createFormSchema:新增用,按 platform_code / use_platform_credential 切换字段
|
||||
* - editFormSchema:编辑基础信息(不含密钥);应用 API 可改 chat_id
|
||||
*
|
||||
* 这样能保证:
|
||||
* - 编辑基础信息时不会无意中触发 webhook 重新加密写入
|
||||
* - 老前端代码即便误传 webhook_url/secret,也会被后端 BaseController::checkRequiredFields 过滤
|
||||
* 注意:secret 字段全局唯一(webhook 加签与应用 Secret 复用同一 fieldName)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 通用基础字段(id、platform_code、name、status、remark)
|
||||
* 新增和编辑都包含这些字段
|
||||
*/
|
||||
/** 是否应用 API 平台 */
|
||||
export function isWorkWechatApp(platformCode?: string) {
|
||||
return platformCode === 'work_wechat_app';
|
||||
}
|
||||
|
||||
/** 是否使用平台默认凭证(1=是) */
|
||||
export function isUsePlatformCredential(values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) {
|
||||
return (
|
||||
isWorkWechatApp(values.platform_code) &&
|
||||
Number(values.use_platform_credential ?? 1) === 1
|
||||
);
|
||||
}
|
||||
|
||||
/** 是否展示自定义凭证字段(应用 API + 自定义) */
|
||||
export function showCustomAppCredential(values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) {
|
||||
return (
|
||||
isWorkWechatApp(values.platform_code) &&
|
||||
Number(values.use_platform_credential ?? 1) === 0
|
||||
);
|
||||
}
|
||||
|
||||
const baseSchema = [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
@@ -27,7 +48,6 @@ const baseSchema = [
|
||||
},
|
||||
},
|
||||
{
|
||||
// 选项由弹窗组件 onOpenChange 时拉取接口后合并进 schema 再 setState
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [],
|
||||
@@ -58,8 +78,6 @@ const baseSchema = [
|
||||
label: '状态',
|
||||
},
|
||||
{
|
||||
// 注意:项目内 textarea 的 schema 组件名是 'Textarea'(antd 原生组件直接注册)
|
||||
// 不是 'VbenTextarea'(不存在的组件名,会导致字段无法渲染)
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '可选,备注说明(如:财务群-企业微信)',
|
||||
@@ -70,12 +88,45 @@ const baseSchema = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Webhook / Secret 字段(仅新增时使用)
|
||||
* - webhook_url:始终必填
|
||||
* - secret:钉钉必填(加签模式),企微/飞书选填(通过 dependencies 按 platform_code 动态校验)
|
||||
*/
|
||||
const webhookSchema = [
|
||||
/** 新增时的凭证字段(按平台 + 凭证策略切换展示) */
|
||||
const createCredentialSchema = [
|
||||
{
|
||||
// 仅新增态展示:选择同平台已有机器人,回填凭证策略与 chat_id 等
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '可选,选择后回填凭证与群聊(不含名称)',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
optionFilterProp: 'label',
|
||||
options: [],
|
||||
},
|
||||
fieldName: 'clone_from_id',
|
||||
label: '克隆机器人',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code'],
|
||||
show: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '使用平台默认凭证', value: 1 },
|
||||
{ label: '自定义凭证', value: 0 },
|
||||
],
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'use_platform_credential',
|
||||
label: '凭证来源',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code'],
|
||||
show: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code),
|
||||
rules: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code) ? 'selectRequired' : null,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
@@ -84,36 +135,122 @@ const webhookSchema = [
|
||||
},
|
||||
fieldName: 'webhook_url',
|
||||
label: 'Webhook 地址',
|
||||
rules: 'required',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code'],
|
||||
show: (values: { platform_code?: string }) =>
|
||||
!isWorkWechatApp(values.platform_code),
|
||||
rules: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code) ? null : 'required',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '企业微信企业 ID(corpid)',
|
||||
autocomplete: 'off',
|
||||
},
|
||||
fieldName: 'corp_id',
|
||||
label: '企业 ID',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code', 'use_platform_credential'],
|
||||
show: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => showCustomAppCredential(values),
|
||||
rules: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => (showCustomAppCredential(values) ? 'required' : null),
|
||||
},
|
||||
},
|
||||
{
|
||||
// webhook 加签密钥 / 应用 Secret 共用 fieldName=secret
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: '钉钉必填;企微/飞书选填',
|
||||
placeholder: '钉钉/应用API自定义必填;企微 webhook、飞书选填',
|
||||
autocomplete: 'new-password',
|
||||
},
|
||||
fieldName: 'secret',
|
||||
label: '加签密钥',
|
||||
// 钉钉自定义机器人加签模式必须有 secret;其他平台可空
|
||||
label: '密钥',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code', 'use_platform_credential'],
|
||||
show: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => {
|
||||
if (isWorkWechatApp(values.platform_code)) {
|
||||
return showCustomAppCredential(values);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
rules: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => {
|
||||
if (showCustomAppCredential(values)) return 'required';
|
||||
if (values.platform_code === 'dingtalk') return 'required';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '企业应用 agentid',
|
||||
min: 1,
|
||||
class: 'w-full',
|
||||
},
|
||||
fieldName: 'agent_id',
|
||||
label: '应用 AgentId',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code', 'use_platform_credential'],
|
||||
show: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => showCustomAppCredential(values),
|
||||
rules: (values: {
|
||||
platform_code?: string;
|
||||
use_platform_credential?: number;
|
||||
}) => (showCustomAppCredential(values) ? 'required' : null),
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '可选;兼容旧数据。新流程请在场景中选群',
|
||||
autocomplete: 'off',
|
||||
},
|
||||
fieldName: 'chat_id',
|
||||
label: '默认群聊 ID',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code'],
|
||||
rules: (values: { platform_code?: string }) => {
|
||||
return values.platform_code === 'dingtalk' ? 'required' : null;
|
||||
},
|
||||
show: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 新增机器人 schema(含 webhook_url、secret) */
|
||||
export const createFormSchema = [...baseSchema, ...webhookSchema];
|
||||
/** 编辑态可改 chat_id(应用 API);改凭证走独立弹窗 */
|
||||
const editAppChatSchema = [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '可选;场景选群为主。填写后可同步成员',
|
||||
autocomplete: 'off',
|
||||
},
|
||||
fieldName: 'chat_id',
|
||||
label: '默认群聊 ID',
|
||||
dependencies: {
|
||||
triggerFields: ['platform_code'],
|
||||
show: (values: { platform_code?: string }) =>
|
||||
isWorkWechatApp(values.platform_code),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 编辑机器人 schema(仅基础信息,不含 webhook_url、secret) */
|
||||
export const editFormSchema = [...baseSchema];
|
||||
export const createFormSchema = [...baseSchema, ...createCredentialSchema];
|
||||
export const editFormSchema = [...baseSchema, ...editAppChatSchema];
|
||||
|
||||
/**
|
||||
* 新增机器人的默认表单配置(向后兼容:modal.vue 在新增分支下用此配置)
|
||||
* 编辑分支由 modal.vue 在 onOpenChange 中动态切换 schema
|
||||
*/
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
|
||||
@@ -36,13 +36,13 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'platform_code', align: 'left', title: '平台', width: 120, slots: { default: 'platform_code' } },
|
||||
{ field: 'name', align: 'left', title: '机器人名称' },
|
||||
{ field: 'chat_names', align: 'left', title: '绑定群聊', slots: { default: 'chat_names' } },
|
||||
{ field: 'webhook_url', align: 'left', title: 'Webhook', width: 120, slots: { default: 'webhook_status' } },
|
||||
{ field: 'secret', align: 'left', title: '加签密钥', width: 120, slots: { default: 'secret_status' } },
|
||||
{ field: 'chat_names', align: 'left', title: '绑定群聊/群ID', slots: { default: 'chat_names' } },
|
||||
{ field: 'webhook_url', align: 'left', title: '凭证', width: 130, slots: { default: 'webhook_status' } },
|
||||
{ field: 'secret', align: 'left', title: '密钥', width: 100, slots: { default: 'secret_status' } },
|
||||
{ field: 'status', align: 'left', title: '状态', width: 100, slots: { default: 'status' } },
|
||||
{ field: 'remark', align: 'left', title: '备注' },
|
||||
{ field: 'created_at', align: 'left', title: '创建时间', width: 180 },
|
||||
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 300, fixed: 'right' },
|
||||
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 320, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
|
||||
@@ -17,8 +17,11 @@ import ChatBindModal from './components/chat-bind-modal.vue';
|
||||
import TestSendModal from './components/test-send-modal.vue';
|
||||
import TestResultModal from './components/test-result-modal.vue';
|
||||
import WebhookModal from './components/webhook-modal.vue';
|
||||
import AppCredentialModal from './components/app-credential-modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import { isWorkWechatApp } from './config/form';
|
||||
import { getOaRobotInfo } from './api';
|
||||
|
||||
/**
|
||||
* OA 机器人管理列表页
|
||||
@@ -69,12 +72,26 @@ const [ChatBindEditModal, chatBindModalApi] = useVbenModal({
|
||||
connectedComponent: ChatBindModal,
|
||||
});
|
||||
|
||||
const [AppCredentialEditModal, appCredentialModalApi] = useVbenModal({
|
||||
connectedComponent: AppCredentialModal,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开新增/编辑弹窗
|
||||
* 编辑时拉详情(含 corp_id/chat_id/members),避免列表 mask/缺字段
|
||||
*/
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
const showModal = async (data: any = {}, isUpdate = false) => {
|
||||
let values = data;
|
||||
if (isUpdate && data?.id) {
|
||||
try {
|
||||
values = await getOaRobotInfo(data.id);
|
||||
} catch (e) {
|
||||
console.error('加载机器人详情失败', e);
|
||||
values = data;
|
||||
}
|
||||
}
|
||||
formModalApi.setData({
|
||||
values: data,
|
||||
values,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
});
|
||||
@@ -113,6 +130,20 @@ const openWebhookModal = (row: any) => {
|
||||
webhookModalApi.open();
|
||||
};
|
||||
|
||||
/** 打开应用 API「改凭证」弹窗(拉详情拿 agent_id / use_platform_credential) */
|
||||
const openAppCredentialModal = async (row: any) => {
|
||||
let detail = row;
|
||||
if (row?.id) {
|
||||
try {
|
||||
detail = await getOaRobotInfo(row.id);
|
||||
} catch (e) {
|
||||
console.error('加载机器人凭证详情失败', e);
|
||||
}
|
||||
}
|
||||
appCredentialModalApi.setData({ row: detail, gridApi });
|
||||
appCredentialModalApi.open();
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开「绑定群聊」独立弹窗
|
||||
* 入口:列表「chat_names」列的「未绑定」link 按钮(也支持手动调用)
|
||||
@@ -167,6 +198,7 @@ loadPlatforms();
|
||||
<TestModal />
|
||||
<TestResultEditModal />
|
||||
<WebhookEditModal />
|
||||
<AppCredentialEditModal />
|
||||
<ChatBindEditModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
@@ -205,7 +237,11 @@ loadPlatforms();
|
||||
- 未绑定:显示可点击的「未绑定」link 按钮,点击后打开独立绑定弹窗
|
||||
-->
|
||||
<template #chat_names="{ row }">
|
||||
<span v-if="row.chat_names">{{ row.chat_names }}</span>
|
||||
<!-- 应用 API:展示 chatid,不走 N:N 绑定弹窗 -->
|
||||
<span v-if="isWorkWechatApp(row.platform_code)">
|
||||
{{ row.chat_id || row.chat_names || '未配置群聊' }}
|
||||
</span>
|
||||
<span v-else-if="row.chat_names">{{ row.chat_names }}</span>
|
||||
<TableAction
|
||||
v-else
|
||||
:actions="[
|
||||
@@ -219,9 +255,18 @@ loadPlatforms();
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<!-- Webhook 状态列:仅展示已配置/未配置 -->
|
||||
<!-- Webhook / 应用凭证状态 -->
|
||||
<template #webhook_status="{ row }">
|
||||
<Tag :color="row.has_webhook || row.webhook_url ? 'success' : 'default'">
|
||||
<Tag
|
||||
v-if="isWorkWechatApp(row.platform_code)"
|
||||
:color="row.has_app_credential ? 'success' : 'default'"
|
||||
>
|
||||
{{ row.has_app_credential ? '应用已配置' : '应用未配置' }}
|
||||
</Tag>
|
||||
<Tag
|
||||
v-else
|
||||
:color="row.has_webhook || row.webhook_url ? 'success' : 'default'"
|
||||
>
|
||||
{{ row.has_webhook || row.webhook_url ? '已配置' : '未配置' }}
|
||||
</Tag>
|
||||
</template>
|
||||
@@ -249,11 +294,15 @@ loadPlatforms();
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '改地址',
|
||||
label: isWorkWechatApp(row.platform_code) ? '改凭证' : '改地址',
|
||||
type: 'link',
|
||||
icon: 'ant-design:link-outlined',
|
||||
icon: isWorkWechatApp(row.platform_code)
|
||||
? 'ant-design:key-outlined'
|
||||
: 'ant-design:link-outlined',
|
||||
size: 'small',
|
||||
onClick: openWebhookModal.bind(null, row),
|
||||
onClick: isWorkWechatApp(row.platform_code)
|
||||
? openAppCredentialModal.bind(null, row)
|
||||
: openWebhookModal.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
|
||||
@@ -50,3 +50,25 @@ export async function deleteOaScene(data: Record<string, any>) {
|
||||
export async function getOaMessageTypes() {
|
||||
return requestClient.get<any>(`${prefix}message-types`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景测试发送(同步)
|
||||
* 可传 id(已保存)或草稿 message_config + robot_ids;test_params 覆盖正文类字段
|
||||
*/
|
||||
export async function testSendOaScene(data: {
|
||||
id?: number;
|
||||
scene_code?: string;
|
||||
scene_name?: string;
|
||||
message_config?: Record<string, any>;
|
||||
robot_ids?: number[];
|
||||
at_users?: Record<string, any>;
|
||||
targets?: any[];
|
||||
test_params?: {
|
||||
content?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
media_url?: string;
|
||||
};
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}test-send`, data);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Alert,
|
||||
Button,
|
||||
Tag,
|
||||
message,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
@@ -21,36 +24,29 @@ import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue'
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getAdminList } from '#/views/system/admin/api';
|
||||
import { getOaPlatformList } from '#/views/system/oa-platform/api';
|
||||
import { getOaRobotList } from '#/views/system/oa-robot/api';
|
||||
import {
|
||||
getOaRobotChatMembers,
|
||||
getOaRobotList,
|
||||
} from '#/views/system/oa-robot/api';
|
||||
import { isWorkWechatApp } from '#/views/system/oa-robot/config/form';
|
||||
import {
|
||||
createOaScene,
|
||||
getOaMessageTypes,
|
||||
testSendOaScene,
|
||||
updateOaScene,
|
||||
} from '#/views/system/oa-scene/api';
|
||||
import { modalFormProps } from '#/views/system/oa-scene/config/form';
|
||||
import TemplateCardForm from '#/views/system/oa-scene/components/template-card-form.vue';
|
||||
import SceneTargetsPanel, {
|
||||
type SceneTargetItem,
|
||||
} from '#/views/system/oa-scene/components/scene-targets-panel.vue';
|
||||
|
||||
/**
|
||||
* OA 场景新增/编辑弹窗(全量消息类型版)
|
||||
*
|
||||
* 结构:
|
||||
* 1. 顶部基础信息表单(场景编码、名称、状态、说明)
|
||||
* 2. 中部:每个启用平台一个 Tab,Tab 内独立配置:
|
||||
* - 消息类型选择(RadioGroup,选项按平台过滤)
|
||||
* - 消息内容表单(按 payload_schema 动态渲染)
|
||||
* - 推送机器人多选 + @ 人多选
|
||||
*
|
||||
* 提交结构:
|
||||
* {
|
||||
* scene_code, scene_name, description, status,
|
||||
* message_config: {
|
||||
* work_wechat: { message_type, payload },
|
||||
* dingtalk: { message_type, payload },
|
||||
* feishu: { message_type, payload }
|
||||
* },
|
||||
* robot_ids: [...], // 所有平台合并
|
||||
* at_users: { work_wechat: [...], dingtalk: [...] }
|
||||
* }
|
||||
* 提交 at_users 统一为双结构(后端兼容旧纯数组):
|
||||
* { work_wechat: { admin_ids: number[], userids: string[] }, work_wechat_app: { ... } }
|
||||
* work_wechat_app 额外提交 targets:人/多群/每群 @
|
||||
*/
|
||||
|
||||
interface MessageTypeOption {
|
||||
@@ -95,8 +91,114 @@ const payloadValues = ref<Record<string, Record<string, any>>>({});
|
||||
|
||||
/** 机器人勾选(按平台分组) */
|
||||
const selectedRobots = ref<Record<string, number[]>>({});
|
||||
/** @ 人勾选(按平台分组) */
|
||||
const selectedAtUsers = ref<Record<string, number[]>>({});
|
||||
/** @ 配置(按平台):admin_ids=系统员工,userids=应用群成员 */
|
||||
const selectedAtUsers = ref<
|
||||
Record<string, { admin_ids: number[]; userids: string[] }>
|
||||
>({});
|
||||
/** 应用 API 群成员选项(按平台合并已选机器人的成员) */
|
||||
const membersByPlatform = ref<
|
||||
Record<string, { userid: string; name: string }[]>
|
||||
>({});
|
||||
/** 应用 API 投递目标(人 / 群 / 每群 @) */
|
||||
const sceneTargets = ref<SceneTargetItem[]>([]);
|
||||
/** 投递目标面板实例(保存前校验客户群确认人) */
|
||||
const targetsPanelRef = ref<{ validateBeforeSubmit: () => string } | null>(
|
||||
null,
|
||||
);
|
||||
/** 暂存待回显的 robot_ids(数据加载完后处理) */
|
||||
let pendingRobotIds: number[] = [];
|
||||
/** 暂存待回显的 targets */
|
||||
let pendingTargets: SceneTargetItem[] = [];
|
||||
|
||||
/** 业务传参示例值(仅测试用,不落库) */
|
||||
const testParams = ref({
|
||||
content: '',
|
||||
title: '',
|
||||
url: '',
|
||||
media_url: '',
|
||||
});
|
||||
const testing = ref(false);
|
||||
/** 传参示例里展示的 scene_code 提示 */
|
||||
const formSceneCode = ref('');
|
||||
interface SceneTestResultItem {
|
||||
robot_id: number;
|
||||
robot_name: string;
|
||||
platform_code: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
cost_ms: number;
|
||||
target_label?: string;
|
||||
}
|
||||
const testResults = ref<SceneTestResultItem[]>([]);
|
||||
const testSummary = ref('');
|
||||
|
||||
/** 业务可传字段说明(与 SendOaNotifyJob 覆盖规则对齐) */
|
||||
const DISPATCH_PARAM_FIELDS = [
|
||||
{
|
||||
key: 'content' as const,
|
||||
label: 'content',
|
||||
hint: '正文:覆盖各平台 payload.content',
|
||||
},
|
||||
{
|
||||
key: 'title' as const,
|
||||
label: 'title',
|
||||
hint: '标题:覆盖 payload.title',
|
||||
},
|
||||
{
|
||||
key: 'url' as const,
|
||||
label: 'url',
|
||||
hint: '链接:覆盖 payload.url',
|
||||
},
|
||||
{
|
||||
key: 'media_url' as const,
|
||||
label: 'media_url',
|
||||
hint: '媒体:覆盖 payload.media_url',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 规范化详情回显的 at_users(兼容旧纯数组)
|
||||
*/
|
||||
function normalizeAtUsers(
|
||||
raw: Record<string, any> | undefined,
|
||||
): Record<string, { admin_ids: number[]; userids: string[] }> {
|
||||
const result: Record<string, { admin_ids: number[]; userids: string[] }> = {};
|
||||
for (const [code, val] of Object.entries(raw || {})) {
|
||||
if (Array.isArray(val)) {
|
||||
result[code] = {
|
||||
admin_ids: val.map((id) => Number(id)).filter((id) => id > 0),
|
||||
userids: [],
|
||||
};
|
||||
} else {
|
||||
result[code] = {
|
||||
admin_ids: ((val as any)?.admin_ids || [])
|
||||
.map((id: any) => Number(id))
|
||||
.filter((id: number) => id > 0),
|
||||
userids: ((val as any)?.userids || [])
|
||||
.map((u: any) => String(u))
|
||||
.filter((u: string) => u !== ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function ensureAtSlot(platformCode: string) {
|
||||
if (!selectedAtUsers.value[platformCode]) {
|
||||
selectedAtUsers.value = {
|
||||
...selectedAtUsers.value,
|
||||
[platformCode]: { admin_ids: [], userids: [] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function adminIdsOf(platformCode: string): number[] {
|
||||
return selectedAtUsers.value[platformCode]?.admin_ids || [];
|
||||
}
|
||||
|
||||
function useridsOf(platformCode: string): string[] {
|
||||
return selectedAtUsers.value[platformCode]?.userids || [];
|
||||
}
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
@@ -109,40 +211,13 @@ const [Modal, modalApi] = useVbenModal({
|
||||
onConfirm: async () => {
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const baseValues = await formApi.getValues();
|
||||
|
||||
// 组装 message_config
|
||||
const message_config: Record<string, any> = {};
|
||||
for (const platform of platforms.value) {
|
||||
const code = platform.platform_code;
|
||||
const messageType = selectedMessageType.value[code];
|
||||
if (!messageType) continue;
|
||||
message_config[code] = {
|
||||
message_type: messageType,
|
||||
payload: payloadValues.value[code] || {},
|
||||
};
|
||||
// 客户群必须配置确认发送员工(前端先拦,后端 syncSceneTargets 再校验)
|
||||
const targetErr = targetsPanelRef.value?.validateBeforeSubmit?.() || '';
|
||||
if (targetErr) {
|
||||
message.warning(targetErr);
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并所有平台的机器人勾选
|
||||
const robot_ids: number[] = [];
|
||||
for (const platformCode of Object.keys(selectedRobots.value)) {
|
||||
robot_ids.push(...(selectedRobots.value[platformCode] || []));
|
||||
}
|
||||
// at_users 按平台分组提交
|
||||
const at_users: Record<string, number[]> = {};
|
||||
for (const platformCode of Object.keys(selectedAtUsers.value)) {
|
||||
const ids = selectedAtUsers.value[platformCode] || [];
|
||||
if (ids.length > 0) {
|
||||
at_users[platformCode] = ids;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...baseValues,
|
||||
message_config,
|
||||
robot_ids,
|
||||
at_users,
|
||||
};
|
||||
const payload = await assembleSceneDraft();
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi = isUpdate.value ? updateOaScene : createOaScene;
|
||||
submitApi(payload)
|
||||
@@ -168,6 +243,11 @@ const [Modal, modalApi] = useVbenModal({
|
||||
payloadValues.value = {};
|
||||
selectedRobots.value = {};
|
||||
selectedAtUsers.value = {};
|
||||
sceneTargets.value = [];
|
||||
testParams.value = { content: '', title: '', url: '', media_url: '' };
|
||||
testResults.value = [];
|
||||
testSummary.value = '';
|
||||
formSceneCode.value = String(values?.scene_code || '');
|
||||
if (values) {
|
||||
formApi.setValues({
|
||||
...values,
|
||||
@@ -181,20 +261,31 @@ const [Modal, modalApi] = useVbenModal({
|
||||
payloadValues.value[code] = config[code].payload || {};
|
||||
}
|
||||
// 回显已勾选的机器人(需等数据加载后由 groupRobotsByPlatform 重算)
|
||||
// 暂存原始 robot_ids 用于后续回显
|
||||
pendingRobotIds = values.robot_ids || [];
|
||||
selectedAtUsers.value = { ...(values.at_users || {}) };
|
||||
selectedAtUsers.value = normalizeAtUsers(values.at_users);
|
||||
pendingTargets = Array.isArray(values.targets) ? values.targets : [];
|
||||
sceneTargets.value = pendingTargets.map((t: any) => ({
|
||||
target_type: Number(t.target_type) === 1 ? 1 : 2,
|
||||
platform_code: t.platform_code || 'work_wechat_app',
|
||||
admin_id: Number(t.admin_id || 0) || undefined,
|
||||
chat_id: Number(t.chat_id || 0) || undefined,
|
||||
at_admin_ids: (t.at_admin_ids || [])
|
||||
.map(Number)
|
||||
.filter((n: number) => n > 0),
|
||||
at_userids: (t.at_userids || [])
|
||||
.map(String)
|
||||
.filter((s: string) => s !== ''),
|
||||
sender_userid: String(t.sender_userid || ''),
|
||||
}));
|
||||
} else {
|
||||
pendingRobotIds = [];
|
||||
pendingTargets = [];
|
||||
}
|
||||
loadData();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 暂存待回显的 robot_ids(数据加载完后处理)
|
||||
let pendingRobotIds: number[] = [];
|
||||
|
||||
/**
|
||||
* 加载平台、消息类型、机器人、员工列表(并行)
|
||||
*/
|
||||
@@ -219,16 +310,20 @@ async function loadData() {
|
||||
const typesData = (typesRes as any)?.data ?? typesRes ?? {};
|
||||
messageTypesMap.value = typesData;
|
||||
|
||||
// 机器人列表
|
||||
// 机器人:启用的 + 当前场景已绑定的(禁用也要能回显勾选)
|
||||
const robotRes = await getOaRobotList({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
status: 1,
|
||||
});
|
||||
const robotRows: any[] = robotRes?.items || robotRes?.data || [];
|
||||
allRobots.value = robotRows.filter((r: any) =>
|
||||
platforms.value.some((p: any) => p.platform_code === r.platform_code),
|
||||
);
|
||||
const boundIdSet = new Set(pendingRobotIds.map((id) => Number(id)));
|
||||
allRobots.value = robotRows.filter((r: any) => {
|
||||
const onPlatform = platforms.value.some(
|
||||
(p: any) => p.platform_code === r.platform_code,
|
||||
);
|
||||
if (!onPlatform) return false;
|
||||
return Number(r.status) === 1 || boundIdSet.has(Number(r.id));
|
||||
});
|
||||
|
||||
// 员工列表
|
||||
const adminRows: any[] = adminRes?.items || adminRes?.data || [];
|
||||
@@ -241,10 +336,12 @@ async function loadData() {
|
||||
if (pendingRobotIds.length > 0) {
|
||||
selectedRobots.value = groupRobotsByPlatform(pendingRobotIds);
|
||||
}
|
||||
await refreshAppMembers();
|
||||
|
||||
// 初始化每个平台的默认消息类型(如未回显则默认 text)
|
||||
for (const platform of platforms.value) {
|
||||
const code = platform.platform_code;
|
||||
ensureAtSlot(code);
|
||||
if (!selectedMessageType.value[code]) {
|
||||
const types = messageTypesMap.value[code] || [];
|
||||
const defaultType = types.find((t) => t.message_type === 'text');
|
||||
@@ -274,6 +371,106 @@ function groupRobotsByPlatform(robotIds: number[]): Record<string, number[]> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装当前表单的场景草稿(保存与测试发送共用)
|
||||
*/
|
||||
async function assembleSceneDraft() {
|
||||
const baseValues = await formApi.getValues();
|
||||
const message_config: Record<string, any> = {};
|
||||
for (const platform of platforms.value) {
|
||||
const code = platform.platform_code;
|
||||
const messageType = selectedMessageType.value[code];
|
||||
if (!messageType) continue;
|
||||
message_config[code] = {
|
||||
message_type: messageType,
|
||||
payload: payloadValues.value[code] || {},
|
||||
};
|
||||
}
|
||||
const robot_ids: number[] = [];
|
||||
for (const platformCode of Object.keys(selectedRobots.value)) {
|
||||
robot_ids.push(...(selectedRobots.value[platformCode] || []));
|
||||
}
|
||||
const at_users: Record<
|
||||
string,
|
||||
{ admin_ids: number[]; userids: string[] }
|
||||
> = {};
|
||||
for (const platformCode of Object.keys(selectedAtUsers.value)) {
|
||||
if (isWorkWechatApp(platformCode)) continue;
|
||||
const slot = selectedAtUsers.value[platformCode] || {
|
||||
admin_ids: [],
|
||||
userids: [],
|
||||
};
|
||||
if (slot.admin_ids.length > 0 || slot.userids.length > 0) {
|
||||
at_users[platformCode] = {
|
||||
admin_ids: slot.admin_ids,
|
||||
userids: slot.userids,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
...baseValues,
|
||||
message_config,
|
||||
robot_ids,
|
||||
at_users,
|
||||
targets: sceneTargets.value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试发送:用当前草稿 + 示例传参同步投递,结果展示在弹窗内
|
||||
*/
|
||||
async function handleTestSend() {
|
||||
const draft = await assembleSceneDraft();
|
||||
formSceneCode.value = String(draft.scene_code || formSceneCode.value || '');
|
||||
if (!draft.robot_ids || draft.robot_ids.length === 0) {
|
||||
message.warning('请先勾选至少一个推送机器人');
|
||||
return;
|
||||
}
|
||||
if (!draft.message_config || Object.keys(draft.message_config).length === 0) {
|
||||
message.warning('请先配置至少一个平台的消息类型');
|
||||
return;
|
||||
}
|
||||
const targetErr = targetsPanelRef.value?.validateBeforeSubmit?.() || '';
|
||||
if (targetErr) {
|
||||
message.warning(targetErr);
|
||||
return;
|
||||
}
|
||||
testing.value = true;
|
||||
testResults.value = [];
|
||||
testSummary.value = '正在测试发送...';
|
||||
try {
|
||||
const result = await testSendOaScene({
|
||||
id: Number(draft.id || 0) || undefined,
|
||||
scene_code: String(draft.scene_code || ''),
|
||||
scene_name: String(draft.scene_name || ''),
|
||||
message_config: draft.message_config,
|
||||
robot_ids: draft.robot_ids,
|
||||
at_users: draft.at_users,
|
||||
targets: draft.targets,
|
||||
test_params: { ...testParams.value },
|
||||
});
|
||||
const rows = Array.isArray(result?.results) ? result.results : [];
|
||||
testResults.value = rows;
|
||||
const total = result?.total ?? rows.length;
|
||||
const succ =
|
||||
result?.success_count ?? rows.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);
|
||||
}
|
||||
} catch (e: any) {
|
||||
testSummary.value = e?.message || '测试发送失败';
|
||||
message.error(testSummary.value);
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function robotsOfPlatform(platformCode: string): any[] {
|
||||
return allRobots.value.filter((r: any) => r.platform_code === platformCode);
|
||||
}
|
||||
@@ -287,13 +484,37 @@ function toggleRobot(platformCode: string, robotId: number, checked: boolean) {
|
||||
if (checked) current.add(robotId);
|
||||
else current.delete(robotId);
|
||||
selectedRobots.value = { ...selectedRobots.value, [platformCode]: Array.from(current) };
|
||||
if (isWorkWechatApp(platformCode)) {
|
||||
refreshAppMembers();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAtUser(platformCode: string, adminId: number, checked: boolean) {
|
||||
const current = new Set(selectedAtUsers.value[platformCode] || []);
|
||||
ensureAtSlot(platformCode);
|
||||
const current = new Set(adminIdsOf(platformCode));
|
||||
if (checked) current.add(adminId);
|
||||
else current.delete(adminId);
|
||||
selectedAtUsers.value = { ...selectedAtUsers.value, [platformCode]: Array.from(current) };
|
||||
selectedAtUsers.value = {
|
||||
...selectedAtUsers.value,
|
||||
[platformCode]: {
|
||||
admin_ids: Array.from(current),
|
||||
userids: useridsOf(platformCode),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toggleAtMember(platformCode: string, userid: string, checked: boolean) {
|
||||
ensureAtSlot(platformCode);
|
||||
const current = new Set(useridsOf(platformCode));
|
||||
if (checked) current.add(userid);
|
||||
else current.delete(userid);
|
||||
selectedAtUsers.value = {
|
||||
...selectedAtUsers.value,
|
||||
[platformCode]: {
|
||||
admin_ids: adminIdsOf(platformCode),
|
||||
userids: Array.from(current),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toggleAllRobots(platformCode: string, e: any) {
|
||||
@@ -303,17 +524,67 @@ function toggleAllRobots(platformCode: string, e: any) {
|
||||
...selectedRobots.value,
|
||||
[platformCode]: checked ? robotIds : [],
|
||||
};
|
||||
if (isWorkWechatApp(platformCode)) {
|
||||
refreshAppMembers();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAllAtUsers(platformCode: string, e: any) {
|
||||
const checked = (e?.target as HTMLInputElement)?.checked;
|
||||
const adminIds = adminList.value.map((a) => a.id);
|
||||
ensureAtSlot(platformCode);
|
||||
selectedAtUsers.value = {
|
||||
...selectedAtUsers.value,
|
||||
[platformCode]: checked ? adminIds : [],
|
||||
[platformCode]: {
|
||||
admin_ids: checked ? adminIds : [],
|
||||
userids: useridsOf(platformCode),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toggleAllAtMembers(platformCode: string, e: any) {
|
||||
const checked = (e?.target as HTMLInputElement)?.checked;
|
||||
const allIds = (membersByPlatform.value[platformCode] || []).map((m) => m.userid);
|
||||
ensureAtSlot(platformCode);
|
||||
selectedAtUsers.value = {
|
||||
...selectedAtUsers.value,
|
||||
[platformCode]: {
|
||||
admin_ids: adminIdsOf(platformCode),
|
||||
userids: checked ? allIds : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按已选应用 API 机器人合并拉取群成员(userid 去重)
|
||||
*/
|
||||
async function refreshAppMembers() {
|
||||
const appCodes = platforms.value
|
||||
.map((p) => p.platform_code)
|
||||
.filter((code) => isWorkWechatApp(code));
|
||||
for (const code of appCodes) {
|
||||
const robotIds = selectedRobots.value[code] || [];
|
||||
if (robotIds.length === 0) {
|
||||
membersByPlatform.value = { ...membersByPlatform.value, [code]: [] };
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const list = await getOaRobotChatMembers({ robot_ids: robotIds });
|
||||
const rows: any[] = Array.isArray(list) ? list : list?.data || [];
|
||||
membersByPlatform.value = {
|
||||
...membersByPlatform.value,
|
||||
[code]: rows.map((m) => ({
|
||||
userid: String(m.userid),
|
||||
name: String(m.name || ''),
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('加载应用群成员失败', e);
|
||||
membersByPlatform.value = { ...membersByPlatform.value, [code]: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定平台当前消息类型对应的 payload_schema(解析 JSON)
|
||||
*/
|
||||
@@ -369,10 +640,14 @@ watch(
|
||||
(list) => {
|
||||
const validCodes = new Set(list.map((p) => p.platform_code));
|
||||
const newRobots: Record<string, number[]> = {};
|
||||
const newAtUsers: Record<string, number[]> = {};
|
||||
const newAtUsers: Record<string, { admin_ids: number[]; userids: string[] }> =
|
||||
{};
|
||||
for (const code of validCodes) {
|
||||
newRobots[code] = selectedRobots.value[code] || [];
|
||||
newAtUsers[code] = selectedAtUsers.value[code] || [];
|
||||
newAtUsers[code] = selectedAtUsers.value[code] || {
|
||||
admin_ids: [],
|
||||
userids: [],
|
||||
};
|
||||
}
|
||||
selectedRobots.value = newRobots;
|
||||
selectedAtUsers.value = newAtUsers;
|
||||
@@ -602,22 +877,33 @@ watch(
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ robot.name }}
|
||||
{{ robot.name
|
||||
}}{{ Number(robot.status) === 0 ? '(已禁用)' : '' }}
|
||||
</Checkbox>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
<!-- 4. @ 人多选 -->
|
||||
<div class="section">
|
||||
<!-- 4. 应用 API:投递目标(人/多群/@);其他平台:平台级 @ -->
|
||||
<div v-if="isWorkWechatApp(platform.platform_code)" class="section">
|
||||
<div class="section-title">投递目标</div>
|
||||
<SceneTargetsPanel
|
||||
ref="targetsPanelRef"
|
||||
v-model="sceneTargets"
|
||||
:admin-list="adminList"
|
||||
:platform-code="platform.platform_code"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="section">
|
||||
<div class="mb-2 flex items-center">
|
||||
<Checkbox
|
||||
:checked="
|
||||
adminList.length > 0 &&
|
||||
(selectedAtUsers[platform.platform_code] || []).length === adminList.length
|
||||
adminIdsOf(platform.platform_code).length ===
|
||||
adminList.length
|
||||
"
|
||||
@change="toggleAllAtUsers(platform.platform_code, $event)"
|
||||
>
|
||||
<span class="font-medium">@ 人员(全选)</span>
|
||||
<span class="font-medium">@ 系统员工</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div v-if="adminList.length === 0" class="text-gray-400">
|
||||
@@ -625,7 +911,7 @@ watch(
|
||||
</div>
|
||||
<Checkbox.Group
|
||||
v-else
|
||||
:value="selectedAtUsers[platform.platform_code] || []"
|
||||
:value="adminIdsOf(platform.platform_code)"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
@@ -633,7 +919,7 @@ watch(
|
||||
:key="admin.id"
|
||||
:value="admin.id"
|
||||
:checked="
|
||||
(selectedAtUsers[platform.platform_code] || []).includes(admin.id)
|
||||
adminIdsOf(platform.platform_code).includes(admin.id)
|
||||
"
|
||||
@change="
|
||||
toggleAtUser(
|
||||
@@ -650,8 +936,80 @@ watch(
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<!-- 业务传参示例 + 测试结果(全平台共用,与 dispatch 覆盖字段对齐) -->
|
||||
<div class="section dispatch-params">
|
||||
<div class="section-title">业务传参(测试用)</div>
|
||||
<Alert
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
message="业务方调用示例"
|
||||
:description="`OaNotifyService::dispatch('${formSceneCode || 'scene_code'}', ['content' => '...', 'title' => '...']);非空示例值会覆盖场景 payload 对应字段`"
|
||||
/>
|
||||
<div
|
||||
v-for="field in DISPATCH_PARAM_FIELDS"
|
||||
:key="field.key"
|
||||
class="payload-row mb-2"
|
||||
>
|
||||
<label class="payload-label">
|
||||
<code>{{ field.label }}</code>
|
||||
<span class="param-hint">{{ field.hint }}</span>
|
||||
</label>
|
||||
<Input
|
||||
v-model:value="testParams[field.key]"
|
||||
:placeholder="`示例值(可选)`"
|
||||
size="small"
|
||||
allow-clear
|
||||
/>
|
||||
</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
|
||||
class="mb-2"
|
||||
/>
|
||||
<div
|
||||
v-for="(item, idx) in testResults"
|
||||
:key="`${item.robot_id}-${idx}`"
|
||||
class="result-item"
|
||||
>
|
||||
<Tag :color="item.success ? 'success' : 'error'">
|
||||
{{ item.success ? '成功' : '失败' }}
|
||||
</Tag>
|
||||
<span class="result-name">{{ item.robot_name }}</span>
|
||||
<span class="result-type">({{ item.platform_code }})</span>
|
||||
<span v-if="item.target_label" class="result-target">
|
||||
→ {{ item.target_label }}
|
||||
</span>
|
||||
<span v-if="item.cost_ms" class="result-cost">
|
||||
{{ item.cost_ms }}ms
|
||||
</span>
|
||||
<div v-if="!item.success && item.message" class="result-error">
|
||||
{{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<template #prepend-footer>
|
||||
<Button type="default" :loading="testing" @click="handleTestSend">
|
||||
测试发送
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -706,4 +1064,44 @@ watch(
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dispatch-params {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.param-hint {
|
||||
margin-left: 8px;
|
||||
font-weight: 400;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e5e6eb;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
margin-left: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-type,
|
||||
.result-target,
|
||||
.result-cost {
|
||||
margin-left: 6px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.result-error {
|
||||
margin-top: 4px;
|
||||
color: #f53f3f;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,23 +3,27 @@ import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { deleteOaScene } from './api';
|
||||
import FormModalDemo from './components/modal.vue';
|
||||
import { deleteOaScene, getOaSceneInfo } from './api';
|
||||
import SceneFormPage from './components/form-page.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
/**
|
||||
* OA 通知场景管理列表页
|
||||
* 新增/编辑走内页表单(v-if 挂载 form-page),列表用 v-show 保持 Grid 状态
|
||||
*/
|
||||
defineOptions({ name: 'OaScene' });
|
||||
|
||||
const showForm = ref(false);
|
||||
const formValues = ref<any>({});
|
||||
const formIsUpdate = ref(false);
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
@@ -39,29 +43,32 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModalDemo,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开新增/编辑弹窗
|
||||
* 编辑时需要拉取场景详情(含 robot_ids 和 at_users 字段),所以传入 id
|
||||
* 打开新增/编辑内页:编辑必须拉详情(列表无 message_config 等)
|
||||
*/
|
||||
const showModal = async (row: any = {}, isUpdate = false) => {
|
||||
async function openForm(row: any = {}, isUpdate = false) {
|
||||
let values: any = {};
|
||||
if (isUpdate && row?.id) {
|
||||
// 编辑场景下,先拉取详情(含 robot_ids 和 at_users 字段)再打开弹窗
|
||||
// 详情接口由弹窗内的 onOpenChange 触发(这里直接传 row,但 row 中没有 robot_ids/at_users)
|
||||
// 为简化,这里直接打开弹窗,让弹窗根据 row.id 重新拉详情
|
||||
values = { ...row, id: row.id };
|
||||
try {
|
||||
values = await getOaSceneInfo(row.id);
|
||||
} catch (e) {
|
||||
console.error('加载场景详情失败', e);
|
||||
message.error('加载场景详情失败');
|
||||
return;
|
||||
}
|
||||
}
|
||||
formModalApi.setData({
|
||||
values,
|
||||
update: isUpdate,
|
||||
gridApi,
|
||||
});
|
||||
formModalApi.open();
|
||||
};
|
||||
formValues.value = values;
|
||||
formIsUpdate.value = isUpdate;
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function onFormBack() {
|
||||
showForm.value = false;
|
||||
}
|
||||
|
||||
function onFormSaved() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除场景(支持单个和批量)
|
||||
@@ -81,66 +88,77 @@ const deleteApi = (row: any) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="OA 通知场景管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: showModal.bind(null),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '批量删除',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
ifShow: hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, false),
|
||||
<div v-show="!showForm">
|
||||
<Page auto-content-height title="OA 通知场景管理">
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: openForm.bind(null),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #message_type="{ row }">
|
||||
<Tag :color="row.message_type === 'markdown' ? 'warning' : 'processing'">
|
||||
{{ row.message_type === 'markdown' ? 'Markdown' : '文本' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'success' : 'default'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: showModal.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '批量删除',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
ifShow: hasTopTableDropDownActions,
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, false),
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #message_type="{ row }">
|
||||
<Tag
|
||||
:color="row.message_type === 'markdown' ? 'warning' : 'processing'"
|
||||
>
|
||||
{{ row.message_type === 'markdown' ? 'Markdown' : '文本' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'success' : 'default'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: openForm.bind(null, row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除吗?',
|
||||
confirm: deleteApi.bind(null, row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</div>
|
||||
|
||||
<SceneFormPage
|
||||
v-if="showForm"
|
||||
:values="formValues"
|
||||
:is-update="formIsUpdate"
|
||||
@back="onFormBack"
|
||||
@saved="onFormSaved"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
InputNumber,
|
||||
InputPassword,
|
||||
message,
|
||||
Spin,
|
||||
Switch,
|
||||
@@ -11,33 +14,36 @@ import {
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
getOaPlatformCredential,
|
||||
getOaPlatformList,
|
||||
updateOaPlatformCredential,
|
||||
updateOaPlatformEnabled,
|
||||
} from '#/views/system/oa-platform/api';
|
||||
import { getSystemConfigList, saveSystemConfig } from '#/views/system/system-config/api';
|
||||
|
||||
/**
|
||||
* 系统配置 - OA 通知 Tab 内容
|
||||
*
|
||||
* 内容:
|
||||
* 1. 总开关(OA 通知模块开关):保存到 xk_system_config.oa_notify_enabled
|
||||
* 2. 平台子开关列表(动态拉取,从 xk_oa_platform 表读取)
|
||||
*
|
||||
* 关键交互:
|
||||
* - 总开关关闭时,下方所有平台子开关整体置灰禁用
|
||||
* - 平台子开关切换时立即调接口(不等保存按钮)
|
||||
* - 总开关切换走现有 saveSystemConfig 接口
|
||||
* 系统配置 - OA 通知 Tab
|
||||
* 总开关 + 平台子开关;work_wechat_app 可展开编辑平台级凭证
|
||||
*/
|
||||
|
||||
/** 总开关 */
|
||||
const oaEnabled = ref(false);
|
||||
const totalSaving = ref(false);
|
||||
const platformsLoading = ref(false);
|
||||
const platforms = ref<any[]>([]);
|
||||
/** 当前展开编辑凭证的平台 id */
|
||||
const editingCredId = ref<number | null>(null);
|
||||
const credSaving = ref(false);
|
||||
const credForm = ref({
|
||||
corp_id: '',
|
||||
agent_id: 0,
|
||||
app_secret: '',
|
||||
default_sender: '',
|
||||
});
|
||||
|
||||
function isWorkWechatApp(code: string) {
|
||||
return code === 'work_wechat_app';
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载系统配置(取总开关值)
|
||||
*/
|
||||
async function loadSystemConfig() {
|
||||
try {
|
||||
const list = await getSystemConfigList();
|
||||
@@ -53,9 +59,6 @@ async function loadSystemConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载平台列表
|
||||
*/
|
||||
async function loadPlatforms() {
|
||||
platformsLoading.value = true;
|
||||
try {
|
||||
@@ -68,9 +71,6 @@ async function loadPlatforms() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换总开关(保存到 xk_system_config)
|
||||
*/
|
||||
async function handleTotalSwitch(checked: boolean) {
|
||||
totalSaving.value = true;
|
||||
try {
|
||||
@@ -81,34 +81,70 @@ async function handleTotalSwitch(checked: boolean) {
|
||||
message.success(`OA 通知已${checked ? '启用' : '关闭'}`);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
// 失败回滚到原状态
|
||||
oaEnabled.value = !checked;
|
||||
} finally {
|
||||
totalSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换平台子开关(立即调用接口,乐观更新 + 失败回滚)
|
||||
*/
|
||||
async function handlePlatformSwitch(
|
||||
platform: any,
|
||||
checked: boolean,
|
||||
index: number,
|
||||
) {
|
||||
const oldValue = platform.enabled;
|
||||
// 乐观更新:先在前端切换状态
|
||||
platforms.value[index].enabled = checked ? 1 : 0;
|
||||
try {
|
||||
await updateOaPlatformEnabled(platform.id, checked ? 1 : 0);
|
||||
message.success(`${platform.name} 已${checked ? '启用' : '关闭'}`);
|
||||
} catch (e: any) {
|
||||
// 失败回滚
|
||||
platforms.value[index].enabled = oldValue;
|
||||
message.error(e.message || `${platform.name} 切换失败`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开/收起凭证编辑 */
|
||||
async function toggleCredential(platform: any) {
|
||||
if (editingCredId.value === platform.id) {
|
||||
editingCredId.value = null;
|
||||
return;
|
||||
}
|
||||
editingCredId.value = platform.id;
|
||||
try {
|
||||
const data = await getOaPlatformCredential(platform.id);
|
||||
const row = data?.data ?? data ?? {};
|
||||
credForm.value = {
|
||||
corp_id: row.corp_id || '',
|
||||
agent_id: Number(row.agent_id || 0),
|
||||
app_secret: '',
|
||||
default_sender: row.default_sender || '',
|
||||
};
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载凭证失败');
|
||||
editingCredId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCredential(platform: any) {
|
||||
credSaving.value = true;
|
||||
try {
|
||||
await updateOaPlatformCredential({
|
||||
id: platform.id,
|
||||
corp_id: credForm.value.corp_id,
|
||||
agent_id: Number(credForm.value.agent_id || 0),
|
||||
app_secret: credForm.value.app_secret || '',
|
||||
default_sender: credForm.value.default_sender || '',
|
||||
});
|
||||
message.success('平台凭证已保存');
|
||||
editingCredId.value = null;
|
||||
await loadPlatforms();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
} finally {
|
||||
credSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSystemConfig();
|
||||
loadPlatforms();
|
||||
@@ -117,7 +153,6 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="py-4">
|
||||
<!-- 总开关 -->
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -137,41 +172,117 @@ onMounted(() => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 平台子开关列表(总开关关闭时整体置灰禁用) -->
|
||||
<Card :class="{ 'oa-platform-disabled': !oaEnabled }">
|
||||
<div class="mb-3 text-base font-medium">平台子开关</div>
|
||||
<div class="mb-2 text-sm text-gray-500">
|
||||
每个平台独立开关,关闭后该平台的所有机器人都会跳过发送
|
||||
每个平台独立开关;企业微信(应用API)可配置平台级凭证供机器人继承
|
||||
</div>
|
||||
<Spin :spinning="platformsLoading">
|
||||
<div v-if="platforms.length === 0 && !platformsLoading" class="py-4 text-center text-gray-400">
|
||||
<div
|
||||
v-if="platforms.length === 0 && !platformsLoading"
|
||||
class="py-4 text-center text-gray-400"
|
||||
>
|
||||
暂无平台数据,请联系开发初始化
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(platform, index) in platforms"
|
||||
:key="platform.platform_code"
|
||||
class="flex items-center justify-between rounded border border-gray-100 p-3"
|
||||
class="rounded border border-gray-100 p-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon :icon="platform.icon" class="text-2xl" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ platform.name }}</span>
|
||||
<Tag>{{ platform.platform_code }}</Tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
排序:{{ platform.sort }}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ platform.name }}</span>
|
||||
<Tag>{{ platform.platform_code }}</Tag>
|
||||
<Tag
|
||||
v-if="isWorkWechatApp(platform.platform_code)"
|
||||
:color="platform.has_credential ? 'success' : 'default'"
|
||||
>
|
||||
{{ platform.has_credential ? '凭证已配' : '凭证未配' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
排序:{{ platform.sort }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="isWorkWechatApp(platform.platform_code)"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="toggleCredential(platform)"
|
||||
>
|
||||
{{ editingCredId === platform.id ? '收起凭证' : '配置凭证' }}
|
||||
</Button>
|
||||
<Switch
|
||||
:checked="platform.enabled === 1"
|
||||
:disabled="!oaEnabled"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
@change="
|
||||
(checked: boolean) =>
|
||||
handlePlatformSwitch(platform, checked, index)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
isWorkWechatApp(platform.platform_code) &&
|
||||
editingCredId === platform.id
|
||||
"
|
||||
class="mt-3 grid grid-cols-1 gap-3 border-t border-dashed border-gray-200 pt-3 md:grid-cols-2"
|
||||
>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">企业 ID(corpid)</div>
|
||||
<Input
|
||||
v-model:value="credForm.corp_id"
|
||||
placeholder="请输入 corpid"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">AgentId</div>
|
||||
<InputNumber
|
||||
v-model:value="credForm.agent_id"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
placeholder="应用 agentid"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">
|
||||
应用 Secret(留空不改)
|
||||
</div>
|
||||
<InputPassword
|
||||
v-model:value="credForm.app_secret"
|
||||
placeholder="自建应用 Secret"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">
|
||||
客户群群发默认 sender(userid)
|
||||
</div>
|
||||
<Input
|
||||
v-model:value="credForm.default_sender"
|
||||
placeholder="可选,群发任务执行人"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="credSaving"
|
||||
@click="saveCredential(platform)"
|
||||
>
|
||||
保存凭证
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
:checked="platform.enabled === 1"
|
||||
:disabled="!oaEnabled"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
@change="(checked: boolean) => handlePlatformSwitch(platform, checked, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
@@ -180,7 +291,6 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 总开关关闭时,平台子开关区域整体置灰 */
|
||||
.oa-platform-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
|
||||
Reference in New Issue
Block a user