'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]) => `
`).join('');
const warnings = plan.warnings || [];
if (warnings.length > 0) {
const el = $('#planWarnings');
el.innerHTML = `
需要注意 ${warnings.length} 项
${warnings.map((w) => `- ${escapeHtml(w)}
`).join('')}
`;
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());
});