feat: 钉钉、企业微信webbook、企业微信应用api,问题:交互

This commit is contained in:
2026-07-21 14:01:41 +08:00
parent 4224130ad4
commit 2b13a19731
6 changed files with 2055 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
import { requestClient } from '#/api/request';
/**
* 企微组织架构 APIOA 群聊页「员工」Tab
* 路由前缀oa-ww-org/
*/
const prefix = 'oa-ww-org/';
/**
* 从企微同步组织架构(部门 + 成员)
*/
export async function syncOaWwOrg(data: { platform_code?: string } = {}) {
return requestClient.post<any>(`${prefix}sync-org`, data);
}
/**
* 本地部门树antd Tree
*/
export async function getOaWwDeptTree(params: { platform_code?: string } = {}) {
return requestClient.get<any>(`${prefix}department-tree`, { params });
}
/**
* 员工分页列表
*/
export async function getOaWwUserList(params: {
platform_code?: string;
dept_id?: number;
keyword?: string;
page?: number;
pageSize?: number;
}) {
return requestClient.get<any>(`${prefix}user-list`, { params });
}

View File

@@ -0,0 +1,107 @@
<script lang="ts" setup>
/**
* 创建企微应用群聊弹窗
* 调 appchat/create成功后回写本地 xk_oa_chatchat_kind=应用群source=接口创建)
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createOaAppChat } from '../api';
const gridApi = ref();
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'name',
label: '群名称',
rules: 'required',
componentProps: { placeholder: '应用群聊名称' },
},
{
component: 'VbenInput',
fieldName: 'owner',
label: '群主 userid',
rules: 'required',
componentProps: { placeholder: '企微成员 userid' },
},
{
component: 'Select',
fieldName: 'userlist',
label: '成员 userid',
rules: 'selectRequired',
help: '至少 2 人(含群主);可输入后回车添加',
componentProps: {
mode: 'tags',
placeholder: '输入 userid 后回车',
tokenSeparators: [',', ' '],
},
},
{
component: 'VbenInput',
fieldName: 'chatid',
label: '指定 chatid',
help: '选填;不填则由企微自动生成',
componentProps: { placeholder: '选填' },
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
const userlist = Array.isArray(values.userlist)
? values.userlist
: String(values.userlist || '')
.split(/[,\s]+/)
.filter(Boolean);
modalApi.setState({ loading: true, confirmLoading: true });
try {
await createOaAppChat({
platform_code: 'work_wechat_app',
name: values.name,
owner: values.owner,
userlist,
chatid: values.chatid || '',
});
message.success('应用群创建成功');
gridApi.value?.reload?.() ?? gridApi.value?.query?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
formApi.resetForm();
}
},
});
</script>
<template>
<Modal title="创建应用群聊" class="w-[40%]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,244 @@
<script lang="ts" setup>
/**
* OA 群聊页「员工」面板
* 左侧企微部门树自动分类,右侧员工列表;支持同步组织架构与关键词搜索
*/
import { onMounted, ref } from 'vue';
import {
Button,
Empty,
InputSearch,
message,
Pagination,
Spin,
Table,
Tag,
Tree,
} from 'ant-design-vue';
import {
getOaWwDeptTree,
getOaWwUserList,
syncOaWwOrg,
} from '../api/org';
const syncLoading = ref(false);
const treeLoading = ref(false);
const tableLoading = ref(false);
const treeData = ref<any[]>([]);
const selectedDeptId = ref<number | null>(null);
const keyword = ref('');
const users = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
/**
* 同步企微组织架构
*/
async function handleSync() {
syncLoading.value = true;
try {
const res = await syncOaWwOrg({ platform_code: 'work_wechat_app' });
const data = res?.data ?? res ?? {};
message.success(
`同步完成:部门 ${data.dept_count ?? 0},成员 ${data.user_count ?? 0}`,
);
selectedDeptId.value = null;
page.value = 1;
await Promise.all([loadTree(), loadUsers()]);
} catch (e: any) {
message.error(e?.message || '同步失败,请确认平台凭证与通讯录权限');
} finally {
syncLoading.value = false;
}
}
/**
* 加载部门树
*/
async function loadTree() {
treeLoading.value = true;
try {
const res = await getOaWwDeptTree({ platform_code: 'work_wechat_app' });
const data = res?.data ?? res ?? [];
treeData.value = Array.isArray(data) ? data : [];
} catch (e) {
console.error(e);
treeData.value = [];
} finally {
treeLoading.value = false;
}
}
/**
* 加载员工列表
*/
async function loadUsers() {
tableLoading.value = true;
try {
const res = await getOaWwUserList({
platform_code: 'work_wechat_app',
dept_id: selectedDeptId.value || 0,
keyword: keyword.value.trim(),
page: page.value,
pageSize: pageSize.value,
});
const data = res?.data ?? res ?? {};
users.value = Array.isArray(data.items) ? data.items : [];
total.value = Number(data.total || 0);
} catch (e) {
console.error(e);
users.value = [];
total.value = 0;
} finally {
tableLoading.value = false;
}
}
/**
* 选中部门节点
*/
function onSelectDept(keys: (string | number)[]) {
const key = keys[0];
selectedDeptId.value = key !== undefined && key !== null ? Number(key) : null;
page.value = 1;
loadUsers();
}
function onSearch() {
page.value = 1;
loadUsers();
}
function onPageChange(p: number, ps: number) {
page.value = p;
pageSize.value = ps;
loadUsers();
}
const columns = [
{ title: 'userid', dataIndex: 'userid', key: 'userid', width: 140 },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '手机号', dataIndex: 'mobile', key: 'mobile', width: 120 },
{
title: '主部门',
dataIndex: 'main_department_name',
key: 'main_department_name',
width: 140,
},
{ title: '职位', dataIndex: 'position', key: 'position', width: 120 },
{
title: '匹配系统员工',
key: 'matched',
width: 140,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
},
];
function statusLabel(status: number) {
const map: Record<number, string> = {
1: '已激活',
2: '已禁用',
4: '未激活',
5: '退出企业',
};
return map[status] || String(status);
}
onMounted(async () => {
await loadTree();
await loadUsers();
});
</script>
<template>
<div class="employee-panel flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<Button type="primary" :loading="syncLoading" @click="handleSync">
同步组织架构
</Button>
<InputSearch
v-model:value="keyword"
allow-clear
placeholder="搜索姓名 / userid / 手机号"
class="w-[280px]"
@search="onSearch"
/>
<span class="text-xs text-gray-400">
需平台凭证且应用已开通通讯录读权限匹配仅展示不自动绑定
</span>
</div>
<div class="flex gap-3">
<!-- 左侧部门树固定最大高度内部滚动避免撑高整页 -->
<aside
class="flex w-[260px] shrink-0 flex-col rounded-lg border border-border bg-card p-3"
style="max-height: 560px"
>
<div class="mb-2 text-sm font-medium">组织架构</div>
<Spin :spinning="treeLoading">
<div class="overflow-auto" style="max-height: 500px">
<Empty
v-if="!treeLoading && treeData.length === 0"
description="暂无部门,请先同步"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
/>
<Tree
v-else
:tree-data="treeData"
:selected-keys="
selectedDeptId !== null ? [String(selectedDeptId)] : []
"
default-expand-all
@select="onSelectDept"
/>
</div>
</Spin>
</aside>
<!-- 右侧员工表不设自适应高度内容自然撑开 + 横向滚动 -->
<div class="min-w-0 flex-1 rounded-lg border border-border bg-card p-3">
<Table
size="small"
:columns="columns"
:data-source="users"
:loading="tableLoading"
:pagination="false"
row-key="id"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'matched'">
<Tag v-if="record.matched_admin_id" color="success">
{{ record.matched_admin_name || `#${record.matched_admin_id}` }}
</Tag>
<span v-else class="text-gray-400">未匹配</span>
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="record.status === 1 ? 'success' : 'default'">
{{ statusLabel(Number(record.status)) }}
</Tag>
</template>
</template>
</Table>
<div class="mt-3 flex justify-end">
<Pagination
:current="page"
:page-size="pageSize"
:total="total"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
@change="onPageChange"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,184 @@
<script lang="ts" setup>
/**
* 企业微信应用 API「改凭证」弹窗
* 仅保存凭证策略(平台默认 / 自定义 corp/secret/agent测试请到列表「测试」弹窗选人/选群
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { updateOaRobotAppCredential } from '#/views/system/oa-robot/api';
import {
isUsePlatformCredential,
showCustomAppCredential,
} from '#/views/system/oa-robot/config/form';
const gridApi = ref();
const robotId = ref(0);
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
showDefaultActions: false,
schema: [
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: '使用平台默认凭证', value: 1 },
{ label: '自定义凭证', value: 0 },
],
},
defaultValue: 1,
fieldName: 'use_platform_credential',
label: '凭证来源',
rules: 'selectRequired',
},
{
component: 'VbenInput',
fieldName: 'corp_id',
label: '企业 ID',
componentProps: { placeholder: 'corpid', autocomplete: 'off' },
dependencies: {
triggerFields: ['use_platform_credential'],
show: (values: { use_platform_credential?: number }) =>
showCustomAppCredential({
platform_code: 'work_wechat_app',
use_platform_credential: values.use_platform_credential,
}),
rules: (values: { use_platform_credential?: number }) =>
showCustomAppCredential({
platform_code: 'work_wechat_app',
use_platform_credential: values.use_platform_credential,
})
? 'required'
: null,
},
},
{
component: 'VbenInputPassword',
fieldName: 'secret',
label: '应用 Secret',
componentProps: {
placeholder: '留空表示不修改;填写则覆盖',
autocomplete: 'new-password',
},
dependencies: {
triggerFields: ['use_platform_credential'],
show: (values: { use_platform_credential?: number }) =>
showCustomAppCredential({
platform_code: 'work_wechat_app',
use_platform_credential: values.use_platform_credential,
}),
},
},
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '应用 AgentId',
componentProps: { placeholder: '企业应用 agentid', min: 1, class: 'w-full' },
dependencies: {
triggerFields: ['use_platform_credential'],
show: (values: { use_platform_credential?: number }) =>
showCustomAppCredential({
platform_code: 'work_wechat_app',
use_platform_credential: values.use_platform_credential,
}),
rules: (values: { use_platform_credential?: number }) =>
showCustomAppCredential({
platform_code: 'work_wechat_app',
use_platform_credential: values.use_platform_credential,
})
? 'required'
: null,
},
},
{
component: 'VbenInput',
fieldName: 'chat_id',
label: '默认群聊 ID',
componentProps: {
placeholder: '可选;场景选群为主。填写后可同步成员',
autocomplete: 'off',
},
},
],
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
const usePlatform = Number(values.use_platform_credential ?? 1);
// 自定义凭证时 secret 首次必填;编辑留空表示不改
if (
!isUsePlatformCredential({
platform_code: 'work_wechat_app',
use_platform_credential: usePlatform,
}) &&
!String(values.secret || '').trim()
) {
// 编辑场景允许留空不改;若库中本无 secret 会由后端校验
}
modalApi.setState({ confirmLoading: true });
try {
await updateOaRobotAppCredential({
id: robotId.value,
use_platform_credential: usePlatform,
corp_id: String(values.corp_id || ''),
secret: String(values.secret || ''),
agent_id: Number(values.agent_id || 0) || undefined,
chat_id: String(values.chat_id || ''),
});
message.success('凭证已更新');
gridApi.value?.query?.() || gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<{ row: any; gridApi: any }>() ?? {};
gridApi.value = data.gridApi;
const row = data.row || {};
robotId.value = Number(row.id || 0);
formApi.setValues({
use_platform_credential: Number(row.use_platform_credential ?? 1),
corp_id: row.corp_id || '',
secret: '',
agent_id: Number(row.agent_id || 0) || undefined,
chat_id: row.chat_id || '',
});
modalApi.setState({
title: `改凭证 - ${row.name || ''}`,
});
},
});
</script>
<template>
<Modal class="w-[40%]">
<Alert
type="info"
show-icon
message="本弹窗仅保存凭证"
description="冒烟测试请关闭后在列表点击「测试」,并选择接收员工或群聊后再发送。"
style="margin-bottom: 16px"
/>
<Form />
</Modal>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,355 @@
<script lang="ts" setup>
/**
* 企微应用 API 场景投递目标面板
* 支持:选人(多选)+ 选群(多选)+ 每群独立 @员工 / @userid
* 客户群必须配置「确认发送员工」sender_useridadd_msg_template.sender
*/
import { computed, onMounted, ref, watch } from 'vue';
import {
Button,
Checkbox,
Collapse,
CollapsePanel,
Select,
Tag,
message,
} from 'ant-design-vue';
import { getOaChatListByPlatform } from '#/views/system/oa-chat/api';
import { getOaWwUserList } from '#/views/system/oa-chat/api/org';
export interface SceneTargetItem {
target_type: 1 | 2;
platform_code: string;
admin_id?: number;
chat_id?: number;
at_admin_ids?: number[];
at_userids?: string[];
/** 客户群群发确认人 userid */
sender_userid?: string;
}
const props = defineProps<{
modelValue: SceneTargetItem[];
adminList: Array<{ id: number; name: string }>;
platformCode?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: SceneTargetItem[]): void;
}>();
const platformCode = computed(() => props.platformCode || 'work_wechat_app');
/** 该平台下的群聊选项 */
const chatOptions = ref<
Array<{
id: number;
name: string;
chat_kind: number;
external_id: string;
}>
>([]);
/** 企微组织架构员工(确认发送人 / 可作参考) */
const wwUserOptions = ref<{ label: string; value: string }[]>([]);
const personAdminIds = computed({
get() {
return props.modelValue
.filter((t) => t.target_type === 1 && (t.admin_id || 0) > 0)
.map((t) => Number(t.admin_id));
},
set(ids: number[]) {
rebuild(ids, groupChatIds.value);
},
});
const groupChatIds = computed({
get() {
return props.modelValue
.filter((t) => t.target_type === 2 && (t.chat_id || 0) > 0)
.map((t) => Number(t.chat_id));
},
set(ids: number[]) {
rebuild(personAdminIds.value, ids);
},
});
/** 按 chat_id 取群目标行(含 @ / 确认人配置) */
function groupTargetOf(chatId: number): SceneTargetItem {
return (
props.modelValue.find(
(t) => t.target_type === 2 && Number(t.chat_id) === chatId,
) || {
target_type: 2,
platform_code: platformCode.value,
chat_id: chatId,
at_admin_ids: [],
at_userids: [],
sender_userid: '',
}
);
}
function chatMeta(chatId: number) {
return chatOptions.value.find((c) => c.id === chatId);
}
/**
* 重建 targets保留已有群的 @ / sender 配置,新增群默认空
*/
function rebuild(adminIds: number[], chatIds: number[]) {
const next: SceneTargetItem[] = [];
for (const id of adminIds) {
next.push({
target_type: 1,
platform_code: platformCode.value,
admin_id: id,
});
}
for (const chatId of chatIds) {
const prev = groupTargetOf(chatId);
next.push({
target_type: 2,
platform_code: platformCode.value,
chat_id: chatId,
at_admin_ids: [...(prev.at_admin_ids || [])],
at_userids: [...(prev.at_userids || [])],
sender_userid: prev.sender_userid || '',
});
}
emit('update:modelValue', next);
}
function updateGroupAt(
chatId: number,
patch: {
at_admin_ids?: number[];
at_userids?: string[];
sender_userid?: string;
},
) {
const next = props.modelValue.map((t) => {
if (t.target_type !== 2 || Number(t.chat_id) !== chatId) return t;
return {
...t,
at_admin_ids: patch.at_admin_ids ?? t.at_admin_ids ?? [],
at_userids: patch.at_userids ?? t.at_userids ?? [],
sender_userid:
patch.sender_userid !== undefined
? patch.sender_userid
: t.sender_userid || '',
};
});
emit('update:modelValue', next);
}
/**
* 保存前校验:任一客户群缺确认发送员工则拦截
* @returns 错误文案,通过返回空串
*/
function validateBeforeSubmit(): string {
for (const chatId of groupChatIds.value) {
const meta = chatMeta(chatId);
if (Number(meta?.chat_kind) !== 2) continue;
const sender = String(groupTargetOf(chatId).sender_userid || '').trim();
if (!sender) {
return `客户群「${meta?.name || chatId}」请选择确认发送员工`;
}
}
return '';
}
defineExpose({ validateBeforeSubmit });
async function loadChats() {
try {
const res = await getOaChatListByPlatform();
const grouped = res?.data ?? res ?? {};
const list = grouped[platformCode.value] || [];
chatOptions.value = (Array.isArray(list) ? list : []).map((c: any) => ({
id: Number(c.id),
name: String(c.name || ''),
chat_kind: Number(c.chat_kind ?? 2),
external_id: String(c.external_id || ''),
}));
} catch (e) {
console.error(e);
message.error('加载群聊列表失败');
}
}
async function loadWwUsers() {
try {
const userRes = await getOaWwUserList({
platform_code: platformCode.value,
page: 1,
pageSize: 200,
});
const userData = userRes?.data ?? userRes ?? {};
const items = Array.isArray(userData.items) ? userData.items : [];
wwUserOptions.value = items.map((u: any) => ({
label: u.name ? `${u.name}${u.userid}` : String(u.userid),
value: String(u.userid),
}));
} catch (e) {
console.error(e);
wwUserOptions.value = [];
}
}
onMounted(() => {
loadChats();
loadWwUsers();
});
watch(platformCode, () => {
loadChats();
loadWwUsers();
});
</script>
<template>
<div class="scene-targets-panel">
<div class="mb-3 text-xs text-gray-500">
同一场景可同时选人多群客户群发送为群发任务需指定确认员工并在企微客户端确认非实时
</div>
<!-- 发送给个人 -->
<div class="mb-4">
<div class="section-title">发送给个人</div>
<Checkbox.Group
v-model:value="personAdminIds"
class="flex flex-wrap gap-2"
>
<Checkbox
v-for="admin in adminList"
:key="admin.id"
:value="admin.id"
>
{{ admin.name }}
</Checkbox>
</Checkbox.Group>
<div v-if="adminList.length === 0" class="text-gray-400">暂无员工可选</div>
</div>
<!-- 发送给群 -->
<div class="mb-2">
<div class="section-title">发送给群</div>
<Select
v-model:value="groupChatIds"
mode="multiple"
allow-clear
class="w-full"
placeholder="选择群聊(可多选)"
:options="
chatOptions.map((c) => ({
label: `${c.name}${c.chat_kind === 2 ? '(客户群·需确认)' : '(应用群)'}`,
value: c.id,
}))
"
/>
</div>
<Collapse v-if="groupChatIds.length > 0" ghost>
<CollapsePanel
v-for="chatId in groupChatIds"
:key="chatId"
:header="chatMeta(chatId)?.name || `群#${chatId}`"
>
<template #extra>
<Tag
:color="chatMeta(chatId)?.chat_kind === 1 ? 'blue' : 'orange'"
@click.stop
>
{{
chatMeta(chatId)?.chat_kind === 1
? '应用群·实时'
: '客户群·需确认'
}}
</Tag>
</template>
<!-- 仅客户群确认发送员工必填 -->
<div
v-if="chatMeta(chatId)?.chat_kind === 2"
class="mb-3"
>
<div class="mb-1 text-xs">
确认发送员工 <span class="text-red-500">*</span>
</div>
<Select
show-search
allow-clear
class="w-full"
placeholder="选择在企微客户端确认群发的员工 userid"
:value="groupTargetOf(chatId).sender_userid || undefined"
:options="wwUserOptions"
:filter-option="
(input: string, option: any) =>
String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase())
"
@change="
(v: string) => updateGroupAt(chatId, { sender_userid: v || '' })
"
/>
<div class="mt-1 text-xs text-gray-400">
对应企微 add_msg_template.sender该员工需在客户端点确认后消息才会发出
</div>
</div>
<div class="mb-2 text-xs text-gray-500">本群 @ 配置</div>
<div class="mb-2">
<div class="mb-1 text-xs">@ 系统员工(手机号)</div>
<Select
mode="multiple"
class="w-full"
placeholder="选择要 @ 的员工"
:value="groupTargetOf(chatId).at_admin_ids || []"
:options="
adminList.map((a) => ({ label: a.name, value: a.id }))
"
@change="
(v: number[]) => updateGroupAt(chatId, { at_admin_ids: v })
"
/>
</div>
<div>
<div class="mb-1 text-xs">@ 群成员 userid</div>
<Select
mode="tags"
class="w-full"
placeholder="输入 userid 回车添加"
:token-separators="[',', ' ']"
:value="groupTargetOf(chatId).at_userids || []"
@change="
(v: string[]) => updateGroupAt(chatId, { at_userids: v })
"
/>
</div>
</CollapsePanel>
</Collapse>
<Button
v-if="chatOptions.length === 0"
type="link"
size="small"
class="px-0"
@click="loadChats"
>
刷新群聊列表
</Button>
</div>
</template>
<style scoped>
.section-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #1d2129;
}
</style>