Files
idsync/internal/webui/dist/app.js
T
caowangrenbo 947b1a1475 chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
2026-09-29 00:10:59 +08:00

424 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// idsync 管理界面。刻意不引入前端框架:页面数量少、状态简单,
// 原生 JS 足以胜任,且省去一整套构建链与依赖升级负担。
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
const OP_LABELS = {
create_user: '新建用户',
update_user: '更新用户',
deactivate_user: '迁入离职 OU',
reactivate_user: '恢复用户',
create_group: '新建分组',
update_group: '更新分组',
add_member: '加入分组',
remove_member: '移出分组',
};
const OP_BADGES = {
create_user: 'badge-success',
create_group: 'badge-success',
update_user: 'badge-info',
update_group: 'badge-info',
reactivate_user: 'badge-warning',
deactivate_user: 'badge-error',
add_member: 'badge-success',
remove_member: 'badge-error',
};
// ---------------------------------------------------------------- 主题
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 hideError() {
$('#globalError').classList.add('hidden');
}
// ---------------------------------------------------------------- 视图切换
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();
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');
hideError();
$('#statActive').textContent = d.counts.activeUsers;
$('#statInactive').textContent = d.counts.inactiveUsers;
$('#statGroups').textContent = d.counts.groups;
$('#connStatus').innerHTML = [
statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url),
statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
].join('');
$('#lastRunInfo').innerHTML = d.lastRun
? `<div class="space-y-1">
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
</div>`
: '<span class="text-base-content/50">暂无成功的同步记录</span>';
} catch (err) {
showError(err.message);
}
}
function statusRow(name, ok, error, detail) {
const badge = ok
? '<span class="badge badge-success badge-sm">正常</span>'
: '<span class="badge badge-error badge-sm">异常</span>';
const extra = ok
? `<span class="text-base-content/60">${escapeHtml(detail || '')}</span>`
: `<span class="text-error">${escapeHtml(error || '')}</span>`;
return `<div class="flex items-center justify-between gap-3">
<span class="flex items-center gap-2"><span class="font-medium">${name}</span>${badge}</span>
${extra}
</div>`;
}
// ---------------------------------------------------------------- 用户
let usersCache = [];
async function loadUsers() {
try {
const d = await api('/api/users');
usersCache = d.users || [];
renderUsers();
hideError();
} 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) => `
<tr>
<td class="font-medium">${escapeHtml(u.uid)}</td>
<td>${escapeHtml(u.name || '')}</td>
<td>${escapeHtml(u.email || '')}</td>
<td>${u.state === 'inactive'
? '<span class="badge badge-error badge-sm">离职</span>'
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
<td>${u.drift
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>`
: '<span class="text-base-content/40">—</span>'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td>
</tr>`).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) => `
<tr>
<td class="font-medium">${escapeHtml(g.name || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td>
</tr>`).join('');
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
hideError();
} 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);
hideError();
} catch (err) {
showError(err.message);
}
}
function renderPlan(plan) {
const s = plan.stats || {};
const cards = [
['新建用户', s.createUser, 'text-success'],
['更新用户', s.updateUser, 'text-info'],
['迁入离职', s.deactivateUser, 'text-error'],
['恢复用户', s.reactivateUser, 'text-warning'],
['新建分组', s.createGroup, 'text-success'],
['加入分组', s.addMember, 'text-success'],
['移出分组', s.removeMember, 'text-error'],
];
$('#planSummary').innerHTML = cards.map(([label, value, cls]) => `
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<span class="text-xs text-base-content/60">${label}</span>
<span class="text-2xl font-semibold ${value > 0 ? cls : 'text-base-content/30'}">${value || 0}</span>
</div>
</div>`).join('');
const warnings = plan.warnings || [];
if (warnings.length > 0) {
const el = $('#planWarnings');
el.innerHTML = `<div class="alert alert-warning text-sm">
<div>
<div class="font-medium mb-1">需要注意 ${warnings.length} 项</div>
<ul class="list-disc pl-5 space-y-1">
${warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join('')}
</ul>
</div>
</div>`;
el.classList.remove('hidden');
}
const ops = plan.ops || [];
$('#planRows').innerHTML = ops.map((op) => `
<tr>
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-neutral'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
<td class="font-medium">${escapeHtml(op.target)}</td>
<td>${escapeHtml(op.detail)}</td>
</tr>`).join('');
$('#planEmpty').classList.toggle('hidden', ops.length > 0);
}
async function applyPlan(force = false) {
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
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.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) => `
<tr>
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
<td>${r.mode === 'dry-run'
? '<span class="badge badge-ghost badge-sm">预览</span>'
: '<span class="badge badge-primary badge-sm">执行</span>'}</td>
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
<td>${r.status === 'success'
? '<span class="badge badge-success badge-sm">成功</span>'
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
<td>${r.created}</td><td>${r.updated}</td><td>${r.moved}</td>
<td>${r.added}</td><td>${r.removed}</td>
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
</tr>`).join('');
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
hideError();
} 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ---------------------------------------------------------------- 启动
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);
$('#applyBtn').addEventListener('click', () => {
if (!currentPlan) return;
const s = currentPlan.stats || {};
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0)
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
if (total === 0) {
showError('没有需要应用的变更。');
return;
}
$('#applyModalBody').innerHTML = `
<div class="space-y-1">
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
<div>迁入离职 OU <strong>${s.deactivateUser || 0}</strong> · 恢复用户 <strong>${s.reactivateUser || 0}</strong></div>
<div>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
</div>`;
const modal = document.getElementById('applyModal');
if (modal && typeof modal.showModal === 'function') modal.showModal();
});
$('#applyCancel').addEventListener('click', () => {
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
});
$('#applyConfirm').addEventListener('click', () => applyPlan(false));
// 已有会话则直接进入主界面
api('/api/status')
.then(() => { showApp(); switchView('overview'); })
.catch(() => showLogin());
});