Restore system sync configuration page

This commit is contained in:
2026-06-10 11:57:02 +08:00
parent 7c0c230c36
commit f6f37e2d7d
9 changed files with 2614 additions and 40 deletions
-6
View File
@@ -238,15 +238,12 @@ backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py
!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/database-monitor.ts
web/apps/web-ele/src/api/core/demo.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/link-preview.ts
web/apps/web-ele/src/api/core/redis-manager.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/redis-monitor.ts
web/apps/web-ele/src/api/core/region.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/scheduler.ts
web/apps/web-ele/src/api/core/server-monitor.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/router/routes/modules/wiki.ts
web/apps/web-ele/src/views/_core/approval-center/ web/apps/web-ele/src/views/_core/approval-center/
web/apps/web-ele/src/views/_core/data-source/ 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/region-manager/
web/apps/web-ele/src/views/_core/scheduler/ web/apps/web-ele/src/views/_core/scheduler/
web/apps/web-ele/src/views/_core/server-monitor/ 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/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/panels/SystemSummaryPanel.vue
!web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.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',
);
}
+3
View File
@@ -30,7 +30,9 @@ const modules = import.meta.glob([
'./langs/*/chat.json', './langs/*/chat.json',
'./langs/*/common.json', './langs/*/common.json',
'./langs/*/dept.json', './langs/*/dept.json',
'./langs/*/dingtalk-sync.json',
'./langs/*/dict.json', './langs/*/dict.json',
'./langs/*/feishu-sync.json',
'./langs/*/file-manager.json', './langs/*/file-manager.json',
'./langs/*/form-manager.json', './langs/*/form-manager.json',
'./langs/*/loginLog.json', './langs/*/loginLog.json',
@@ -49,6 +51,7 @@ const modules = import.meta.glob([
'./langs/*/ui-config.json', './langs/*/ui-config.json',
'./langs/*/user-avatar.json', './langs/*/user-avatar.json',
'./langs/*/user.json', './langs/*/user.json',
'./langs/*/wecom-sync.json',
]); ]);
const localesMap = loadLocalesMapFromDir( const localesMap = loadLocalesMapFromDir(
@@ -8,7 +8,9 @@ import { Page } from '@vben/common-ui';
import { import {
BellRing, BellRing,
Bot, Bot,
CircleHelp,
IconifyIcon, IconifyIcon,
RefreshCw,
Shield, Shield,
} from '@vben/icons'; } from '@vben/icons';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
@@ -26,10 +28,14 @@ import {
getAllConfigsApi, getAllConfigsApi,
} from '#/api/core/system-config'; } from '#/api/core/system-config';
import { CardList } from '#/components/card-list'; import { CardList } from '#/components/card-list';
import { ZqDialog } from '#/components/zq-dialog';
import { ZqTabs } from '#/components/zq-tabs'; import { ZqTabs } from '#/components/zq-tabs';
import ConfigForm from './modules/config-form.vue'; import ConfigForm from './modules/config-form.vue';
import DingtalkSyncForm from './modules/dingtalk-sync-form.vue';
import ModelConfigForm from './modules/model-config-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' }); defineOptions({ name: 'SystemConfigManager' });
@@ -38,7 +44,7 @@ interface ConfigMenuItem extends CardListItem {
name: string; name: string;
group: string; group: string;
icon: string; icon: string;
category: 'notify' | 'oauth'; category: 'notify' | 'oauth' | 'sync';
} }
const SSO_GROUPS = [ const SSO_GROUPS = [
@@ -48,14 +54,26 @@ const SSO_GROUPS = [
'oauth_google', 'oauth_google',
'oauth_wechat', 'oauth_wechat',
'oauth_microsoft', 'oauth_microsoft',
'oauth_dingtalk',
'oauth_feishu',
'oauth_wecom',
]; ];
const NOTIFY_GROUPS = [ const NOTIFY_GROUPS = [
'notify_email', 'notify_email',
'notify_sms', 'notify_sms',
'notify_dingtalk',
'notify_feishu',
'notify_wecom',
'notify_wechat_mp', 'notify_wechat_mp',
]; ];
const SYNC_GROUPS = [
'sync_dingtalk',
'sync_wecom',
'sync_feishu',
];
const GROUP_ICONS: Record<string, string> = { const GROUP_ICONS: Record<string, string> = {
oauth_gitee: 'simple-icons:gitee', oauth_gitee: 'simple-icons:gitee',
oauth_github: 'simple-icons:github', oauth_github: 'simple-icons:github',
@@ -63,9 +81,18 @@ const GROUP_ICONS: Record<string, string> = {
oauth_google: 'simple-icons:google', oauth_google: 'simple-icons:google',
oauth_wechat: 'simple-icons:wechat', oauth_wechat: 'simple-icons:wechat',
oauth_microsoft: 'simple-icons:microsoft', 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_email: 'mdi:email-outline',
notify_sms: 'mdi:message-text-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', 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'); const activeTab = ref<string>('oauth');
@@ -74,6 +101,11 @@ const selectedMenuId = ref<string>('oauth_gitee');
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const configFormRef = ref<InstanceType<typeof ConfigForm>>(); 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 menuItems = computed<ConfigMenuItem[]>(() => {
const items: 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; return items;
}); });
@@ -115,6 +157,7 @@ const tabItems = computed<ZqTabItem[]>(() => [
{ key: 'oauth', label: $t('system-config.ssoConfig'), icon: Shield }, { key: 'oauth', label: $t('system-config.ssoConfig'), icon: Shield },
{ key: 'notify', label: $t('system-config.notifyConfig'), icon: BellRing }, { key: 'notify', label: $t('system-config.notifyConfig'), icon: BellRing },
{ key: 'model', label: $t('system-config.modelConfig'), icon: Bot }, { key: 'model', label: $t('system-config.modelConfig'), icon: Bot },
{ key: 'sync', label: $t('system-config.syncConfig'), icon: RefreshCw },
]); ]);
const cardListOptions: CardListOptions<ConfigMenuItem> = { const cardListOptions: CardListOptions<ConfigMenuItem> = {
@@ -176,6 +219,21 @@ function handleSaved() {
loadAllConfigs(); 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(() => { onMounted(() => {
loadAllConfigs(); loadAllConfigs();
}); });
@@ -194,7 +252,7 @@ onMounted(() => {
/> />
</div> </div>
<!-- 列表oauth/notify tab 显示 --> <!-- 列表oauth/notify/sync tab 显示 -->
<template v-if="activeTab !== 'model'"> <template v-if="activeTab !== 'model'">
<div class="mr-3 w-[250px] flex-shrink-0"> <div class="mr-3 w-[250px] flex-shrink-0">
<ElCard shadow="never" class="h-full !border-none"> <ElCard shadow="never" class="h-full !border-none">
@@ -220,42 +278,136 @@ onMounted(() => {
<!-- 右侧内容 --> <!-- 右侧内容 -->
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden">
<ElCard <!-- 同步配置 -->
shadow="never" <template v-if="activeTab === 'sync'">
class="config-card flex h-full flex-col !border-none" <ElCard
> shadow="never"
<template #header> class="config-card flex h-full flex-col !border-none"
<div class="card-header"> >
<IconifyIcon <template #header>
:icon="selectedItem?.icon || ''" <div class="card-header">
class="mr-2 size-5 opacity-60" <IconifyIcon
/> :icon="selectedItem?.icon || ''"
<span>{{ selectedItem?.name }}</span> class="mr-2 size-5 opacity-60"
<div class="ml-auto flex gap-2"> />
<ElButton @click="handleReset"> <span>{{ selectedItem?.name }}</span>
{{ $t('system-config.reset') }} <div class="ml-auto flex items-center gap-2">
</ElButton> <CircleHelp
<ElButton class="size-5 cursor-pointer opacity-50 transition-opacity hover:opacity-100"
type="primary" @click="showGuideDialog = true"
:loading="saving" />
@click="handleSave" <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)"
> >
{{ $t('system-config.save') }} {{ idx + 1 }}
</ElButton> </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>
</div> </div>
</template> </ZqDialog>
</template>
<ElScrollbar class="flex-1"> <!-- SSO/通知配置通用表单 -->
<ConfigForm <template v-else>
ref="configFormRef" <ElCard
:group="selectedMenuId" shadow="never"
:config-data="allConfigs[selectedMenuId] || {}" class="config-card flex h-full flex-col !border-none"
:loading="loading" >
@saved="handleSaved" <template #header>
/> <div class="card-header">
</ElScrollbar> <IconifyIcon
</ElCard> :icon="selectedItem?.icon || ''"
class="mr-2 size-5 opacity-60"
/>
<span>{{ selectedItem?.name }}</span>
<div class="ml-auto flex gap-2">
<ElButton @click="handleReset">
{{ $t('system-config.reset') }}
</ElButton>
<ElButton
type="primary"
:loading="saving"
@click="handleSave"
>
{{ $t('system-config.save') }}
</ElButton>
</div>
</div>
</template>
<ElScrollbar class="flex-1">
<ConfigForm
ref="configFormRef"
:group="selectedMenuId"
:config-data="allConfigs[selectedMenuId] || {}"
:loading="loading"
@saved="handleSaved"
/>
</ElScrollbar>
</ElCard>
</template>
</div> </div>
</template> </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(&#45;&#45;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>