'use strict'; // idsync 管理界面。刻意不引入前端框架:页面数量少、状态简单, // 原生 JS 足以胜任,且省去一整套构建链与依赖升级负担。 const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); // 操作类型的中文标签,与后端 syncer.OpKind 对应。 const OP_LABELS = { create_group: '新建分组', create_user: '新建用户', update_user: '更新用户', delete_user: '删除用户', add_member: '加入分组', remove_member: '移出分组', }; const OP_BADGES = { create_group: 'badge-success', create_user: 'badge-success', update_user: 'badge-info', delete_user: 'badge-error', add_member: 'badge-success', remove_member: 'badge-error', }; // 用户生命周期状态的中文标注。 const STATE_BADGES = { active: ['在职', 'badge-success'], leave_candidate: ['待发离职确认', 'badge-warning'], pending_confirm: ['待人工确认离职', 'badge-warning'], confirmed_leave: ['已确认离职', 'badge-error'], deleted: ['已删除', 'badge-soft'], }; // ---------------------------------------------------------------- 主题 function initTheme() { const saved = localStorage.getItem('idsync-theme'); if (saved === 'light' || saved === 'dark') { document.documentElement.dataset.theme = saved; } $('#themeToggle').addEventListener('click', () => { const root = document.documentElement; const next = root.dataset.theme === 'dark' ? 'light' : 'dark'; root.dataset.theme = next; localStorage.setItem('idsync-theme', next); }); } // ---------------------------------------------------------------- HTTP async function api(path, options = {}) { const res = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, ...options, }); if (res.status === 401) { showLogin(); throw new Error('登录状态已失效'); } let body = null; try { body = await res.json(); } catch (_) { // 非 JSON 响应(如上游超时返回的 HTML),下面按状态码统一处理 } if (!res.ok) { throw new Error((body && body.error) || `请求失败(HTTP ${res.status})`); } return body; } function showError(msg) { const el = $('#globalError'); el.textContent = msg; el.classList.remove('hidden'); setTimeout(() => el.classList.add('hidden'), 8000); } // ---------------------------------------------------------------- 视图切换 function showLogin() { $('#loginView').classList.remove('hidden'); $('#loginView').classList.add('flex'); $('#appView').classList.add('hidden'); } function showApp() { $('#loginView').classList.add('hidden'); $('#loginView').classList.remove('flex'); $('#appView').classList.remove('hidden'); } function switchView(name) { $$('.view').forEach((el) => el.classList.add('hidden')); const view = $(`#view-${name}`); if (view) view.classList.remove('hidden'); $$('.tabs .tab').forEach((t) => { t.classList.toggle('tab-active', t.dataset.view === name); }); if (name === 'overview') { loadStatus(); loadPending(); } if (name === 'users') loadUsers(); if (name === 'groups') loadGroups(); if (name === 'plan') loadPlan(); if (name === 'runs') loadRuns(); } // ---------------------------------------------------------------- 登录 async function doLogin() { const errEl = $('#loginError'); errEl.classList.add('hidden'); try { await api('/api/login', { method: 'POST', body: JSON.stringify({ username: $('#username').value, password: $('#password').value }), }); showApp(); switchView('overview'); } catch (err) { errEl.textContent = err.message; errEl.classList.remove('hidden'); } } async function doLogout() { try { await api('/api/logout', { method: 'POST' }); } catch (_) { /* 登出失败也要回登录页 */ } showLogin(); } // ---------------------------------------------------------------- 概览 async function loadStatus() { try { const d = await api('/api/status'); $('#statActive').textContent = d.counts.activeUsers; $('#statPending').textContent = d.counts.pendingConfirm; $('#statGroups').textContent = d.counts.departments; $('#connStatus').innerHTML = [ statusRow('LLDAP 读(LDAP)', d.ldap.ok, d.ldap.error, d.ldap.url), statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`), statusRow('LLDAP 写(GraphQL)', d.lldap.baseUrl ? true : false, '', d.lldap.baseUrl || '未配置'), ].join(''); const skipped = d.counts.skipped || 0; $('#skippedStatus').textContent = skipped; $('#skippedStatusWrap').classList.toggle('hidden', skipped === 0); $('#lastRunInfo').innerHTML = d.lastRun ? `
时间:${fmtTime(d.lastRun.finishedAt)}
模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}
变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 删除 ${d.lastRun.deleted || 0} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}
` : '暂无物化记录'; } catch (err) { showError(err.message); } } function statusRow(name, ok, error, detail) { const badge = ok ? '正常' : '异常'; const extra = ok ? `${escapeHtml(detail || '')}` : `${escapeHtml(error || '')}`; return `
${name}${badge} ${extra}
`; } // ---------------------------------------------------------------- 离职待确认 async function loadPending() { try { const d = await api('/api/resign/pending'); const rows = d.pending || []; $('#pendingRows').innerHTML = rows.length ? rows.map((u) => `
  • ${escapeHtml(u.name || u.uid)} ${escapeHtml(u.uid)}
    ${fmtTime(u.lastSyncedAt)}
  • `).join('') : '
  • 暂无待人工确认的离职。飞书离职卡片确认后,用户才会被删除。
  • '; } catch (err) { $('#pendingRows').innerHTML = `
  • ${escapeHtml(err.message)}
  • `; } } // ---------------------------------------------------------------- 快照 async function doSnapshot() { const btn = $('#snapshotBtn'); btn.disabled = true; btn.textContent = '快照中…'; try { await api('/api/snapshot', { method: 'POST' }); showError('快照完成:已把飞书现状写入镜像并推进离职流程。'); $('#globalError').className = 'alert alert-success text-sm mb-4'; loadPending(); } catch (err) { showError(err.message); } finally { btn.disabled = false; btn.textContent = '立即快照'; } } // ---------------------------------------------------------------- 用户 let usersCache = []; async function loadUsers() { try { const d = await api('/api/users'); usersCache = d.users || []; renderUsers(); } catch (err) { showError(err.message); } } function renderUsers() { const kw = $('#userFilter').value.trim().toLowerCase(); const rows = usersCache.filter((u) => { if (!kw) return true; return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw)); }); $('#userRows').innerHTML = rows.map((u) => { const st = STATE_BADGES[u.state] || [u.state || '未知', 'badge-soft']; return ` ${escapeHtml(u.uid)} ${escapeHtml(u.name || '')} ${escapeHtml(u.email || '')} ${st[0]} ${(u.deptIds || []).length} ${fmtTime(u.lastSyncedAt)} ${u.skipped ? `被跳过` : '—'} `; }).join(''); $('#userEmpty').classList.toggle('hidden', rows.length > 0); } // ---------------------------------------------------------------- 部门分组 async function loadGroups() { try { const d = await api('/api/groups'); const groups = d.groups || []; $('#groupRows').innerHTML = groups.map((g) => ` ${escapeHtml(g.Name || '')} ${escapeHtml(g.DeptID || '—')} ${escapeHtml(g.ParentID || '—')} ${g.GroupID || '—'} ${escapeHtml(g.DN || '')} `).join(''); $('#groupEmpty').classList.toggle('hidden', groups.length > 0); } catch (err) { showError(err.message); } } // ---------------------------------------------------------------- 变更预览 let currentPlan = null; async function loadPlan() { $('#planRows').innerHTML = ''; $('#planSummary').innerHTML = ''; $('#planWarnings').classList.add('hidden'); $('#planEmpty').classList.add('hidden'); try { const plan = await api('/api/plan'); currentPlan = plan; renderPlan(plan); } catch (err) { showError(err.message); } } function renderPlan(plan) { const s = plan.stats || {}; const cards = [ ['新建用户', s.createUser, 'text-success'], ['更新用户', s.updateUser, 'text-info'], ['删除用户', s.deleteUser, 'text-error'], ['新建分组', s.createGroup, 'text-success'], ['加入分组', s.addMember, 'text-success'], ['移出分组', s.removeMember, 'text-error'], ]; $('#planSummary').innerHTML = cards.map(([label, value, cls]) => `
    ${label} ${value || 0}
    `).join(''); const warnings = plan.warnings || []; if (warnings.length > 0) { const el = $('#planWarnings'); el.innerHTML = `
    需要注意 ${warnings.length} 项
    `; el.classList.remove('hidden'); } const ops = plan.ops || []; $('#planRows').innerHTML = ops.map((op) => ` ${OP_LABELS[op.kind] || op.kind} ${escapeHtml(op.target)} ${escapeHtml(op.detail)} `).join(''); $('#planEmpty').classList.toggle('hidden', ops.length > 0); } // FlyonUI 的 overlay(modal)由 flyonui.js 的 HSOverlay 驱动:它切换 open/hidden class 来显隐。 // 不要改回原生 dialog.showModal()——那不会加上 .open,overlay-open 变体不生效,框就永远不可见。 function openApplyModal() { if (window.HSOverlay) window.HSOverlay.open('#applyModal'); } function closeApplyModal() { if (window.HSOverlay) window.HSOverlay.close('#applyModal'); } async function applyPlan(force = false) { closeApplyModal(); try { const d = await api('/api/sync', { method: 'POST', body: JSON.stringify({ dryRun: false, force }), }); const res = d.result || {}; showError(`物化完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},删除 ${res.Deleted || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`); $('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4'; switchView('runs'); } catch (err) { showError(err.message); } } // ---------------------------------------------------------------- 物化历史 async function loadRuns() { try { const d = await api('/api/runs'); const runs = d.runs || []; $('#runRows').innerHTML = runs.map((r) => ` ${fmtTime(r.startedAt)} ${r.mode === 'dry-run' ? '预览' : '执行'} ${r.trigger === 'schedule' ? '定时' : '手动'} ${r.status === 'success' ? '成功' : '失败'} ${r.created}${r.updated}${r.deleted || 0} ${r.added}${r.removed} ${r.failed > 0 ? `${r.failed}` : '0'} `).join(''); $('#runEmpty').classList.toggle('hidden', runs.length > 0); } catch (err) { showError(err.message); } } // ---------------------------------------------------------------- 工具 function fmtTime(s) { if (!s) return '—'; const d = new Date(s); if (isNaN(d.getTime())) return '—'; const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ---------------------------------------------------------------- 启动 document.addEventListener('DOMContentLoaded', () => { initTheme(); $('#loginBtn').addEventListener('click', doLogin); $('#password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); $('#username').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); $('#logoutBtn').addEventListener('click', doLogout); $$('.tabs .tab').forEach((t) => { t.addEventListener('click', () => switchView(t.dataset.view)); }); $('#userFilter').addEventListener('input', renderUsers); $('#reloadUsers').addEventListener('click', loadUsers); $('#reloadRuns').addEventListener('click', loadRuns); $('#previewBtn').addEventListener('click', loadPlan); $('#snapshotBtn').addEventListener('click', doSnapshot); $('#refreshPending').addEventListener('click', loadPending); $('#applyBtn').addEventListener('click', () => { if (!currentPlan) return; const s = currentPlan.stats || {}; const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deleteUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0); if (total === 0) { showError('没有需要应用的变更。'); return; } $('#applyModalBody').innerHTML = `
    新建用户 ${s.createUser || 0} · 更新用户 ${s.updateUser || 0}
    删除用户 ${s.deleteUser || 0}
    新建分组 ${s.createGroup || 0} · 加入分组 ${s.addMember || 0} · 移出分组 ${s.removeMember || 0}
    `; openApplyModal(); }); // 「取消」与右上角关闭按钮走 data-overlay 声明式关闭,无需额外监听。 $('#applyConfirm').addEventListener('click', () => applyPlan(false)); // 已有会话则直接进入主界面 api('/api/status') .then(() => { showApp(); switchView('overview'); }) .catch(() => showLogin()); });