Restore system sync configuration page
This commit is contained in:
@@ -238,15 +238,12 @@ backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py
|
||||
!backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py
|
||||
web/apps/web-ele/src/api/core/database-monitor.ts
|
||||
web/apps/web-ele/src/api/core/demo.ts
|
||||
web/apps/web-ele/src/api/core/dingtalk-sync.ts
|
||||
web/apps/web-ele/src/api/core/feishu-sync.ts
|
||||
web/apps/web-ele/src/api/core/link-preview.ts
|
||||
web/apps/web-ele/src/api/core/redis-manager.ts
|
||||
web/apps/web-ele/src/api/core/redis-monitor.ts
|
||||
web/apps/web-ele/src/api/core/region.ts
|
||||
web/apps/web-ele/src/api/core/scheduler.ts
|
||||
web/apps/web-ele/src/api/core/server-monitor.ts
|
||||
web/apps/web-ele/src/api/core/wecom-sync.ts
|
||||
web/apps/web-ele/src/router/routes/modules/wiki.ts
|
||||
web/apps/web-ele/src/views/_core/approval-center/
|
||||
web/apps/web-ele/src/views/_core/data-source/
|
||||
@@ -260,9 +257,6 @@ web/apps/web-ele/src/views/_core/redis-monitor/
|
||||
web/apps/web-ele/src/views/_core/region-manager/
|
||||
web/apps/web-ele/src/views/_core/scheduler/
|
||||
web/apps/web-ele/src/views/_core/server-monitor/
|
||||
web/apps/web-ele/src/views/_core/system-config/modules/dingtalk-sync-form.vue
|
||||
web/apps/web-ele/src/views/_core/system-config/modules/feishu-sync-form.vue
|
||||
web/apps/web-ele/src/views/_core/system-config/modules/wecom-sync-form.vue
|
||||
web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue
|
||||
web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel.vue
|
||||
!web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 连接测试请求
|
||||
*/
|
||||
export interface TestConnectionParams {
|
||||
app_key?: string;
|
||||
app_secret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步配置
|
||||
*/
|
||||
export interface DingtalkSyncConfig {
|
||||
callback_aes_key?: string;
|
||||
callback_token?: string;
|
||||
callback_url?: string;
|
||||
corp_id?: string;
|
||||
app_key?: string;
|
||||
app_secret?: string;
|
||||
sync_dept_id?: string;
|
||||
sync_root_dept_id?: string;
|
||||
enable_dept_event?: string;
|
||||
enable_user_event?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调状态
|
||||
*/
|
||||
export interface CallbackStatus {
|
||||
registered: boolean;
|
||||
callback_url?: string;
|
||||
subscribed_events?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步统计项
|
||||
*/
|
||||
export interface SyncTypeStats {
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
not_synced: number;
|
||||
status: null | string;
|
||||
sync_time: null | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步统计
|
||||
*/
|
||||
export interface SyncStats {
|
||||
dept: SyncTypeStats;
|
||||
user: SyncTypeStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 钉钉部门树节点
|
||||
*/
|
||||
export interface DingtalkDeptTreeNode {
|
||||
dept_id: number;
|
||||
name: string;
|
||||
children?: DingtalkDeptTreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步同步任务返回
|
||||
*/
|
||||
export interface SyncTaskResult {
|
||||
log_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步任务状态查询结果
|
||||
*/
|
||||
export interface SyncStatusResult {
|
||||
id: string;
|
||||
sync_type: string;
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
status: string;
|
||||
started_at: null | string;
|
||||
finished_at: null | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream 增量事件日志
|
||||
*/
|
||||
export interface StreamEventLog {
|
||||
id: string;
|
||||
event_type: string;
|
||||
target_type: string;
|
||||
target_name: null | string;
|
||||
dingtalk_dept_id: null | string;
|
||||
dingtalk_userid: null | string;
|
||||
status: string;
|
||||
error_detail: null | string;
|
||||
event_time: null | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream 事件日志分页结果
|
||||
*/
|
||||
export interface StreamEventsResult {
|
||||
items: StreamEventLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接测试
|
||||
*/
|
||||
export async function testConnectionApi(data: TestConnectionParams) {
|
||||
return requestClient.post('/api/core/dingtalk-sync/test-connection', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步配置
|
||||
*/
|
||||
export async function getSyncConfigApi() {
|
||||
return requestClient.get<Record<string, any>>(
|
||||
'/api/core/dingtalk-sync/config',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存同步配置
|
||||
*/
|
||||
export async function updateSyncConfigApi(data: DingtalkSyncConfig) {
|
||||
return requestClient.put('/api/core/dingtalk-sync/config', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步组织架构(异步任务)
|
||||
*/
|
||||
export async function syncDeptApi() {
|
||||
return requestClient.post<SyncTaskResult>('/api/core/dingtalk-sync/sync/dept');
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步用户(异步任务)
|
||||
*/
|
||||
export async function syncUserApi() {
|
||||
return requestClient.post<SyncTaskResult>('/api/core/dingtalk-sync/sync/user');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询同步任务状态
|
||||
*/
|
||||
export async function getSyncStatusApi(logId: string) {
|
||||
return requestClient.get<SyncStatusResult>(
|
||||
`/api/core/dingtalk-sync/sync/status/${logId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Stream 增量事件日志
|
||||
*/
|
||||
export async function getStreamEventsApi(page: number = 1, pageSize: number = 20) {
|
||||
return requestClient.get<StreamEventsResult>('/api/core/dingtalk-sync/stream/events', {
|
||||
params: { page, page_size: pageSize },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步统计
|
||||
*/
|
||||
export async function getSyncStatsApi() {
|
||||
return requestClient.get<SyncStats>('/api/core/dingtalk-sync/stats');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步日志
|
||||
*/
|
||||
export async function getSyncLogsApi(page: number = 1, pageSize: number = 20) {
|
||||
return requestClient.get('/api/core/dingtalk-sync/logs', {
|
||||
params: { page, page_size: pageSize },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钉钉部门树(选择同步范围)
|
||||
*/
|
||||
export async function getDeptTreeApi(data?: TestConnectionParams) {
|
||||
return requestClient.post<DingtalkDeptTreeNode[]>(
|
||||
'/api/core/dingtalk-sync/dept-tree',
|
||||
data || {},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册事件回调
|
||||
*/
|
||||
export async function registerCallbackApi() {
|
||||
return requestClient.post('/api/core/dingtalk-sync/callback/register');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除事件回调
|
||||
*/
|
||||
export async function deleteCallbackApi() {
|
||||
return requestClient.delete('/api/core/dingtalk-sync/callback/register');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回调注册状态
|
||||
*/
|
||||
export async function getCallbackStatusApi() {
|
||||
return requestClient.get<CallbackStatus>(
|
||||
'/api/core/dingtalk-sync/callback/status',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream 模式连接状态
|
||||
*/
|
||||
export interface StreamStatus {
|
||||
stream_mode: boolean;
|
||||
running: boolean;
|
||||
total_events: number;
|
||||
last_event_type: string | null;
|
||||
last_event_time: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Stream 模式状态
|
||||
*/
|
||||
export async function getStreamStatusApi() {
|
||||
return requestClient.get<StreamStatus>(
|
||||
'/api/core/dingtalk-sync/stream/status',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 连接测试请求
|
||||
*/
|
||||
export interface TestConnectionParams {
|
||||
app_id?: string;
|
||||
app_secret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步配置
|
||||
*/
|
||||
export interface FeishuSyncConfig {
|
||||
app_id?: string;
|
||||
app_secret?: string;
|
||||
sync_dept_id?: string;
|
||||
sync_root_dept_id?: string;
|
||||
enable_dept_event?: string;
|
||||
enable_user_event?: string;
|
||||
encrypt_key?: string;
|
||||
verification_token?: string;
|
||||
callback_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调状态
|
||||
*/
|
||||
export interface CallbackStatus {
|
||||
registered: boolean;
|
||||
callback_url?: string;
|
||||
subscribed_events?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步统计项
|
||||
*/
|
||||
export interface SyncTypeStats {
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
not_synced: number;
|
||||
status: null | string;
|
||||
sync_time: null | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步统计
|
||||
*/
|
||||
export interface SyncStats {
|
||||
dept: SyncTypeStats;
|
||||
user: SyncTypeStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 飞书部门树节点
|
||||
*/
|
||||
export interface FeishuDeptTreeNode {
|
||||
dept_id: string;
|
||||
name: string;
|
||||
children?: FeishuDeptTreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接测试
|
||||
*/
|
||||
export async function testConnectionApi(data: TestConnectionParams) {
|
||||
return requestClient.post('/api/core/feishu-sync/test-connection', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步配置
|
||||
*/
|
||||
export async function getSyncConfigApi() {
|
||||
return requestClient.get<Record<string, any>>(
|
||||
'/api/core/feishu-sync/config',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存同步配置
|
||||
*/
|
||||
export async function updateSyncConfigApi(data: FeishuSyncConfig) {
|
||||
return requestClient.put('/api/core/feishu-sync/config', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步组织架构
|
||||
*/
|
||||
export async function syncDeptApi() {
|
||||
return requestClient.post('/api/core/feishu-sync/sync/dept');
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步用户
|
||||
*/
|
||||
export async function syncUserApi() {
|
||||
return requestClient.post('/api/core/feishu-sync/sync/user');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步统计
|
||||
*/
|
||||
export async function getSyncStatsApi() {
|
||||
return requestClient.get<SyncStats>('/api/core/feishu-sync/stats');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步日志
|
||||
*/
|
||||
export async function getSyncLogsApi(page: number = 1, pageSize: number = 20) {
|
||||
return requestClient.get('/api/core/feishu-sync/logs', {
|
||||
params: { page, page_size: pageSize },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取飞书部门树(选择同步范围)
|
||||
*/
|
||||
export async function getDeptTreeApi(data?: TestConnectionParams) {
|
||||
return requestClient.post<FeishuDeptTreeNode[]>(
|
||||
'/api/core/feishu-sync/dept-tree',
|
||||
data || {},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回调注册状态
|
||||
*/
|
||||
export async function getCallbackStatusApi() {
|
||||
return requestClient.get<CallbackStatus>(
|
||||
'/api/core/feishu-sync/callback/status',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface TestConnectionParams {
|
||||
corp_id?: string;
|
||||
corp_secret?: string;
|
||||
}
|
||||
|
||||
export interface WecomSyncConfig {
|
||||
callback_aes_key?: string;
|
||||
callback_token?: string;
|
||||
callback_url?: string;
|
||||
corp_id?: string;
|
||||
corp_secret?: string;
|
||||
sync_dept_id?: string;
|
||||
sync_root_dept_id?: string;
|
||||
enable_dept_event?: string;
|
||||
enable_user_event?: string;
|
||||
}
|
||||
|
||||
export interface CallbackStatus {
|
||||
registered: boolean;
|
||||
callback_url?: string;
|
||||
subscribed_events?: string[];
|
||||
}
|
||||
|
||||
export interface SyncTypeStats {
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
not_synced: number;
|
||||
status: null | string;
|
||||
sync_time: null | string;
|
||||
}
|
||||
|
||||
export interface SyncStats {
|
||||
dept: SyncTypeStats;
|
||||
user: SyncTypeStats;
|
||||
}
|
||||
|
||||
export interface WecomDeptTreeNode {
|
||||
dept_id: number;
|
||||
name: string;
|
||||
children?: WecomDeptTreeNode[];
|
||||
}
|
||||
|
||||
export async function testConnectionApi(data: TestConnectionParams) {
|
||||
return requestClient.post('/api/core/wecom-sync/test-connection', data);
|
||||
}
|
||||
|
||||
export async function getSyncConfigApi() {
|
||||
return requestClient.get<Record<string, any>>(
|
||||
'/api/core/wecom-sync/config',
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSyncConfigApi(data: WecomSyncConfig) {
|
||||
return requestClient.put('/api/core/wecom-sync/config', data);
|
||||
}
|
||||
|
||||
export async function syncDeptApi() {
|
||||
return requestClient.post('/api/core/wecom-sync/sync/dept');
|
||||
}
|
||||
|
||||
export async function syncUserApi() {
|
||||
return requestClient.post('/api/core/wecom-sync/sync/user');
|
||||
}
|
||||
|
||||
export async function getSyncStatsApi() {
|
||||
return requestClient.get<SyncStats>('/api/core/wecom-sync/stats');
|
||||
}
|
||||
|
||||
export async function getDeptTreeApi(data?: TestConnectionParams) {
|
||||
return requestClient.post<WecomDeptTreeNode[]>(
|
||||
'/api/core/wecom-sync/dept-tree',
|
||||
data || {},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCallbackStatusApi() {
|
||||
return requestClient.get<CallbackStatus>(
|
||||
'/api/core/wecom-sync/callback/status',
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,9 @@ const modules = import.meta.glob([
|
||||
'./langs/*/chat.json',
|
||||
'./langs/*/common.json',
|
||||
'./langs/*/dept.json',
|
||||
'./langs/*/dingtalk-sync.json',
|
||||
'./langs/*/dict.json',
|
||||
'./langs/*/feishu-sync.json',
|
||||
'./langs/*/file-manager.json',
|
||||
'./langs/*/form-manager.json',
|
||||
'./langs/*/loginLog.json',
|
||||
@@ -49,6 +51,7 @@ const modules = import.meta.glob([
|
||||
'./langs/*/ui-config.json',
|
||||
'./langs/*/user-avatar.json',
|
||||
'./langs/*/user.json',
|
||||
'./langs/*/wecom-sync.json',
|
||||
]);
|
||||
|
||||
const localesMap = loadLocalesMapFromDir(
|
||||
|
||||
@@ -8,7 +8,9 @@ import { Page } from '@vben/common-ui';
|
||||
import {
|
||||
BellRing,
|
||||
Bot,
|
||||
CircleHelp,
|
||||
IconifyIcon,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
} from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
@@ -26,10 +28,14 @@ import {
|
||||
getAllConfigsApi,
|
||||
} from '#/api/core/system-config';
|
||||
import { CardList } from '#/components/card-list';
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
import { ZqTabs } from '#/components/zq-tabs';
|
||||
|
||||
import ConfigForm from './modules/config-form.vue';
|
||||
import DingtalkSyncForm from './modules/dingtalk-sync-form.vue';
|
||||
import ModelConfigForm from './modules/model-config-form.vue';
|
||||
import FeishuSyncForm from './modules/feishu-sync-form.vue';
|
||||
import WecomSyncForm from './modules/wecom-sync-form.vue';
|
||||
|
||||
defineOptions({ name: 'SystemConfigManager' });
|
||||
|
||||
@@ -38,7 +44,7 @@ interface ConfigMenuItem extends CardListItem {
|
||||
name: string;
|
||||
group: string;
|
||||
icon: string;
|
||||
category: 'notify' | 'oauth';
|
||||
category: 'notify' | 'oauth' | 'sync';
|
||||
}
|
||||
|
||||
const SSO_GROUPS = [
|
||||
@@ -48,14 +54,26 @@ const SSO_GROUPS = [
|
||||
'oauth_google',
|
||||
'oauth_wechat',
|
||||
'oauth_microsoft',
|
||||
'oauth_dingtalk',
|
||||
'oauth_feishu',
|
||||
'oauth_wecom',
|
||||
];
|
||||
|
||||
const NOTIFY_GROUPS = [
|
||||
'notify_email',
|
||||
'notify_sms',
|
||||
'notify_dingtalk',
|
||||
'notify_feishu',
|
||||
'notify_wecom',
|
||||
'notify_wechat_mp',
|
||||
];
|
||||
|
||||
const SYNC_GROUPS = [
|
||||
'sync_dingtalk',
|
||||
'sync_wecom',
|
||||
'sync_feishu',
|
||||
];
|
||||
|
||||
const GROUP_ICONS: Record<string, string> = {
|
||||
oauth_gitee: 'simple-icons:gitee',
|
||||
oauth_github: 'simple-icons:github',
|
||||
@@ -63,9 +81,18 @@ const GROUP_ICONS: Record<string, string> = {
|
||||
oauth_google: 'simple-icons:google',
|
||||
oauth_wechat: 'simple-icons:wechat',
|
||||
oauth_microsoft: 'simple-icons:microsoft',
|
||||
oauth_dingtalk: 'ri:dingding-line',
|
||||
oauth_feishu: 'simple-icons:bytedance',
|
||||
oauth_wecom: 'simple-icons:wechat',
|
||||
notify_email: 'mdi:email-outline',
|
||||
notify_sms: 'mdi:message-text-outline',
|
||||
notify_dingtalk: 'ri:dingding-line',
|
||||
notify_feishu: 'simple-icons:bytedance',
|
||||
notify_wecom: 'simple-icons:wechat',
|
||||
notify_wechat_mp: 'simple-icons:wechat',
|
||||
sync_dingtalk: 'ri:dingding-line',
|
||||
sync_wecom: 'simple-icons:wechat',
|
||||
sync_feishu: 'simple-icons:bytedance',
|
||||
};
|
||||
|
||||
const activeTab = ref<string>('oauth');
|
||||
@@ -74,6 +101,11 @@ const selectedMenuId = ref<string>('oauth_gitee');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const configFormRef = ref<InstanceType<typeof ConfigForm>>();
|
||||
const syncFormRef = ref<InstanceType<typeof DingtalkSyncForm>>();
|
||||
const wecomSyncFormRef = ref<InstanceType<typeof WecomSyncForm>>();
|
||||
const feishuSyncFormRef = ref<InstanceType<typeof FeishuSyncForm>>();
|
||||
const syncSaving = ref(false);
|
||||
const showGuideDialog = ref(false);
|
||||
|
||||
const menuItems = computed<ConfigMenuItem[]>(() => {
|
||||
const items: ConfigMenuItem[] = [];
|
||||
@@ -98,6 +130,16 @@ const menuItems = computed<ConfigMenuItem[]>(() => {
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of SYNC_GROUPS) {
|
||||
items.push({
|
||||
id: group,
|
||||
name: $t(`system-config.groups.${group}`),
|
||||
group,
|
||||
icon: GROUP_ICONS[group] || 'mdi:sync',
|
||||
category: 'sync',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
@@ -115,6 +157,7 @@ const tabItems = computed<ZqTabItem[]>(() => [
|
||||
{ key: 'oauth', label: $t('system-config.ssoConfig'), icon: Shield },
|
||||
{ key: 'notify', label: $t('system-config.notifyConfig'), icon: BellRing },
|
||||
{ key: 'model', label: $t('system-config.modelConfig'), icon: Bot },
|
||||
{ key: 'sync', label: $t('system-config.syncConfig'), icon: RefreshCw },
|
||||
]);
|
||||
|
||||
const cardListOptions: CardListOptions<ConfigMenuItem> = {
|
||||
@@ -176,6 +219,21 @@ function handleSaved() {
|
||||
loadAllConfigs();
|
||||
}
|
||||
|
||||
async function handleSyncSave() {
|
||||
syncSaving.value = true;
|
||||
try {
|
||||
if (selectedMenuId.value === 'sync_wecom') {
|
||||
await wecomSyncFormRef.value?.save();
|
||||
} else if (selectedMenuId.value === 'sync_feishu') {
|
||||
await feishuSyncFormRef.value?.save();
|
||||
} else {
|
||||
await syncFormRef.value?.save();
|
||||
}
|
||||
} finally {
|
||||
syncSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAllConfigs();
|
||||
});
|
||||
@@ -194,7 +252,7 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 列表(oauth/notify tab 显示) -->
|
||||
<!-- 列表(oauth/notify/sync tab 显示) -->
|
||||
<template v-if="activeTab !== 'model'">
|
||||
<div class="mr-3 w-[250px] flex-shrink-0">
|
||||
<ElCard shadow="never" class="h-full !border-none">
|
||||
@@ -220,6 +278,99 @@ onMounted(() => {
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<!-- 同步配置 -->
|
||||
<template v-if="activeTab === 'sync'">
|
||||
<ElCard
|
||||
shadow="never"
|
||||
class="config-card flex h-full flex-col !border-none"
|
||||
>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<IconifyIcon
|
||||
:icon="selectedItem?.icon || ''"
|
||||
class="mr-2 size-5 opacity-60"
|
||||
/>
|
||||
<span>{{ selectedItem?.name }}</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<CircleHelp
|
||||
class="size-5 cursor-pointer opacity-50 transition-opacity hover:opacity-100"
|
||||
@click="showGuideDialog = true"
|
||||
/>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="syncSaving"
|
||||
@click="handleSyncSave"
|
||||
>
|
||||
{{ $t('system-config.save') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<DingtalkSyncForm
|
||||
v-if="selectedMenuId === 'sync_dingtalk'"
|
||||
ref="syncFormRef"
|
||||
/>
|
||||
<WecomSyncForm
|
||||
v-else-if="selectedMenuId === 'sync_wecom'"
|
||||
ref="wecomSyncFormRef"
|
||||
/>
|
||||
<FeishuSyncForm
|
||||
v-else-if="selectedMenuId === 'sync_feishu'"
|
||||
ref="feishuSyncFormRef"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<!-- 配置步骤弹窗 -->
|
||||
<ZqDialog
|
||||
v-model="showGuideDialog"
|
||||
:title="selectedMenuId === 'sync_wecom' ? $t('wecom-sync.guideTitle') : selectedMenuId === 'sync_feishu' ? $t('feishu-sync.guideTitle') : $t('dingtalk-sync.guideTitle')"
|
||||
width="680px"
|
||||
:show-footer="false"
|
||||
max-height="70vh"
|
||||
>
|
||||
<div class="space-y-5 px-2 py-1">
|
||||
<div
|
||||
v-for="(step, idx) in (selectedMenuId === 'sync_wecom' ? 5 : selectedMenuId === 'sync_feishu' ? 5 : 6)"
|
||||
:key="`${selectedMenuId}-${idx}`"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex size-7 flex-shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
|
||||
style="background-color: var(--el-color-primary)"
|
||||
>
|
||||
{{ idx + 1 }}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="mb-1 text-sm font-semibold"
|
||||
style="color: var(--el-text-color-primary)"
|
||||
>
|
||||
{{ selectedMenuId === 'sync_wecom'
|
||||
? $t(`wecom-sync.guideStep${idx + 1}Title`)
|
||||
: selectedMenuId === 'sync_feishu'
|
||||
? $t(`feishu-sync.guideStep${idx + 1}Title`)
|
||||
: $t(`dingtalk-sync.guideStep${idx + 1}Title`)
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm leading-relaxed"
|
||||
style="color: var(--el-text-color-secondary)"
|
||||
>
|
||||
{{ selectedMenuId === 'sync_wecom'
|
||||
? $t(`wecom-sync.guideStep${idx + 1}Desc`)
|
||||
: selectedMenuId === 'sync_feishu'
|
||||
? $t(`feishu-sync.guideStep${idx + 1}Desc`)
|
||||
: $t(`dingtalk-sync.guideStep${idx + 1}Desc`)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ZqDialog>
|
||||
</template>
|
||||
|
||||
<!-- SSO/通知配置:通用表单 -->
|
||||
<template v-else>
|
||||
<ElCard
|
||||
shadow="never"
|
||||
class="config-card flex h-full flex-col !border-none"
|
||||
@@ -256,6 +407,7 @@ onMounted(() => {
|
||||
/>
|
||||
</ElScrollbar>
|
||||
</ElCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
DingtalkDeptTreeNode,
|
||||
StreamEventLog,
|
||||
StreamStatus,
|
||||
SyncTypeStats,
|
||||
} from '#/api/core/dingtalk-sync';
|
||||
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { Link2, RefreshCw } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElCheckbox,
|
||||
ElDivider,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElPagination,
|
||||
ElScrollbar,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag,
|
||||
ElTreeSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
getDeptTreeApi,
|
||||
getStreamEventsApi,
|
||||
getStreamStatusApi,
|
||||
getSyncConfigApi,
|
||||
getSyncStatsApi,
|
||||
getSyncStatusApi,
|
||||
syncDeptApi,
|
||||
syncUserApi,
|
||||
testConnectionApi,
|
||||
updateSyncConfigApi,
|
||||
} from '#/api/core/dingtalk-sync';
|
||||
|
||||
defineOptions({ name: 'DingtalkSyncForm' });
|
||||
|
||||
// ==================== 配置表单 ====================
|
||||
const configForm = ref({
|
||||
corp_id: '',
|
||||
app_key: '',
|
||||
app_secret: '',
|
||||
sync_dept_id: '',
|
||||
sync_root_dept_id: '',
|
||||
enable_dept_event: '',
|
||||
enable_user_event: '',
|
||||
callback_url: '',
|
||||
callback_token: '',
|
||||
callback_aes_key: '',
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const testing = ref(false);
|
||||
const syncingDept = ref(false);
|
||||
const syncingUser = ref(false);
|
||||
|
||||
// ==================== Stream 模式状态 ====================
|
||||
const streamStatus = ref<StreamStatus>({
|
||||
stream_mode: true,
|
||||
running: false,
|
||||
total_events: 0,
|
||||
last_event_type: null,
|
||||
last_event_time: null,
|
||||
});
|
||||
const streamLoading = ref(false);
|
||||
|
||||
// ==================== 同步统计 ====================
|
||||
interface StatsRow {
|
||||
type: string;
|
||||
typeLabel: string;
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
not_synced: number;
|
||||
status: null | string;
|
||||
sync_time: null | string;
|
||||
}
|
||||
|
||||
const statsData = ref<StatsRow[]>([
|
||||
{
|
||||
type: 'dept',
|
||||
typeLabel: '',
|
||||
total_count: 0,
|
||||
success_count: 0,
|
||||
fail_count: 0,
|
||||
not_synced: 0,
|
||||
status: null,
|
||||
sync_time: null,
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
typeLabel: '',
|
||||
total_count: 0,
|
||||
success_count: 0,
|
||||
fail_count: 0,
|
||||
not_synced: 0,
|
||||
status: null,
|
||||
sync_time: null,
|
||||
},
|
||||
]);
|
||||
|
||||
// ==================== 触发事件 ====================
|
||||
interface EventRow {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const eventsData = ref<EventRow[]>([
|
||||
{
|
||||
key: 'enable_dept_event',
|
||||
label: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
key: 'enable_user_event',
|
||||
label: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// ==================== 部门树(同步范围选择) ====================
|
||||
const deptTreeData = ref<any[]>([]);
|
||||
const deptTreeLoading = ref(false);
|
||||
const hasSynced = ref(false);
|
||||
|
||||
function transformDeptTree(
|
||||
nodes: DingtalkDeptTreeNode[],
|
||||
): Array<{ children?: any[]; label: string; value: string }> {
|
||||
return nodes.map((node) => ({
|
||||
value: String(node.dept_id),
|
||||
label: node.name,
|
||||
children:
|
||||
node.children && node.children.length > 0
|
||||
? transformDeptTree(node.children)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// ==================== 轮询 ====================
|
||||
let pollTimerDept: null | ReturnType<typeof setInterval> = null;
|
||||
let pollTimerUser: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
function startPolling(logId: string, type: 'dept' | 'user') {
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const raw: any = await getSyncStatusApi(logId);
|
||||
const res = raw?.data || raw;
|
||||
const idx = type === 'dept' ? 0 : 1;
|
||||
if (res.status !== 'not_found') {
|
||||
statsData.value[idx] = {
|
||||
...statsData.value[idx]!,
|
||||
total_count: res.total_count,
|
||||
success_count: res.success_count,
|
||||
fail_count: res.fail_count,
|
||||
not_synced: Math.max(
|
||||
0,
|
||||
res.total_count - res.success_count - res.fail_count,
|
||||
),
|
||||
status: res.status,
|
||||
sync_time: res.finished_at,
|
||||
};
|
||||
}
|
||||
if (res.status && res.status !== 'running') {
|
||||
stopPolling(type);
|
||||
if (type === 'dept') syncingDept.value = false;
|
||||
else syncingUser.value = false;
|
||||
await loadStats();
|
||||
}
|
||||
} catch {
|
||||
stopPolling(type);
|
||||
if (type === 'dept') syncingDept.value = false;
|
||||
else syncingUser.value = false;
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
if (type === 'dept') pollTimerDept = timer;
|
||||
else pollTimerUser = timer;
|
||||
}
|
||||
|
||||
function stopPolling(type: 'dept' | 'user') {
|
||||
if (type === 'dept' && pollTimerDept) {
|
||||
clearInterval(pollTimerDept);
|
||||
pollTimerDept = null;
|
||||
}
|
||||
if (type === 'user' && pollTimerUser) {
|
||||
clearInterval(pollTimerUser);
|
||||
pollTimerUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Stream 增量事件日志 ====================
|
||||
const streamEvents = ref<StreamEventLog[]>([]);
|
||||
const streamEventsTotal = ref(0);
|
||||
const streamEventsPage = ref(1);
|
||||
const streamEventsPageSize = ref(10);
|
||||
const streamEventsLoading = ref(false);
|
||||
|
||||
async function loadStreamEvents() {
|
||||
streamEventsLoading.value = true;
|
||||
try {
|
||||
const raw: any = await getStreamEventsApi(
|
||||
streamEventsPage.value,
|
||||
streamEventsPageSize.value,
|
||||
);
|
||||
const res = raw?.data || raw;
|
||||
streamEvents.value = res.items || [];
|
||||
streamEventsTotal.value = res.total || 0;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
streamEventsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEventsPageChange(page: number) {
|
||||
streamEventsPage.value = page;
|
||||
loadStreamEvents();
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
function updateLabels() {
|
||||
statsData.value[0]!.typeLabel = $t('dingtalk-sync.syncDept');
|
||||
statsData.value[1]!.typeLabel = $t('dingtalk-sync.syncUser');
|
||||
eventsData.value[0]!.label = $t('dingtalk-sync.enableSyncDept');
|
||||
eventsData.value[0]!.description = $t('dingtalk-sync.enableSyncDeptDesc');
|
||||
eventsData.value[1]!.label = $t('dingtalk-sync.enableSyncUser');
|
||||
eventsData.value[1]!.description = $t('dingtalk-sync.enableSyncUserDesc');
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const config = await getSyncConfigApi();
|
||||
configForm.value.corp_id = config.corp_id || '';
|
||||
configForm.value.app_key = config.app_key || '';
|
||||
configForm.value.app_secret = config.app_secret || '';
|
||||
configForm.value.sync_dept_id = config.sync_dept_id || '';
|
||||
configForm.value.sync_root_dept_id = config.sync_root_dept_id || '';
|
||||
configForm.value.enable_dept_event = config.enable_dept_event || '';
|
||||
configForm.value.enable_user_event = config.enable_user_event || '';
|
||||
configForm.value.callback_url = config.callback_url || '';
|
||||
configForm.value.callback_token = config.callback_token || '';
|
||||
configForm.value.callback_aes_key = config.callback_aes_key || '';
|
||||
|
||||
eventsData.value[0]!.enabled = config.enable_dept_event === 'true';
|
||||
eventsData.value[1]!.enabled = config.enable_user_event === 'true';
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const stats = await getSyncStatsApi();
|
||||
const deptStats: SyncTypeStats = stats.dept;
|
||||
const userStats: SyncTypeStats = stats.user;
|
||||
|
||||
statsData.value[0] = {
|
||||
...statsData.value[0]!,
|
||||
total_count: deptStats.total_count,
|
||||
success_count: deptStats.success_count,
|
||||
fail_count: deptStats.fail_count,
|
||||
not_synced: deptStats.not_synced,
|
||||
status: deptStats.status,
|
||||
sync_time: deptStats.sync_time,
|
||||
};
|
||||
statsData.value[1] = {
|
||||
...statsData.value[1]!,
|
||||
total_count: userStats.total_count,
|
||||
success_count: userStats.success_count,
|
||||
fail_count: userStats.fail_count,
|
||||
not_synced: userStats.not_synced,
|
||||
status: userStats.status,
|
||||
sync_time: userStats.sync_time,
|
||||
};
|
||||
|
||||
hasSynced.value = deptStats.success_count > 0;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 连接测试 ====================
|
||||
async function handleTestConnection() {
|
||||
testing.value = true;
|
||||
try {
|
||||
await testConnectionApi({
|
||||
app_key: configForm.value.app_key,
|
||||
app_secret: configForm.value.app_secret,
|
||||
});
|
||||
ElMessage.success($t('dingtalk-sync.testSuccess'));
|
||||
await loadDeptTree();
|
||||
} catch {
|
||||
ElMessage.error($t('dingtalk-sync.testFail'));
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 加载部门树 ====================
|
||||
async function loadDeptTree() {
|
||||
deptTreeLoading.value = true;
|
||||
try {
|
||||
const tree = await getDeptTreeApi({
|
||||
app_key: configForm.value.app_key,
|
||||
app_secret: configForm.value.app_secret,
|
||||
});
|
||||
deptTreeData.value = transformDeptTree(tree);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
deptTreeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 同步操作 ====================
|
||||
async function handleSyncDept() {
|
||||
syncingDept.value = true;
|
||||
statsData.value[0]!.status = 'running';
|
||||
try {
|
||||
const res: any = await syncDeptApi();
|
||||
const logId = res?.log_id || res?.data?.log_id;
|
||||
if (logId) {
|
||||
startPolling(logId, 'dept');
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error($t('dingtalk-sync.syncFail'));
|
||||
syncingDept.value = false;
|
||||
statsData.value[0]!.status = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncUser() {
|
||||
syncingUser.value = true;
|
||||
statsData.value[1]!.status = 'running';
|
||||
try {
|
||||
const res: any = await syncUserApi();
|
||||
const logId = res?.log_id || res?.data?.log_id;
|
||||
if (logId) {
|
||||
startPolling(logId, 'user');
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error($t('dingtalk-sync.syncFail'));
|
||||
syncingUser.value = false;
|
||||
statsData.value[1]!.status = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSync(row: StatsRow) {
|
||||
if (row.type === 'dept') {
|
||||
handleSyncDept();
|
||||
} else {
|
||||
handleSyncUser();
|
||||
}
|
||||
}
|
||||
|
||||
function isSyncing(row: StatsRow): boolean {
|
||||
return row.type === 'dept' ? syncingDept.value : syncingUser.value;
|
||||
}
|
||||
|
||||
// ==================== 状态 Tag ====================
|
||||
function getStatusType(
|
||||
status: null | string,
|
||||
): 'danger' | 'info' | 'success' | 'warning' {
|
||||
switch (status) {
|
||||
case 'failed': {
|
||||
return 'danger';
|
||||
}
|
||||
case 'partial': {
|
||||
return 'warning';
|
||||
}
|
||||
case 'running': {
|
||||
return 'info';
|
||||
}
|
||||
case 'success': {
|
||||
return 'success';
|
||||
}
|
||||
default: {
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: null | string): string {
|
||||
switch (status) {
|
||||
case 'failed': {
|
||||
return $t('dingtalk-sync.statusFailed');
|
||||
}
|
||||
case 'partial': {
|
||||
return $t('dingtalk-sync.statusPartial');
|
||||
}
|
||||
case 'running': {
|
||||
return $t('dingtalk-sync.statusRunning');
|
||||
}
|
||||
case 'success': {
|
||||
return $t('dingtalk-sync.statusSuccess');
|
||||
}
|
||||
default: {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 事件类型格式化 ====================
|
||||
function getEventTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
org_dept_create: $t('dingtalk-sync.eventCreate'),
|
||||
org_dept_modify: $t('dingtalk-sync.eventModify'),
|
||||
org_dept_remove: $t('dingtalk-sync.eventRemove'),
|
||||
user_add_org: $t('dingtalk-sync.eventAddUser'),
|
||||
user_modify_org: $t('dingtalk-sync.eventModifyUser'),
|
||||
user_leave_org: $t('dingtalk-sync.eventLeaveUser'),
|
||||
user_active_org: $t('dingtalk-sync.eventActiveUser'),
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
function getEventTypeTagType(
|
||||
type: string,
|
||||
): '' | 'danger' | 'success' | 'warning' {
|
||||
if (
|
||||
type.includes('create') ||
|
||||
type.includes('add') ||
|
||||
type.includes('active')
|
||||
)
|
||||
return 'success';
|
||||
if (type.includes('modify')) return '';
|
||||
if (type.includes('remove') || type.includes('leave')) return 'danger';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
function getTargetTypeLabel(type: string): string {
|
||||
if (type === 'dept') return $t('dingtalk-sync.syncDept');
|
||||
if (type === 'user') return $t('dingtalk-sync.syncUser');
|
||||
return type;
|
||||
}
|
||||
|
||||
// ==================== 保存配置 ====================
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateSyncConfigApi({
|
||||
corp_id: configForm.value.corp_id || undefined,
|
||||
app_key: configForm.value.app_key || undefined,
|
||||
app_secret: configForm.value.app_secret || undefined,
|
||||
sync_dept_id: configForm.value.sync_dept_id || undefined,
|
||||
sync_root_dept_id: configForm.value.sync_root_dept_id || undefined,
|
||||
enable_dept_event: eventsData.value[0]!.enabled ? 'true' : 'false',
|
||||
enable_user_event: eventsData.value[1]!.enabled ? 'true' : 'false',
|
||||
callback_url: configForm.value.callback_url || undefined,
|
||||
callback_token: configForm.value.callback_token || undefined,
|
||||
callback_aes_key: configForm.value.callback_aes_key || undefined,
|
||||
});
|
||||
ElMessage.success($t('dingtalk-sync.saveSuccess'));
|
||||
} catch {
|
||||
ElMessage.error($t('dingtalk-sync.saveFail'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ save: handleSave, saving });
|
||||
|
||||
// ==================== Stream 模式状态 ====================
|
||||
async function loadStreamStatus() {
|
||||
streamLoading.value = true;
|
||||
try {
|
||||
const raw: any = await getStreamStatusApi();
|
||||
const res = raw?.data || raw;
|
||||
streamStatus.value = res;
|
||||
} catch {
|
||||
streamStatus.value.running = false;
|
||||
} finally {
|
||||
streamLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 格式化时间 ====================
|
||||
function formatTime(val: null | string): string {
|
||||
if (!val) return '';
|
||||
try {
|
||||
return new Date(val).toLocaleString();
|
||||
} catch {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
function formatEventType(type: null | string): string {
|
||||
if (!type) return $t('dingtalk-sync.streamEventNone');
|
||||
const labels: Record<string, string> = {
|
||||
user_add_org: `${$t('dingtalk-sync.syncUser')} +`,
|
||||
user_modify_org: `${$t('dingtalk-sync.syncUser')} ~`,
|
||||
user_leave_org: `${$t('dingtalk-sync.syncUser')} -`,
|
||||
user_active_org: `${$t('dingtalk-sync.syncUser')} ✓`,
|
||||
org_dept_create: `${$t('dingtalk-sync.syncDept')} +`,
|
||||
org_dept_modify: `${$t('dingtalk-sync.syncDept')} ~`,
|
||||
org_dept_remove: `${$t('dingtalk-sync.syncDept')} -`,
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updateLabels();
|
||||
await loadConfig();
|
||||
await Promise.all([loadStats(), loadStreamStatus(), loadStreamEvents()]);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling('dept');
|
||||
stopPolling('user');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<ElScrollbar class="flex-1">
|
||||
<div v-loading="loading" class="space-y-6 p-6">
|
||||
<!-- 凭证配置 -->
|
||||
<ElForm label-width="120px" label-position="left">
|
||||
<ElFormItem :label="$t('dingtalk-sync.corpId')">
|
||||
<ElInput
|
||||
v-model="configForm.corp_id"
|
||||
placeholder="请输入CorpId"
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('dingtalk-sync.appKey')">
|
||||
<ElInput
|
||||
v-model="configForm.app_key"
|
||||
:placeholder="$t('dingtalk-sync.appKeyPlaceholder')"
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('dingtalk-sync.appSecret')">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElInput
|
||||
v-model="configForm.app_secret"
|
||||
:placeholder="$t('dingtalk-sync.appSecretPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
<ElButton :loading="testing" @click="handleTestConnection">
|
||||
<template v-if="!testing" #icon>
|
||||
<Link2 class="size-4" />
|
||||
</template>
|
||||
{{
|
||||
testing
|
||||
? $t('dingtalk-sync.testing')
|
||||
: $t('dingtalk-sync.testConnection')
|
||||
}}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 同步范围 -->
|
||||
<ElFormItem :label="$t('dingtalk-sync.syncScope')">
|
||||
<ElTreeSelect
|
||||
v-model="configForm.sync_dept_id"
|
||||
:data="deptTreeData"
|
||||
:placeholder="$t('dingtalk-sync.syncScopePlaceholder')"
|
||||
:loading="deptTreeLoading"
|
||||
:disabled="false"
|
||||
check-strictly
|
||||
filterable
|
||||
class="!w-[400px]"
|
||||
node-key="value"
|
||||
:props="{ label: 'label', children: 'children' }"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<div
|
||||
class="mb-6 ml-[120px] text-xs"
|
||||
style="color: var(--el-text-color-secondary)"
|
||||
>
|
||||
{{
|
||||
hasSynced
|
||||
? $t('dingtalk-sync.syncScopeLocked')
|
||||
: $t('dingtalk-sync.syncScopeTip')
|
||||
}}
|
||||
</div>
|
||||
</ElForm>
|
||||
|
||||
<!-- 同步统计表 -->
|
||||
<div>
|
||||
<ElTable :data="statsData" border stripe>
|
||||
<ElTableColumn
|
||||
prop="typeLabel"
|
||||
:label="$t('dingtalk-sync.syncType')"
|
||||
width="100"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="total_count"
|
||||
:label="$t('dingtalk-sync.totalCount')"
|
||||
width="80"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="success_count"
|
||||
:label="$t('dingtalk-sync.successCount')"
|
||||
width="110"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="fail_count"
|
||||
:label="$t('dingtalk-sync.failCount')"
|
||||
width="110"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="not_synced"
|
||||
:label="$t('dingtalk-sync.notSynced')"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
:label="$t('dingtalk-sync.syncStatus')"
|
||||
width="110"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElTag
|
||||
v-if="row.status"
|
||||
:type="getStatusType(row.status)"
|
||||
size="small"
|
||||
>
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</ElTag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('dingtalk-sync.syncTime')"
|
||||
min-width="170"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.sync_time) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('dingtalk-sync.operation')"
|
||||
width="100"
|
||||
align="center"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElButton
|
||||
type="primary"
|
||||
link
|
||||
:loading="isSyncing(row)"
|
||||
:disabled="isSyncing(row)"
|
||||
@click="handleSync(row)"
|
||||
>
|
||||
<template v-if="!isSyncing(row)" #icon>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</template>
|
||||
{{
|
||||
isSyncing(row)
|
||||
? $t('dingtalk-sync.syncing')
|
||||
: $t('dingtalk-sync.sync')
|
||||
}}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<!-- 触发事件 -->
|
||||
<div>
|
||||
<div
|
||||
class="mb-3 text-sm font-medium"
|
||||
style="color: var(--el-text-color-primary)"
|
||||
>
|
||||
{{ $t('dingtalk-sync.triggerEvents') }}
|
||||
</div>
|
||||
<ElTable :data="eventsData" border stripe>
|
||||
<ElTableColumn width="60" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElCheckbox v-model="row.enabled" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
prop="label"
|
||||
:label="$t('dingtalk-sync.triggerEvent')"
|
||||
width="200"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="description"
|
||||
:label="$t('dingtalk-sync.description')"
|
||||
/>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<!-- Stream 模式(实时同步) -->
|
||||
<!-- <ElDivider />-->
|
||||
<!-- <div>-->
|
||||
<!-- <div-->
|
||||
<!-- class="mb-2 flex items-center gap-2 text-sm font-medium"-->
|
||||
<!-- style="color: var(--el-text-color-primary)"-->
|
||||
<!-- >-->
|
||||
<!-- {{ $t('dingtalk-sync.streamConfig') }}-->
|
||||
<!-- </div>-->
|
||||
<!-- <ElAlert-->
|
||||
<!-- type="info"-->
|
||||
<!-- :closable="false"-->
|
||||
<!-- show-icon-->
|
||||
<!-- class="!mb-4"-->
|
||||
<!-- >-->
|
||||
<!-- <template #default>-->
|
||||
<!-- {{ $t('dingtalk-sync.streamConfigTip') }}-->
|
||||
<!-- </template>-->
|
||||
<!-- </ElAlert>-->
|
||||
|
||||
<!-- <div v-loading="streamLoading">-->
|
||||
<!-- <ElDescriptions-->
|
||||
<!-- border-->
|
||||
<!-- :column="2"-->
|
||||
<!-- >-->
|
||||
<!-- <ElDescriptionsItem :label="$t('dingtalk-sync.streamTotalEvents')">-->
|
||||
<!-- {{ streamStatus.total_events }}-->
|
||||
<!-- </ElDescriptionsItem>-->
|
||||
<!-- <ElDescriptionsItem :label="$t('dingtalk-sync.streamLastEvent')">-->
|
||||
<!-- {{ formatEventType(streamStatus.last_event_type) }}-->
|
||||
<!-- </ElDescriptionsItem>-->
|
||||
<!-- <ElDescriptionsItem :label="$t('dingtalk-sync.streamLastEventTime')">-->
|
||||
<!-- {{ streamStatus.last_event_time ? formatTime(streamStatus.last_event_time) : $t('dingtalk-sync.streamEventNone') }}-->
|
||||
<!-- </ElDescriptionsItem>-->
|
||||
<!-- </ElDescriptions>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- Stream 增量事件日志 -->
|
||||
<ElDivider />
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
style="color: var(--el-text-color-primary)"
|
||||
>
|
||||
{{ $t('dingtalk-sync.streamEventLog') }}
|
||||
</span>
|
||||
<ElButton size="small" @click="loadStreamEvents">
|
||||
<template #icon>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</template>
|
||||
{{ $t('dingtalk-sync.refresh') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable
|
||||
v-loading="streamEventsLoading"
|
||||
:data="streamEvents"
|
||||
border
|
||||
stripe
|
||||
>
|
||||
<ElTableColumn :label="$t('dingtalk-sync.eventType')" width="140">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="getEventTypeTagType(row.event_type)" size="small">
|
||||
{{ getEventTypeLabel(row.event_type) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('dingtalk-sync.targetType')"
|
||||
width="90"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ getTargetTypeLabel(row.target_type) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
prop="target_name"
|
||||
:label="$t('dingtalk-sync.targetName')"
|
||||
min-width="120"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ row.target_name || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('dingtalk-sync.eventStatus')"
|
||||
width="90"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElTag
|
||||
:type="row.status === 'success' ? 'success' : 'danger'"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
row.status === 'success'
|
||||
? $t('dingtalk-sync.statusSuccess')
|
||||
: $t('dingtalk-sync.statusFailed')
|
||||
}}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('dingtalk-sync.eventTime')" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.event_time) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<div
|
||||
v-if="streamEventsTotal > streamEventsPageSize"
|
||||
class="mt-3 flex justify-end"
|
||||
>
|
||||
<ElPagination
|
||||
v-model:current-page="streamEventsPage"
|
||||
:page-size="streamEventsPageSize"
|
||||
:total="streamEventsTotal"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="handleEventsPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,575 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CallbackStatus, FeishuDeptTreeNode, SyncTypeStats } from '#/api/core/feishu-sync';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { KeyRound, Link2, RefreshCw, Webhook } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCheckbox,
|
||||
ElDivider,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElScrollbar,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag,
|
||||
ElTreeSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
getCallbackStatusApi,
|
||||
getDeptTreeApi,
|
||||
getSyncConfigApi,
|
||||
getSyncStatsApi,
|
||||
syncDeptApi,
|
||||
syncUserApi,
|
||||
testConnectionApi,
|
||||
updateSyncConfigApi,
|
||||
} from '#/api/core/feishu-sync';
|
||||
|
||||
defineOptions({ name: 'FeishuSyncForm' });
|
||||
|
||||
// ==================== 配置表单 ====================
|
||||
const configForm = ref({
|
||||
app_id: '',
|
||||
app_secret: '',
|
||||
sync_dept_id: '',
|
||||
sync_root_dept_id: '',
|
||||
enable_dept_event: '',
|
||||
enable_user_event: '',
|
||||
callback_url: '',
|
||||
encrypt_key: '',
|
||||
verification_token: '',
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const testing = ref(false);
|
||||
const syncingDept = ref(false);
|
||||
const syncingUser = ref(false);
|
||||
|
||||
// ==================== 回调状态 ====================
|
||||
const callbackStatus = ref<CallbackStatus>({ registered: false });
|
||||
|
||||
// ==================== 同步统计 ====================
|
||||
interface StatsRow {
|
||||
type: string;
|
||||
typeLabel: string;
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
not_synced: number;
|
||||
sync_time: null | string;
|
||||
}
|
||||
|
||||
const statsData = ref<StatsRow[]>([
|
||||
{
|
||||
type: 'dept',
|
||||
typeLabel: '',
|
||||
total_count: 0,
|
||||
success_count: 0,
|
||||
fail_count: 0,
|
||||
not_synced: 0,
|
||||
sync_time: null,
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
typeLabel: '',
|
||||
total_count: 0,
|
||||
success_count: 0,
|
||||
fail_count: 0,
|
||||
not_synced: 0,
|
||||
sync_time: null,
|
||||
},
|
||||
]);
|
||||
|
||||
// ==================== 触发事件 ====================
|
||||
interface EventRow {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const eventsData = ref<EventRow[]>([
|
||||
{
|
||||
key: 'enable_dept_event',
|
||||
label: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
key: 'enable_user_event',
|
||||
label: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// ==================== 部门树(同步范围选择) ====================
|
||||
const deptTreeData = ref<any[]>([]);
|
||||
const deptTreeLoading = ref(false);
|
||||
const hasSynced = ref(false);
|
||||
|
||||
function transformDeptTree(
|
||||
nodes: FeishuDeptTreeNode[],
|
||||
): Array<{ children?: any[]; label: string; value: string }> {
|
||||
return nodes.map((node) => ({
|
||||
value: String(node.dept_id),
|
||||
label: node.name,
|
||||
children:
|
||||
node.children && node.children.length > 0
|
||||
? transformDeptTree(node.children)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
function updateLabels() {
|
||||
statsData.value[0]!.typeLabel = $t('feishu-sync.syncDept');
|
||||
statsData.value[1]!.typeLabel = $t('feishu-sync.syncUser');
|
||||
eventsData.value[0]!.label = $t('feishu-sync.enableSyncDept');
|
||||
eventsData.value[0]!.description = $t('feishu-sync.enableSyncDeptDesc');
|
||||
eventsData.value[1]!.label = $t('feishu-sync.enableSyncUser');
|
||||
eventsData.value[1]!.description = $t('feishu-sync.enableSyncUserDesc');
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const config = await getSyncConfigApi();
|
||||
configForm.value.app_id = config.app_id || '';
|
||||
configForm.value.app_secret = config.app_secret || '';
|
||||
configForm.value.sync_dept_id = config.sync_dept_id || '';
|
||||
configForm.value.sync_root_dept_id = config.sync_root_dept_id || '';
|
||||
configForm.value.enable_dept_event = config.enable_dept_event || '';
|
||||
configForm.value.enable_user_event = config.enable_user_event || '';
|
||||
configForm.value.callback_url = config.callback_url || '';
|
||||
configForm.value.encrypt_key = config.encrypt_key || '';
|
||||
configForm.value.verification_token = config.verification_token || '';
|
||||
|
||||
eventsData.value[0]!.enabled = config.enable_dept_event === 'true';
|
||||
eventsData.value[1]!.enabled = config.enable_user_event === 'true';
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const stats = await getSyncStatsApi();
|
||||
const deptStats: SyncTypeStats = stats.dept;
|
||||
const userStats: SyncTypeStats = stats.user;
|
||||
|
||||
statsData.value[0] = {
|
||||
...statsData.value[0]!,
|
||||
total_count: deptStats.total_count,
|
||||
success_count: deptStats.success_count,
|
||||
fail_count: deptStats.fail_count,
|
||||
not_synced: deptStats.not_synced,
|
||||
sync_time: deptStats.sync_time,
|
||||
};
|
||||
statsData.value[1] = {
|
||||
...statsData.value[1]!,
|
||||
total_count: userStats.total_count,
|
||||
success_count: userStats.success_count,
|
||||
fail_count: userStats.fail_count,
|
||||
not_synced: userStats.not_synced,
|
||||
sync_time: userStats.sync_time,
|
||||
};
|
||||
|
||||
hasSynced.value = deptStats.success_count > 0;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 连接测试 ====================
|
||||
async function handleTestConnection() {
|
||||
testing.value = true;
|
||||
try {
|
||||
await testConnectionApi({
|
||||
app_id: configForm.value.app_id,
|
||||
app_secret: configForm.value.app_secret,
|
||||
});
|
||||
ElMessage.success($t('feishu-sync.testSuccess'));
|
||||
await loadDeptTree();
|
||||
} catch {
|
||||
ElMessage.error($t('feishu-sync.testFail'));
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 加载部门树 ====================
|
||||
async function loadDeptTree() {
|
||||
deptTreeLoading.value = true;
|
||||
try {
|
||||
const tree = await getDeptTreeApi({
|
||||
app_id: configForm.value.app_id,
|
||||
app_secret: configForm.value.app_secret,
|
||||
});
|
||||
deptTreeData.value = transformDeptTree(tree);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
deptTreeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 同步操作 ====================
|
||||
async function handleSyncDept() {
|
||||
syncingDept.value = true;
|
||||
try {
|
||||
await syncDeptApi();
|
||||
ElMessage.success($t('feishu-sync.syncDeptSuccess'));
|
||||
await loadStats();
|
||||
} catch {
|
||||
ElMessage.error($t('feishu-sync.syncFail'));
|
||||
} finally {
|
||||
syncingDept.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncUser() {
|
||||
syncingUser.value = true;
|
||||
try {
|
||||
await syncUserApi();
|
||||
ElMessage.success($t('feishu-sync.syncUserSuccess'));
|
||||
await loadStats();
|
||||
} catch {
|
||||
ElMessage.error($t('feishu-sync.syncFail'));
|
||||
} finally {
|
||||
syncingUser.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSync(row: StatsRow) {
|
||||
if (row.type === 'dept') {
|
||||
handleSyncDept();
|
||||
} else {
|
||||
handleSyncUser();
|
||||
}
|
||||
}
|
||||
|
||||
function isSyncing(row: StatsRow): boolean {
|
||||
return row.type === 'dept' ? syncingDept.value : syncingUser.value;
|
||||
}
|
||||
|
||||
// ==================== 保存配置 ====================
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateSyncConfigApi({
|
||||
app_id: configForm.value.app_id || undefined,
|
||||
app_secret: configForm.value.app_secret || undefined,
|
||||
sync_dept_id: configForm.value.sync_dept_id || undefined,
|
||||
sync_root_dept_id: configForm.value.sync_root_dept_id || undefined,
|
||||
enable_dept_event: eventsData.value[0]!.enabled ? 'true' : 'false',
|
||||
enable_user_event: eventsData.value[1]!.enabled ? 'true' : 'false',
|
||||
callback_url: configForm.value.callback_url || undefined,
|
||||
encrypt_key: configForm.value.encrypt_key || undefined,
|
||||
verification_token: configForm.value.verification_token || undefined,
|
||||
});
|
||||
ElMessage.success($t('feishu-sync.saveSuccess'));
|
||||
} catch {
|
||||
ElMessage.error($t('feishu-sync.saveFail'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ save: handleSave, saving });
|
||||
|
||||
// ==================== 回调管理 ====================
|
||||
async function loadCallbackStatus() {
|
||||
try {
|
||||
callbackStatus.value = await getCallbackStatusApi();
|
||||
} catch {
|
||||
callbackStatus.value = { registered: false };
|
||||
}
|
||||
}
|
||||
|
||||
function generateRandomEncryptKey() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const arr = new Uint8Array(32);
|
||||
crypto.getRandomValues(arr);
|
||||
configForm.value.encrypt_key = Array.from(arr, (b) => chars[b % chars.length]).join('');
|
||||
}
|
||||
|
||||
function generateRandomVerificationToken() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const arr = new Uint8Array(32);
|
||||
crypto.getRandomValues(arr);
|
||||
configForm.value.verification_token = Array.from(arr, (b) => chars[b % chars.length]).join('');
|
||||
}
|
||||
|
||||
// ==================== 格式化时间 ====================
|
||||
function formatTime(val: null | string): string {
|
||||
if (!val) return '';
|
||||
try {
|
||||
return new Date(val).toLocaleString();
|
||||
} catch {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updateLabels();
|
||||
await loadConfig();
|
||||
await Promise.all([loadStats(), loadCallbackStatus()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<ElScrollbar class="flex-1">
|
||||
<div v-loading="loading" class="space-y-6 p-6">
|
||||
<!-- 凭证配置 -->
|
||||
<ElForm label-width="120px" label-position="left">
|
||||
<ElFormItem :label="$t('feishu-sync.appId')">
|
||||
<ElInput
|
||||
v-model="configForm.app_id"
|
||||
:placeholder="$t('feishu-sync.appIdPlaceholder')"
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('feishu-sync.appSecret')">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElInput
|
||||
v-model="configForm.app_secret"
|
||||
:placeholder="$t('feishu-sync.appSecretPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
<ElButton
|
||||
:loading="testing"
|
||||
@click="handleTestConnection"
|
||||
>
|
||||
<template v-if="!testing" #icon>
|
||||
<Link2 class="size-4" />
|
||||
</template>
|
||||
{{ testing ? $t('feishu-sync.testing') : $t('feishu-sync.testConnection') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 同步范围 -->
|
||||
<ElFormItem :label="$t('feishu-sync.syncScope')">
|
||||
<ElTreeSelect
|
||||
v-model="configForm.sync_dept_id"
|
||||
:data="deptTreeData"
|
||||
:placeholder="$t('feishu-sync.syncScopePlaceholder')"
|
||||
:loading="deptTreeLoading"
|
||||
:disabled="hasSynced"
|
||||
check-strictly
|
||||
filterable
|
||||
class="!w-[400px]"
|
||||
node-key="value"
|
||||
:props="{ label: 'label', children: 'children' }"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<div
|
||||
class="mb-6 ml-[120px] text-xs"
|
||||
style="color: var(--el-text-color-secondary)"
|
||||
>
|
||||
{{ hasSynced ? $t('feishu-sync.syncScopeLocked') : $t('feishu-sync.syncScopeTip') }}
|
||||
</div>
|
||||
</ElForm>
|
||||
|
||||
<!-- 同步统计表 -->
|
||||
<div>
|
||||
<ElTable :data="statsData" border stripe>
|
||||
<ElTableColumn
|
||||
prop="typeLabel"
|
||||
:label="$t('feishu-sync.syncType')"
|
||||
width="120"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="total_count"
|
||||
:label="$t('feishu-sync.totalCount')"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="success_count"
|
||||
:label="$t('feishu-sync.successCount')"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="fail_count"
|
||||
:label="$t('feishu-sync.failCount')"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="not_synced"
|
||||
:label="$t('feishu-sync.notSynced')"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
:label="$t('feishu-sync.syncTime')"
|
||||
min-width="180"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.sync_time) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('feishu-sync.operation')"
|
||||
width="100"
|
||||
align="center"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElButton
|
||||
type="primary"
|
||||
link
|
||||
:loading="isSyncing(row)"
|
||||
@click="handleSync(row)"
|
||||
>
|
||||
<template v-if="!isSyncing(row)" #icon>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</template>
|
||||
{{ isSyncing(row) ? $t('feishu-sync.syncing') : $t('feishu-sync.sync') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<!-- 触发事件 -->
|
||||
<div>
|
||||
<div
|
||||
class="mb-3 text-sm font-medium"
|
||||
style="color: var(--el-text-color-primary)"
|
||||
>
|
||||
{{ $t('feishu-sync.triggerEvents') }}
|
||||
</div>
|
||||
<ElTable :data="eventsData" border stripe>
|
||||
<ElTableColumn width="60" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElCheckbox v-model="row.enabled" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
prop="label"
|
||||
:label="$t('feishu-sync.triggerEvent')"
|
||||
width="200"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="description"
|
||||
:label="$t('feishu-sync.description')"
|
||||
/>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<!-- 事件回调配置 -->
|
||||
<ElDivider />
|
||||
<div>
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 text-sm font-medium"
|
||||
style="color: var(--el-text-color-primary)"
|
||||
>
|
||||
<Webhook class="size-4" />
|
||||
{{ $t('feishu-sync.callbackConfig') }}
|
||||
</div>
|
||||
<ElAlert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="!mb-4"
|
||||
>
|
||||
<template #default>
|
||||
{{ $t('feishu-sync.callbackConfigTip') }}
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElForm label-width="120px" label-position="left">
|
||||
<ElFormItem :label="$t('feishu-sync.callbackUrl')">
|
||||
<ElInput
|
||||
v-model="configForm.callback_url"
|
||||
:placeholder="$t('feishu-sync.callbackUrlPlaceholder')"
|
||||
clearable
|
||||
class="!w-[500px]"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('feishu-sync.encryptKey')">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElInput
|
||||
v-model="configForm.encrypt_key"
|
||||
:placeholder="$t('feishu-sync.encryptKeyPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
<ElButton @click="generateRandomEncryptKey">
|
||||
<template #icon>
|
||||
<KeyRound class="size-4" />
|
||||
</template>
|
||||
{{ $t('feishu-sync.generateRandom') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('feishu-sync.verificationToken')">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElInput
|
||||
v-model="configForm.verification_token"
|
||||
:placeholder="$t('feishu-sync.verificationTokenPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
<ElButton @click="generateRandomVerificationToken">
|
||||
<template #icon>
|
||||
<KeyRound class="size-4" />
|
||||
</template>
|
||||
{{ $t('feishu-sync.generateRandom') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 回调状态 -->
|
||||
<ElFormItem :label="$t('feishu-sync.callbackStatus')">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElTag
|
||||
:type="callbackStatus.registered ? 'success' : 'info'"
|
||||
>
|
||||
{{ callbackStatus.registered ? $t('feishu-sync.callbackConfigured') : $t('feishu-sync.callbackNotConfigured') }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="callbackStatus.registered && callbackStatus.subscribed_events?.length"
|
||||
:label="$t('feishu-sync.subscribedEvents')"
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<ElTag
|
||||
v-for="event in callbackStatus.subscribed_events"
|
||||
:key="event"
|
||||
size="small"
|
||||
>
|
||||
{{ event }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,562 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CallbackStatus, SyncTypeStats, WecomDeptTreeNode } from '#/api/core/wecom-sync';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { KeyRound, Link2, RefreshCw, Webhook } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCheckbox,
|
||||
ElDivider,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElScrollbar,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag,
|
||||
ElTreeSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
getCallbackStatusApi,
|
||||
getDeptTreeApi,
|
||||
getSyncConfigApi,
|
||||
getSyncStatsApi,
|
||||
syncDeptApi,
|
||||
syncUserApi,
|
||||
testConnectionApi,
|
||||
updateSyncConfigApi,
|
||||
} from '#/api/core/wecom-sync';
|
||||
|
||||
defineOptions({ name: 'WecomSyncForm' });
|
||||
|
||||
const configForm = ref({
|
||||
corp_id: '',
|
||||
corp_secret: '',
|
||||
sync_dept_id: '',
|
||||
sync_root_dept_id: '',
|
||||
enable_dept_event: '',
|
||||
enable_user_event: '',
|
||||
callback_url: '',
|
||||
callback_token: '',
|
||||
callback_aes_key: '',
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const testing = ref(false);
|
||||
const syncingDept = ref(false);
|
||||
const syncingUser = ref(false);
|
||||
|
||||
const callbackStatus = ref<CallbackStatus>({ registered: false });
|
||||
|
||||
interface StatsRow {
|
||||
type: string;
|
||||
typeLabel: string;
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
not_synced: number;
|
||||
sync_time: null | string;
|
||||
}
|
||||
|
||||
const statsData = ref<StatsRow[]>([
|
||||
{
|
||||
type: 'dept',
|
||||
typeLabel: '',
|
||||
total_count: 0,
|
||||
success_count: 0,
|
||||
fail_count: 0,
|
||||
not_synced: 0,
|
||||
sync_time: null,
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
typeLabel: '',
|
||||
total_count: 0,
|
||||
success_count: 0,
|
||||
fail_count: 0,
|
||||
not_synced: 0,
|
||||
sync_time: null,
|
||||
},
|
||||
]);
|
||||
|
||||
interface EventRow {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const eventsData = ref<EventRow[]>([
|
||||
{
|
||||
key: 'enable_dept_event',
|
||||
label: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
key: 'enable_user_event',
|
||||
label: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const deptTreeData = ref<any[]>([]);
|
||||
const deptTreeLoading = ref(false);
|
||||
const hasSynced = ref(false);
|
||||
|
||||
function transformDeptTree(
|
||||
nodes: WecomDeptTreeNode[],
|
||||
): Array<{ children?: any[]; label: string; value: string }> {
|
||||
return nodes.map((node) => ({
|
||||
value: String(node.dept_id),
|
||||
label: node.name,
|
||||
children:
|
||||
node.children && node.children.length > 0
|
||||
? transformDeptTree(node.children)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function updateLabels() {
|
||||
statsData.value[0]!.typeLabel = $t('wecom-sync.syncDept');
|
||||
statsData.value[1]!.typeLabel = $t('wecom-sync.syncUser');
|
||||
eventsData.value[0]!.label = $t('wecom-sync.enableSyncDept');
|
||||
eventsData.value[0]!.description = $t('wecom-sync.enableSyncDeptDesc');
|
||||
eventsData.value[1]!.label = $t('wecom-sync.enableSyncUser');
|
||||
eventsData.value[1]!.description = $t('wecom-sync.enableSyncUserDesc');
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const config = await getSyncConfigApi();
|
||||
configForm.value.corp_id = config.corp_id || '';
|
||||
configForm.value.corp_secret = config.corp_secret || '';
|
||||
configForm.value.sync_dept_id = config.sync_dept_id || '';
|
||||
configForm.value.sync_root_dept_id = config.sync_root_dept_id || '';
|
||||
configForm.value.enable_dept_event = config.enable_dept_event || '';
|
||||
configForm.value.enable_user_event = config.enable_user_event || '';
|
||||
configForm.value.callback_url = config.callback_url || '';
|
||||
configForm.value.callback_token = config.callback_token || '';
|
||||
configForm.value.callback_aes_key = config.callback_aes_key || '';
|
||||
|
||||
eventsData.value[0]!.enabled = config.enable_dept_event === 'true';
|
||||
eventsData.value[1]!.enabled = config.enable_user_event === 'true';
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const stats = await getSyncStatsApi();
|
||||
const deptStats: SyncTypeStats = stats.dept;
|
||||
const userStats: SyncTypeStats = stats.user;
|
||||
|
||||
statsData.value[0] = {
|
||||
...statsData.value[0]!,
|
||||
total_count: deptStats.total_count,
|
||||
success_count: deptStats.success_count,
|
||||
fail_count: deptStats.fail_count,
|
||||
not_synced: deptStats.not_synced,
|
||||
sync_time: deptStats.sync_time,
|
||||
};
|
||||
statsData.value[1] = {
|
||||
...statsData.value[1]!,
|
||||
total_count: userStats.total_count,
|
||||
success_count: userStats.success_count,
|
||||
fail_count: userStats.fail_count,
|
||||
not_synced: userStats.not_synced,
|
||||
sync_time: userStats.sync_time,
|
||||
};
|
||||
|
||||
hasSynced.value = deptStats.success_count > 0;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
testing.value = true;
|
||||
try {
|
||||
await testConnectionApi({
|
||||
corp_id: configForm.value.corp_id,
|
||||
corp_secret: configForm.value.corp_secret,
|
||||
});
|
||||
ElMessage.success($t('wecom-sync.testSuccess'));
|
||||
await loadDeptTree();
|
||||
} catch {
|
||||
ElMessage.error($t('wecom-sync.testFail'));
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeptTree() {
|
||||
deptTreeLoading.value = true;
|
||||
try {
|
||||
const tree = await getDeptTreeApi({
|
||||
corp_id: configForm.value.corp_id,
|
||||
corp_secret: configForm.value.corp_secret,
|
||||
});
|
||||
deptTreeData.value = transformDeptTree(tree);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
deptTreeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncDept() {
|
||||
syncingDept.value = true;
|
||||
try {
|
||||
await syncDeptApi();
|
||||
ElMessage.success($t('wecom-sync.syncDeptSuccess'));
|
||||
await loadStats();
|
||||
} catch {
|
||||
ElMessage.error($t('wecom-sync.syncFail'));
|
||||
} finally {
|
||||
syncingDept.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncUser() {
|
||||
syncingUser.value = true;
|
||||
try {
|
||||
await syncUserApi();
|
||||
ElMessage.success($t('wecom-sync.syncUserSuccess'));
|
||||
await loadStats();
|
||||
} catch {
|
||||
ElMessage.error($t('wecom-sync.syncFail'));
|
||||
} finally {
|
||||
syncingUser.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSync(row: StatsRow) {
|
||||
if (row.type === 'dept') {
|
||||
handleSyncDept();
|
||||
} else {
|
||||
handleSyncUser();
|
||||
}
|
||||
}
|
||||
|
||||
function isSyncing(row: StatsRow): boolean {
|
||||
return row.type === 'dept' ? syncingDept.value : syncingUser.value;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateSyncConfigApi({
|
||||
corp_id: configForm.value.corp_id || undefined,
|
||||
corp_secret: configForm.value.corp_secret || undefined,
|
||||
sync_dept_id: configForm.value.sync_dept_id || undefined,
|
||||
sync_root_dept_id: configForm.value.sync_root_dept_id || undefined,
|
||||
enable_dept_event: eventsData.value[0]!.enabled ? 'true' : 'false',
|
||||
enable_user_event: eventsData.value[1]!.enabled ? 'true' : 'false',
|
||||
callback_url: configForm.value.callback_url || undefined,
|
||||
callback_token: configForm.value.callback_token || undefined,
|
||||
callback_aes_key: configForm.value.callback_aes_key || undefined,
|
||||
});
|
||||
ElMessage.success($t('wecom-sync.saveSuccess'));
|
||||
} catch {
|
||||
ElMessage.error($t('wecom-sync.saveFail'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ save: handleSave, saving });
|
||||
|
||||
async function loadCallbackStatus() {
|
||||
try {
|
||||
callbackStatus.value = await getCallbackStatusApi();
|
||||
} catch {
|
||||
callbackStatus.value = { registered: false };
|
||||
}
|
||||
}
|
||||
|
||||
function generateRandomToken() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const arr = new Uint8Array(32);
|
||||
crypto.getRandomValues(arr);
|
||||
configForm.value.callback_token = Array.from(arr, (b) => chars[b % chars.length]).join('');
|
||||
}
|
||||
|
||||
function generateRandomAesKey() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const arr = new Uint8Array(43);
|
||||
crypto.getRandomValues(arr);
|
||||
configForm.value.callback_aes_key = Array.from(arr, (b) => chars[b % chars.length]).join('');
|
||||
}
|
||||
|
||||
function formatTime(val: null | string): string {
|
||||
if (!val) return '';
|
||||
try {
|
||||
return new Date(val).toLocaleString();
|
||||
} catch {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updateLabels();
|
||||
await loadConfig();
|
||||
await Promise.all([loadStats(), loadCallbackStatus()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<ElScrollbar class="flex-1">
|
||||
<div v-loading="loading" class="space-y-6 p-6">
|
||||
<!-- 凭证配置 -->
|
||||
<ElForm label-width="120px" label-position="left">
|
||||
<ElFormItem :label="$t('wecom-sync.corpId')">
|
||||
<ElInput
|
||||
v-model="configForm.corp_id"
|
||||
:placeholder="$t('wecom-sync.corpIdPlaceholder')"
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('wecom-sync.corpSecret')">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElInput
|
||||
v-model="configForm.corp_secret"
|
||||
:placeholder="$t('wecom-sync.corpSecretPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
<ElButton
|
||||
:loading="testing"
|
||||
@click="handleTestConnection"
|
||||
>
|
||||
<template v-if="!testing" #icon>
|
||||
<Link2 class="size-4" />
|
||||
</template>
|
||||
{{ testing ? $t('wecom-sync.testing') : $t('wecom-sync.testConnection') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 同步范围 -->
|
||||
<ElFormItem :label="$t('wecom-sync.syncScope')">
|
||||
<ElTreeSelect
|
||||
v-model="configForm.sync_dept_id"
|
||||
:data="deptTreeData"
|
||||
:placeholder="$t('wecom-sync.syncScopePlaceholder')"
|
||||
:loading="deptTreeLoading"
|
||||
:disabled="hasSynced"
|
||||
check-strictly
|
||||
filterable
|
||||
class="!w-[400px]"
|
||||
node-key="value"
|
||||
:props="{ label: 'label', children: 'children' }"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<div
|
||||
class="mb-6 ml-[120px] text-xs"
|
||||
style="color: var(--el-text-color-secondary)"
|
||||
>
|
||||
{{ hasSynced ? $t('wecom-sync.syncScopeLocked') : $t('wecom-sync.syncScopeTip') }}
|
||||
</div>
|
||||
</ElForm>
|
||||
|
||||
<!-- 同步统计表 -->
|
||||
<div>
|
||||
<ElTable :data="statsData" border stripe>
|
||||
<ElTableColumn
|
||||
prop="typeLabel"
|
||||
:label="$t('wecom-sync.syncType')"
|
||||
width="120"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="total_count"
|
||||
:label="$t('wecom-sync.totalCount')"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="success_count"
|
||||
:label="$t('wecom-sync.successCount')"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="fail_count"
|
||||
:label="$t('wecom-sync.failCount')"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="not_synced"
|
||||
:label="$t('wecom-sync.notSynced')"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<ElTableColumn
|
||||
:label="$t('wecom-sync.syncTime')"
|
||||
min-width="180"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.sync_time) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('wecom-sync.operation')"
|
||||
width="100"
|
||||
align="center"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElButton
|
||||
type="primary"
|
||||
link
|
||||
:loading="isSyncing(row)"
|
||||
@click="handleSync(row)"
|
||||
>
|
||||
<template v-if="!isSyncing(row)" #icon>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</template>
|
||||
{{ isSyncing(row) ? $t('wecom-sync.syncing') : $t('wecom-sync.sync') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<!-- 触发事件 -->
|
||||
<div>
|
||||
<div
|
||||
class="mb-3 text-sm font-medium"
|
||||
style="color: var(--el-text-color-primary)"
|
||||
>
|
||||
{{ $t('wecom-sync.triggerEvents') }}
|
||||
</div>
|
||||
<ElTable :data="eventsData" border stripe>
|
||||
<ElTableColumn width="60" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElCheckbox v-model="row.enabled" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
prop="label"
|
||||
:label="$t('wecom-sync.triggerEvent')"
|
||||
width="200"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="description"
|
||||
:label="$t('wecom-sync.description')"
|
||||
/>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<!-- 事件回调配置 -->
|
||||
<ElDivider />
|
||||
<div>
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 text-sm font-medium"
|
||||
style="color: var(--el-text-color-primary)"
|
||||
>
|
||||
<Webhook class="size-4" />
|
||||
{{ $t('wecom-sync.callbackConfig') }}
|
||||
</div>
|
||||
<ElAlert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="!mb-4"
|
||||
>
|
||||
<template #default>
|
||||
{{ $t('wecom-sync.callbackConfigTip') }}
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElForm label-width="120px" label-position="left">
|
||||
<ElFormItem :label="$t('wecom-sync.callbackUrl')">
|
||||
<ElInput
|
||||
v-model="configForm.callback_url"
|
||||
:placeholder="$t('wecom-sync.callbackUrlPlaceholder')"
|
||||
clearable
|
||||
class="!w-[500px]"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('wecom-sync.callbackToken')">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElInput
|
||||
v-model="configForm.callback_token"
|
||||
:placeholder="$t('wecom-sync.callbackTokenPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
<ElButton @click="generateRandomToken">
|
||||
<template #icon>
|
||||
<KeyRound class="size-4" />
|
||||
</template>
|
||||
{{ $t('wecom-sync.generateRandom') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('wecom-sync.callbackAesKey')">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElInput
|
||||
v-model="configForm.callback_aes_key"
|
||||
:placeholder="$t('wecom-sync.callbackAesKeyPlaceholder')"
|
||||
show-password
|
||||
clearable
|
||||
class="!w-[400px]"
|
||||
/>
|
||||
<ElButton @click="generateRandomAesKey">
|
||||
<template #icon>
|
||||
<KeyRound class="size-4" />
|
||||
</template>
|
||||
{{ $t('wecom-sync.generateRandom') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- 回调状态 -->
|
||||
<ElFormItem :label="$t('wecom-sync.callbackStatus')">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElTag
|
||||
:type="callbackStatus.registered ? 'success' : 'info'"
|
||||
>
|
||||
{{ callbackStatus.registered ? $t('wecom-sync.callbackRegistered') : $t('wecom-sync.callbackNotRegistered') }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="callbackStatus.registered && callbackStatus.subscribed_events?.length"
|
||||
:label="$t('wecom-sync.subscribedEvents')"
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<ElTag
|
||||
v-for="event in callbackStatus.subscribed_events"
|
||||
:key="event"
|
||||
size="small"
|
||||
>
|
||||
{{ event }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user