Import Feishu directory with optional 30-minute sync

This commit is contained in:
Codex committed 2026-10-23 11:10:59 +08:00
1 parent 4096b93da3
commit e5375deeb2
10 files changed
+662 -3

No files matched your search

+3
View File
@@ -793,6 +793,9 @@
"setupTitle": "Optional HTTPS public gateway", "setupStepCheck": "Detect system", "setupStepInstall": "Install dependencies", "setupStepConfigure": "Configure gateway and certificate", "detectedSystem": "Server OS", "setupUnsupported": "Automatic setup requires root on Ubuntu or TencentOS Server 4", "setupScope": "Use only if you choose the Nginx gateway. Installs Nginx / Certbot and prepares the validation directory. It does not install VPN protocol dependencies, enable ports, or issue certificates.", "setupButton": "Prepare Nginx / Certbot", "setupComplete": "Dependencies are ready; configure the site and certificate next", "setupFailed": "Initialization failed; inspect the output", "setupInterrupted": "Log connection interrupted; refresh status. Installation may still be running on the server",
"commonSetupTitle": "First-time shared network setup", "commonSetupHint": "Check permissions, default route and IPv4 forwarding. Enable shared forwarding only when needed; install protocol-specific dependencies separately in Create Tunnel.", "hostRoot": "Privileges", "hostRoute": "Default route", "hostForward": "IPv4 forwarding", "enableForwarding": "Enable IPv4 forwarding", "forwardingEnabled": "IPv4 forwarding enabled"
},
"feishuSync": {
"title": "Feishu directory", "hint": "Import manually or every 30 minutes when enabled. Imported members do not receive VPN access or tunnel grants. Names are shown exactly as returned by Feishu.", "permissions": "Grant app identity access to department and user lists, publish the app, and set its directory visibility to the intended departments and members.", "auto": "Auto sync (30 min)", "run": "Sync now", "lastSuccess": "Last success", "counts": "Departments {departments} · members {members}", "name": "Name", "departments": "Department IDs", "firstMembers": "Showing the first 500 members", "saved": "Sync settings saved", "started": "Sync started; watch progress below", "done": "Directory sync complete", "failed": "Directory sync failed"
},
"feishuDirectory": {
"title": "Feishu directory lookup",
"hint": "After sign-in, check the name. The basic name API requires the app identity scope contact:user.basic_profile:readonly and a published app.",
+3
View File
@@ -793,6 +793,9 @@
"setupTitle": "可选 · HTTPS 公网入口", "setupStepCheck": "检测系统", "setupStepInstall": "安装依赖", "setupStepConfigure": "配置入口与证书", "detectedSystem": "服务器系统", "setupUnsupported": "自动初始化仅支持以 root 运行的 Ubuntu 或 TencentOS Server 4", "setupScope": "仅在选择 Nginx 入口时点击:按需安装 Nginx / Certbot 并准备验证目录;不会安装 VPN 协议依赖、启用端口或签发证书。", "setupButton": "准备 Nginx / Certbot", "setupComplete": "依赖已准备,继续配置站点与证书", "setupFailed": "初始化失败,请查看输出", "setupInterrupted": "日志连接中断,请刷新状态;服务器上的安装可能仍在继续",
"commonSetupTitle": "首次引导 · 通用网络环境", "commonSetupHint": "检查服务权限、默认出口和 IPv4 转发。仅按需开启通用转发;各专线协议的依赖在“创建专线”中分别安装。", "hostRoot": "运行权限", "hostRoute": "默认出口", "hostForward": "IPv4 转发", "enableForwarding": "开启 IPv4 转发", "forwardingEnabled": "IPv4 转发已开启"
},
"feishuSync": {
"title": "飞书组织与成员", "hint": "手动导入,或开启后每 30 分钟同步一次。导入不会自动开通 VPN 或授予专线权限;仅显示飞书返回的姓名。", "permissions": "需应用身份读取部门及直属成员(如 contact:department.base:readonly、contact:user.department:readonly);发布应用,并设置通讯录可见范围。具体权限以飞书接口报错为准。", "auto": "自动同步(30 分钟)", "run": "立即同步", "lastSuccess": "上次成功", "counts": "部门 {departments} · 成员 {members}", "name": "姓名", "departments": "部门 ID", "firstMembers": "仅显示前 500 位成员", "saved": "同步设置已保存", "started": "已开始同步,可查看下方进度", "done": "通讯录同步完成", "failed": "通讯录同步失败"
},
"feishuDirectory": {
"title": "飞书姓名查询",
"hint": "完成登录后可检查姓名。基础姓名接口需应用身份权限 contact:user.basic_profile:readonly,并发布应用。",
@@ -56,6 +56,28 @@
</div>
<el-alert v-if="directoryResult" :title="directoryResult" :type="directoryAvailable ? 'success' : 'warning'" :closable="false" style="margin-top:12px" />
</el-card>
<el-card v-if="identityStatus?.enabled && identitySecure" class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('feishuSync.title') }}</b></template>
<p class="tls-hint">{{ $t('feishuSync.hint') }}</p>
<p class="tls-hint">{{ $t('feishuSync.permissions') }}</p>
<div class="directory-check">
<el-switch :model-value="syncStatus.enabled || false" :loading="syncSaving" :active-text="$t('feishuSync.auto')" @change="setSyncEnabled" />
<el-button :loading="syncRunning" @click="runSync">{{ $t('feishuSync.run') }}</el-button>
<el-button :loading="syncLoading" @click="loadSync">{{ $t('tls.refresh') }}</el-button>
</div>
<p class="tls-hint">{{ $t('feishuSync.lastSuccess') }}:{{ syncStatus.last_success ? new Date(syncStatus.last_success).toLocaleString() : '—' }} · {{ $t('feishuSync.counts', { departments: syncStatus.departments || 0, members: syncStatus.members || 0 }) }}</p>
<el-alert v-if="syncStatus.last_error || syncError" type="warning" :closable="false" :title="syncError || syncStatus.last_error" style="margin-bottom:12px" />
<el-tree v-if="syncDepartments.length" :data="syncDepartmentTree" node-key="id" :props="{ label: 'name', children: 'children' }" style="margin:12px 0;max-height:260px;overflow:auto" />
<el-table :data="syncMembers" size="small" max-height="340" v-loading="syncLoading">
<el-table-column prop="name" :label="$t('feishuSync.name')" min-width="130" />
<el-table-column prop="open_id" label="open_id" min-width="260" />
<el-table-column :label="$t('feishuSync.departments')" min-width="170">
<template #default="{ row }">{{ memberDepartments(row.department_ids) }}</template>
</el-table-column>
<template #empty><el-empty :description="$t('common.noData')" /></template>
</el-table>
<p v-if="syncStatus.members > syncMembers.length" class="tls-hint">{{ $t('feishuSync.firstMembers') }}</p>
</el-card>
</div>
</template>
@@ -87,13 +109,31 @@ const profilesLoading = ref(false)
const directoryLoading = ref(false)
const directoryResult = ref('')
const directoryAvailable = ref(false)
const syncStatus = ref({})
const syncMembers = ref([])
const syncDepartments = ref([])
const syncDepartmentTree = computed(() => {
const nodes = new Map(syncDepartments.value.map(d => [d.id, { ...d, children: [] }]))
const roots = []
for (const node of nodes.values()) {
const parent = nodes.get(node.parent_id)
if (parent && parent !== node) parent.children.push(node)
else roots.push(node)
}
return roots
})
const syncError = ref('')
const syncLoading = ref(false)
const syncSaving = ref(false)
const syncRunning = ref(false)
let syncPollTimer
const basicProfileVerified = ref(false)
const userScopeVerified = computed(() => !!identityStatus.value?.user_scope_verified && identityForm.value.app_id === identityStatus.value?.app_id && !identityForm.value.app_secret && identityForm.value.tenant_key === identityStatus.value?.tenant_key)
const tenantSeconds = ref(0)
let tenantTimer
let identityCheckTimer
let tenantRequest = 0
onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); tenantRequest++ })
onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); clearTimeout(syncPollTimer); tenantRequest++ })
watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId, secret], [oldId, oldSecret] = []) => {
if (appId === oldId && secret === oldSecret) return
tenantRequest++
@@ -142,7 +182,7 @@ async function loadIdentity() {
try {
const { data } = await api.get('/system/identity')
identityStatus.value = data
if (data.enabled && identitySecure) loadProfiles()
if (data.enabled && identitySecure) { loadProfiles(); loadSync() }
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
tenantName.value = ''
tenantError.value = ''
@@ -170,6 +210,38 @@ async function checkDirectory() {
} catch (e) { if (subject === selectedProfile.value) { directoryAvailable.value = false; basicProfileVerified.value = false; directoryResult.value = e.response?.data?.error || t('feishuDirectory.failed') } }
finally { directoryLoading.value = false }
}
async function loadSync() {
syncLoading.value = true
try {
const { data } = await api.get('/system/identity/directory-sync')
syncStatus.value = data.status || {}
syncMembers.value = data.members || []
syncDepartments.value = data.departments || []
syncError.value = ''
syncRunning.value = !!data.running
clearTimeout(syncPollTimer)
if (data.running) syncPollTimer = setTimeout(loadSync, 3000)
} catch (e) { syncError.value = e.response?.data?.error || t('feishuSync.failed') }
finally { syncLoading.value = false }
}
async function setSyncEnabled(enabled) {
syncSaving.value = true
try {
const { data } = await api.put('/system/identity/directory-sync', { enabled })
syncStatus.value = data.status
ElMessage.success(t('feishuSync.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('feishuSync.failed')) }
finally { syncSaving.value = false }
}
async function runSync() {
syncRunning.value = true
syncError.value = ''
try { await api.post('/system/identity/directory-sync/run'); clearTimeout(syncPollTimer); syncPollTimer = setTimeout(loadSync, 1500); ElMessage.success(t('feishuSync.started')) }
catch (e) { syncRunning.value = false; syncError.value = e.response?.data?.error || t('feishuSync.failed') }
}
function memberDepartments(value) {
try { return JSON.parse(value || '[]').join(', ') || '—' } catch { return '—' }
}
async function saveIdentity() {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
identitySaving.value = true