Initial commit: Office365 web management platform

This commit is contained in:
youbin
2026-03-21 21:11:01 +08:00
commit 8d715a3a15
21 changed files with 3828 additions and 0 deletions

View File

@@ -0,0 +1,839 @@
const state = {
bootstrap: window.APP_BOOTSTRAP || {},
authenticated: false,
users: [],
page: 1,
pageSize: (window.APP_BOOTSTRAP && window.APP_BOOTSTRAP.pageSize) || 25,
total: 0,
totalBeforeSearch: 0,
summary: { active: 0, disabled: 0 },
search: "",
licenses: [],
selectedUser: null,
selectedUsers: new Set(),
activeTaskId: "",
activeTaskTimer: null,
};
const elements = {
platformStatus: document.getElementById("platform-status"),
platformSubstatus: document.getElementById("platform-substatus"),
metricTotal: document.getElementById("metric-total"),
metricActive: document.getElementById("metric-active"),
metricDisabled: document.getElementById("metric-disabled"),
metricLicense: document.getElementById("metric-license"),
usersTableBody: document.getElementById("users-table-body"),
paginationInfo: document.getElementById("pagination-info"),
licenseList: document.getElementById("license-list"),
resultConsole: document.getElementById("result-console"),
loginSection: document.getElementById("login-section"),
loginForm: document.getElementById("login-form"),
logoutBtn: document.getElementById("logout-btn"),
refreshAllBtn: document.getElementById("refresh-all-btn"),
searchForm: document.getElementById("search-form"),
searchInput: document.getElementById("search-input"),
prevPageBtn: document.getElementById("prev-page-btn"),
nextPageBtn: document.getElementById("next-page-btn"),
userForm: document.getElementById("user-form"),
selectedUserId: document.getElementById("selected-user-id"),
deleteUserBtn: document.getElementById("delete-user-btn"),
newUserBtn: document.getElementById("new-user-btn"),
clearFormBtn: document.getElementById("clear-form-btn"),
resetPasswordBtn: document.getElementById("reset-password-btn"),
selectedCount: document.getElementById("selected-count"),
selectAllResultsBtn: document.getElementById("select-all-results-btn"),
clearSelectionBtn: document.getElementById("clear-selection-btn"),
bulkEnableBtn: document.getElementById("bulk-enable-btn"),
bulkDisableBtn: document.getElementById("bulk-disable-btn"),
bulkResetBtn: document.getElementById("bulk-reset-btn"),
bulkDeleteBtn: document.getElementById("bulk-delete-btn"),
selectPageCheckbox: document.getElementById("select-page-checkbox"),
taskStatusCard: document.getElementById("task-status-card"),
taskStatusTitle: document.getElementById("task-status-title"),
taskStatusState: document.getElementById("task-status-state"),
taskProgressFill: document.getElementById("task-progress-fill"),
taskStatusText: document.getElementById("task-status-text"),
};
const TASK_LABELS = {
"create": "批量创建",
"update": "批量更新",
"delete": "批量删除",
"reset-password": "批量改密",
};
async function api(path, options = {}) {
const config = { ...options };
config.headers = config.headers || {};
if (config.body && !(config.body instanceof FormData)) {
config.headers["Content-Type"] = "application/json";
}
const response = await fetch(path, config);
const payload = await response.json().catch(() => ({
success: false,
message: "接口返回了无法解析的响应。",
}));
if (response.status === 401) {
state.authenticated = false;
updateAuthView();
}
if (!response.ok || !payload.success) {
throw new Error(payload.message || "请求失败");
}
return payload.data;
}
function updatePlatformStatus() {
if (state.bootstrap.graphReady) {
elements.platformStatus.textContent = "Graph 已就绪";
elements.platformSubstatus.textContent = state.bootstrap.graphFlavor || "Microsoft Graph Global";
} else {
elements.platformStatus.textContent = "等待配置";
const errors = state.bootstrap.validationErrors || [];
elements.platformSubstatus.textContent = errors.join("") || "请补充 .env";
}
}
function updateAuthView() {
const needsLogin = state.bootstrap.authEnabled && !state.authenticated;
elements.loginSection.classList.toggle("hidden", !needsLogin);
}
function setConsole(content) {
elements.resultConsole.textContent = content;
}
function appendConsole(title, summary) {
const lines = [
`[${new Date().toLocaleString("zh-CN")}] ${title}`,
String(summary || ""),
"",
];
elements.resultConsole.textContent = lines.join("\n") + elements.resultConsole.textContent;
}
function formatTaskLabel(operation) {
return TASK_LABELS[operation] || operation;
}
function formatTaskState(task) {
if (task.status === "queued") {
return "已提交";
}
if (task.status === "running") {
return "执行中";
}
if (task.status === "succeeded") {
return "已完成";
}
if (task.status === "failed") {
return "失败";
}
return task.status || "未知";
}
function summarizeTask(task) {
const base = `${formatTaskLabel(task.operation)}${task.completed}/${task.total},成功 ${task.successCount},失败 ${task.failureCount}`;
if (task.failureCount > 0) {
return `${base}。详细失败项请查看 logs/office365_admin.log`;
}
return base;
}
function showTaskCard(task) {
if (!elements.taskStatusCard) {
return;
}
elements.taskStatusCard.classList.remove("hidden");
elements.taskStatusTitle.textContent = formatTaskLabel(task.operation);
elements.taskStatusState.textContent = formatTaskState(task);
elements.taskProgressFill.style.width = `${task.progressPercent || 0}%`;
const statusText = [
`进度 ${task.completed || 0} / ${task.total || 0}`,
`成功 ${task.successCount || 0}`,
`失败 ${task.failureCount || 0}`,
].join(" · ");
const currentText = task.currentItem
? ` · 当前 ${task.currentItem}`
: "";
elements.taskStatusText.textContent = `${statusText}${currentText}`;
}
function showSingleActionStatus(title, text) {
if (!elements.taskStatusCard) {
return;
}
elements.taskStatusCard.classList.remove("hidden");
elements.taskStatusTitle.textContent = title;
elements.taskStatusState.textContent = "处理中";
elements.taskProgressFill.style.width = "30%";
elements.taskStatusText.textContent = text;
}
function completeSingleActionStatus(title, text) {
if (!elements.taskStatusCard) {
return;
}
elements.taskStatusCard.classList.remove("hidden");
elements.taskStatusTitle.textContent = title;
elements.taskStatusState.textContent = "已完成";
elements.taskProgressFill.style.width = "100%";
elements.taskStatusText.textContent = text;
}
function showFailedActionStatus(title, text) {
if (!elements.taskStatusCard) {
return;
}
elements.taskStatusCard.classList.remove("hidden");
elements.taskStatusTitle.textContent = title;
elements.taskStatusState.textContent = "失败";
elements.taskProgressFill.style.width = "100%";
elements.taskStatusText.textContent = text;
}
function clearTaskPolling() {
if (state.activeTaskTimer) {
window.clearTimeout(state.activeTaskTimer);
state.activeTaskTimer = null;
}
}
async function pollTask(taskId) {
try {
const task = await api(`/api/tasks/${encodeURIComponent(taskId)}`);
showTaskCard(task);
if (task.status === "queued" || task.status === "running") {
clearTaskPolling();
state.activeTaskTimer = window.setTimeout(() => pollTask(taskId), 1000);
return;
}
state.activeTaskId = "";
clearTaskPolling();
appendConsole(
task.status === "failed" ? `${formatTaskLabel(task.operation)}失败` : `${formatTaskLabel(task.operation)}完成`,
summarizeTask(task),
);
await refreshAll();
} catch (error) {
state.activeTaskId = "";
clearTaskPolling();
showFailedActionStatus("任务轮询失败", error.message);
appendConsole("任务轮询失败", error.message);
}
}
function startTask(task, submittedMessage) {
state.activeTaskId = task.id;
clearTaskPolling();
showTaskCard(task);
appendConsole("任务已提交", submittedMessage);
pollTask(task.id);
}
function statusPill(enabled) {
const klass = enabled ? "active" : "disabled";
const label = enabled ? "启用" : "停用";
return `<span class="status-pill ${klass}">${label}</span>`;
}
function selectedIdentifiers() {
return Array.from(state.selectedUsers);
}
function updateSelectionUi() {
const count = state.selectedUsers.size;
if (elements.selectedCount) {
elements.selectedCount.textContent = count ? `已选择 ${count} 个账号` : "未选择账号";
}
const currentPageIds = state.users.map((user) => user.userPrincipalName).filter(Boolean);
const selectedOnPage = currentPageIds.filter((identifier) => state.selectedUsers.has(identifier)).length;
if (elements.selectPageCheckbox) {
elements.selectPageCheckbox.checked = currentPageIds.length > 0 && selectedOnPage === currentPageIds.length;
elements.selectPageCheckbox.indeterminate = selectedOnPage > 0 && selectedOnPage < currentPageIds.length;
elements.selectPageCheckbox.disabled = currentPageIds.length === 0;
}
[
elements.clearSelectionBtn,
elements.bulkEnableBtn,
elements.bulkDisableBtn,
elements.bulkResetBtn,
elements.bulkDeleteBtn,
].forEach((button) => {
if (button) {
button.disabled = count === 0;
}
});
if (elements.selectAllResultsBtn) {
elements.selectAllResultsBtn.disabled = state.total === 0;
}
}
function renderUsers() {
if (!state.users.length) {
elements.usersTableBody.innerHTML = `<tr><td colspan="7" class="empty-card">没有匹配到用户</td></tr>`;
updateSelectionUi();
return;
}
elements.usersTableBody.innerHTML = state.users.map((user) => {
const identifier = user.userPrincipalName || "";
const checked = state.selectedUsers.has(identifier) ? "checked" : "";
return `
<tr>
<td class="check-cell">
<input type="checkbox" data-select-identifier="${encodeURIComponent(identifier)}" ${checked}>
</td>
<td>${escapeHtml(user.displayName || "-")}</td>
<td>${escapeHtml(identifier || "-")}</td>
<td>
<div>${escapeHtml(user.department || "-")}</div>
<div class="muted">${escapeHtml(user.jobTitle || "-")}</div>
</td>
<td>${statusPill(user.accountEnabled)}</td>
<td>${user.assignedLicensesCount || 0}</td>
<td>
<div class="actions-inline">
<button class="btn btn-secondary" data-action="view" data-identifier="${encodeURIComponent(identifier)}">查看</button>
<button class="btn btn-ghost" data-action="reset" data-identifier="${encodeURIComponent(identifier)}">改密</button>
<button class="btn btn-danger" data-action="delete" data-identifier="${encodeURIComponent(identifier)}">删除</button>
</div>
</td>
</tr>
`;
}).join("");
elements.paginationInfo.textContent = `${state.page} 页,当前 ${state.users.length} / 共 ${state.total}`;
updateSelectionUi();
}
function renderMetrics() {
elements.metricTotal.textContent = String(state.total);
elements.metricActive.textContent = String(state.summary.active || 0);
elements.metricDisabled.textContent = String(state.summary.disabled || 0);
const available = state.licenses.reduce((sum, item) => sum + (item.availableUnits || 0), 0);
elements.metricLicense.textContent = String(available);
const totalFoot = state.search
? `搜索结果 ${state.total} / 全量 ${state.totalBeforeSearch}`
: `全量账号 ${state.totalBeforeSearch}`;
document.getElementById("metric-total-foot").textContent = totalFoot;
}
function renderLicenses() {
if (!state.licenses.length) {
elements.licenseList.innerHTML = `<div class="empty-card">未获取到许可证信息</div>`;
return;
}
elements.licenseList.innerHTML = state.licenses.map((item) => `
<article class="license-card">
<strong>${escapeHtml(item.skuPartNumber || "UNKNOWN")}</strong>
<div>可用席位:${item.availableUnits}</div>
<div>已用席位:${item.consumedUnits}</div>
<div>总席位:${item.totalUnits}</div>
</article>
`).join("");
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
async function fetchSession() {
const data = await api("/api/session");
state.authenticated = data.authenticated;
updateAuthView();
}
async function fetchUsers() {
const query = new URLSearchParams({
page: String(state.page),
pageSize: String(state.pageSize),
search: state.search,
});
const data = await api(`/api/users?${query.toString()}`);
state.users = data.items;
state.total = data.total;
state.totalBeforeSearch = data.totalBeforeSearch;
state.summary = data.summary;
renderUsers();
renderMetrics();
}
async function fetchLicenses() {
const data = await api("/api/licenses");
state.licenses = data;
renderLicenses();
renderMetrics();
}
async function fetchUserDetail(identifier) {
const data = await api(`/api/users/${encodeURIComponent(identifier)}`);
state.selectedUser = data;
fillUserForm(data);
appendConsole("已加载用户", data.userPrincipalName || identifier);
}
function formPayload() {
return {
userPrincipalName: document.getElementById("userPrincipalName").value.trim(),
displayName: document.getElementById("displayName").value.trim(),
givenName: document.getElementById("givenName").value.trim(),
surname: document.getElementById("surname").value.trim(),
department: document.getElementById("department").value.trim(),
jobTitle: document.getElementById("jobTitle").value.trim(),
officeLocation: document.getElementById("officeLocation").value.trim(),
mobilePhone: document.getElementById("mobilePhone").value.trim(),
usageLocation: document.getElementById("usageLocation").value.trim(),
skuPartNumber: document.getElementById("skuPartNumber").value.trim(),
password: document.getElementById("password").value,
accountEnabled: document.getElementById("accountEnabled").checked,
forceChangePasswordNextSignIn: document.getElementById("forceChangePasswordNextSignIn").checked,
};
}
function fillUserForm(user) {
elements.selectedUserId.value = user.id || "";
document.getElementById("userPrincipalName").value = user.userPrincipalName || "";
document.getElementById("displayName").value = user.displayName || "";
document.getElementById("givenName").value = user.givenName || "";
document.getElementById("surname").value = user.surname || "";
document.getElementById("department").value = user.department || "";
document.getElementById("jobTitle").value = user.jobTitle || "";
document.getElementById("officeLocation").value = user.officeLocation || "";
document.getElementById("mobilePhone").value = user.mobilePhone || "";
document.getElementById("usageLocation").value = user.usageLocation || state.bootstrap.defaultUsageLocation || "";
document.getElementById("skuPartNumber").value = (user.licenseLabels && user.licenseLabels[0]) || "";
document.getElementById("password").value = "";
document.getElementById("accountEnabled").checked = Boolean(user.accountEnabled);
document.getElementById("forceChangePasswordNextSignIn").checked = state.bootstrap.forceChangePassword;
}
function clearUserForm() {
state.selectedUser = null;
elements.selectedUserId.value = "";
elements.userForm.reset();
document.getElementById("accountEnabled").checked = true;
document.getElementById("forceChangePasswordNextSignIn").checked = state.bootstrap.forceChangePassword;
document.getElementById("usageLocation").value = state.bootstrap.defaultUsageLocation || "";
}
function toggleUserSelection(identifier, checked) {
if (!identifier) {
return;
}
if (checked) {
state.selectedUsers.add(identifier);
} else {
state.selectedUsers.delete(identifier);
}
updateSelectionUi();
}
function clearSelection() {
state.selectedUsers.clear();
updateSelectionUi();
renderUsers();
}
function toggleCurrentPageSelection(checked) {
state.users.forEach((user) => {
if (!user.userPrincipalName) {
return;
}
if (checked) {
state.selectedUsers.add(user.userPrincipalName);
} else {
state.selectedUsers.delete(user.userPrincipalName);
}
});
renderUsers();
}
async function selectAllMatchingUsers() {
const query = new URLSearchParams({ search: state.search });
const data = await api(`/api/users/selection?${query.toString()}`);
state.selectedUsers = new Set(data.identifiers || []);
renderUsers();
appendConsole("已选中搜索结果", `${state.selectedUsers.size} 个账号`);
}
async function saveUser(event) {
event.preventDefault();
const payload = formPayload();
const identifier = payload.userPrincipalName;
if (!identifier) {
throw new Error("请先填写账号或邮箱。");
}
showSingleActionStatus("保存用户", identifier);
let data;
if (state.selectedUser && state.selectedUser.userPrincipalName) {
data = await api(`/api/users/${encodeURIComponent(state.selectedUser.userPrincipalName)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
appendConsole("用户已更新", data.user.userPrincipalName || identifier);
} else {
data = await api("/api/users", {
method: "POST",
body: JSON.stringify(payload),
});
appendConsole("用户已创建", data.user.userPrincipalName || identifier);
}
completeSingleActionStatus("保存用户", data.user.userPrincipalName || identifier);
await refreshAll();
if (data.user && data.user.userPrincipalName) {
await fetchUserDetail(data.user.userPrincipalName);
}
}
async function deleteCurrentUser(identifier) {
if (!identifier) {
throw new Error("请先选择一个用户。");
}
const confirmDelete = window.confirm(`确认删除 ${identifier} 吗?此操作不可撤销。`);
if (!confirmDelete) {
return;
}
showSingleActionStatus("删除用户", identifier);
await api(`/api/users/${encodeURIComponent(identifier)}`, { method: "DELETE" });
state.selectedUsers.delete(identifier);
appendConsole("用户已删除", identifier);
completeSingleActionStatus("删除用户", identifier);
clearUserForm();
await refreshAll();
}
async function resetPassword(identifier) {
if (!identifier) {
throw new Error("请先选择一个用户。");
}
const payload = {
password: document.getElementById("password").value,
forceChangePasswordNextSignIn: document.getElementById("forceChangePasswordNextSignIn").checked,
};
showSingleActionStatus("重置密码", identifier);
await api(`/api/users/${encodeURIComponent(identifier)}/reset-password`, {
method: "POST",
body: JSON.stringify(payload),
});
appendConsole("密码已重置", identifier);
completeSingleActionStatus("重置密码", identifier);
}
async function submitBatchTask(action, body, summaryText) {
const task = await api(`/api/users/batch/${action}`, {
method: "POST",
body: JSON.stringify(body),
});
startTask(task, summaryText);
}
async function runSelectedBulk(action) {
const identifiers = selectedIdentifiers();
if (!identifiers.length) {
throw new Error("请先勾选至少一个用户。");
}
if (action === "delete") {
const confirmed = window.confirm(`确认批量删除这 ${identifiers.length} 个账号吗?此操作不可撤销。`);
if (!confirmed) {
return;
}
await submitBatchTask("delete", { identifiers }, `批量删除任务已提交,共 ${identifiers.length} 个账号`);
} else if (action === "enable" || action === "disable") {
const rows = identifiers.map((userPrincipalName) => ({
userPrincipalName,
accountEnabled: action === "enable",
}));
await submitBatchTask("update", { rows }, `${action === "enable" ? "批量启用" : "批量停用"}任务已提交,共 ${identifiers.length} 个账号`);
} else if (action === "reset-password") {
const promptedPassword = window.prompt("输入统一临时密码;留空则使用系统默认密码。", "");
if (promptedPassword === null) {
return;
}
const password = promptedPassword.trim();
const rows = identifiers.map((userPrincipalName) => ({
userPrincipalName,
...(password ? { password } : {}),
forceChangePasswordNextSignIn: true,
}));
await submitBatchTask("reset-password", { rows }, `批量改密任务已提交,共 ${identifiers.length} 个账号`);
} else {
throw new Error("不支持的批量操作。");
}
clearSelection();
}
async function runBatch(action) {
const textareaMap = {
"create": document.getElementById("batch-create-content"),
"update": document.getElementById("batch-update-content"),
"delete": document.getElementById("batch-delete-content"),
"reset-password": document.getElementById("batch-reset-content"),
};
const textarea = textareaMap[action];
const content = textarea.value.trim();
if (!content) {
throw new Error("请先粘贴批量内容或上传文件。");
}
await submitBatchTask(action, { content }, `${formatTaskLabel(action)}任务已提交,等待后台处理`);
}
async function refreshAll() {
updatePlatformStatus();
if (state.bootstrap.authEnabled && !state.authenticated) {
return;
}
if (!state.bootstrap.graphReady) {
return;
}
await Promise.all([fetchUsers(), fetchLicenses()]);
}
function bindEvents() {
elements.loginForm?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
showSingleActionStatus("登录后台", "正在验证管理员账号");
await api("/api/login", {
method: "POST",
body: JSON.stringify({
username: document.getElementById("login-username").value.trim(),
password: document.getElementById("login-password").value,
}),
});
await fetchSession();
await refreshAll();
appendConsole("登录成功", "现在可以开始管理租户账号");
completeSingleActionStatus("登录后台", "登录成功");
} catch (error) {
showFailedActionStatus("登录后台", error.message);
appendConsole("登录失败", error.message);
}
});
elements.logoutBtn?.addEventListener("click", async () => {
await api("/api/logout", { method: "POST" });
state.authenticated = false;
updateAuthView();
appendConsole("已退出后台", "如需继续操作,请重新登录");
});
elements.refreshAllBtn?.addEventListener("click", async () => {
try {
showSingleActionStatus("刷新数据", "正在重新加载用户与许可证");
await refreshAll();
appendConsole("刷新完成", "用户列表和许可证信息已更新");
completeSingleActionStatus("刷新数据", "刷新完成");
} catch (error) {
showFailedActionStatus("刷新数据", error.message);
appendConsole("刷新失败", error.message);
}
});
elements.searchForm?.addEventListener("submit", async (event) => {
event.preventDefault();
state.search = elements.searchInput.value.trim();
state.page = 1;
try {
showSingleActionStatus("搜索用户", state.search || "全部用户");
await fetchUsers();
appendConsole("搜索完成", `${state.total} 条匹配结果`);
completeSingleActionStatus("搜索用户", `${state.total} 条匹配结果`);
} catch (error) {
showFailedActionStatus("搜索用户", error.message);
appendConsole("搜索失败", error.message);
}
});
elements.prevPageBtn?.addEventListener("click", async () => {
if (state.page <= 1) {
return;
}
state.page -= 1;
await fetchUsers();
});
elements.nextPageBtn?.addEventListener("click", async () => {
if (state.page * state.pageSize >= state.total) {
return;
}
state.page += 1;
await fetchUsers();
});
elements.selectPageCheckbox?.addEventListener("change", (event) => {
toggleCurrentPageSelection(event.target.checked);
});
elements.selectAllResultsBtn?.addEventListener("click", async () => {
try {
await selectAllMatchingUsers();
} catch (error) {
appendConsole("全选失败", error.message);
}
});
elements.clearSelectionBtn?.addEventListener("click", () => {
clearSelection();
appendConsole("已清空选择", "当前未勾选任何账号");
});
elements.bulkEnableBtn?.addEventListener("click", async () => {
try {
await runSelectedBulk("enable");
} catch (error) {
appendConsole("批量启用失败", error.message);
}
});
elements.bulkDisableBtn?.addEventListener("click", async () => {
try {
await runSelectedBulk("disable");
} catch (error) {
appendConsole("批量停用失败", error.message);
}
});
elements.bulkResetBtn?.addEventListener("click", async () => {
try {
await runSelectedBulk("reset-password");
} catch (error) {
appendConsole("批量改密失败", error.message);
}
});
elements.bulkDeleteBtn?.addEventListener("click", async () => {
try {
await runSelectedBulk("delete");
} catch (error) {
appendConsole("批量删除失败", error.message);
}
});
elements.usersTableBody?.addEventListener("change", (event) => {
const checkbox = event.target.closest("input[data-select-identifier]");
if (!checkbox) {
return;
}
const identifier = decodeURIComponent(checkbox.dataset.selectIdentifier || "");
toggleUserSelection(identifier, checkbox.checked);
});
elements.usersTableBody?.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]");
if (!button) {
return;
}
const action = button.dataset.action;
const identifier = decodeURIComponent(button.dataset.identifier || "");
try {
if (action === "view") {
await fetchUserDetail(identifier);
} else if (action === "delete") {
await deleteCurrentUser(identifier);
} else if (action === "reset") {
await resetPassword(identifier);
}
} catch (error) {
appendConsole("用户操作失败", error.message);
showFailedActionStatus("用户操作失败", error.message);
}
});
elements.userForm?.addEventListener("submit", async (event) => {
try {
await saveUser(event);
} catch (error) {
appendConsole("保存失败", error.message);
showFailedActionStatus("保存用户", error.message);
}
});
elements.newUserBtn?.addEventListener("click", () => clearUserForm());
elements.clearFormBtn?.addEventListener("click", () => clearUserForm());
elements.deleteUserBtn?.addEventListener("click", async () => {
try {
await deleteCurrentUser(state.selectedUser && state.selectedUser.userPrincipalName);
} catch (error) {
appendConsole("删除失败", error.message);
showFailedActionStatus("删除用户", error.message);
}
});
elements.resetPasswordBtn?.addEventListener("click", async () => {
try {
await resetPassword(state.selectedUser && state.selectedUser.userPrincipalName);
} catch (error) {
appendConsole("重置密码失败", error.message);
showFailedActionStatus("重置密码", error.message);
}
});
document.querySelectorAll("[data-batch-action]").forEach((button) => {
button.addEventListener("click", async () => {
try {
await runBatch(button.dataset.batchAction);
} catch (error) {
appendConsole("批量任务提交失败", error.message);
showFailedActionStatus("批量任务提交失败", error.message);
}
});
});
document.querySelectorAll(".file-input").forEach((input) => {
input.addEventListener("change", async (event) => {
const file = event.target.files && event.target.files[0];
if (!file) {
return;
}
const targetId = event.target.dataset.target;
const target = document.getElementById(targetId);
target.value = await file.text();
appendConsole("文件已加载", `${file.name} 已写入输入框`);
});
});
}
async function boot() {
setConsole("等待操作...");
updatePlatformStatus();
bindEvents();
updateSelectionUi();
try {
await fetchSession();
if (!state.bootstrap.authEnabled || state.authenticated) {
await refreshAll();
}
} catch (error) {
appendConsole("初始化失败", error.message);
showFailedActionStatus("初始化失败", error.message);
}
}
boot();

View File

@@ -0,0 +1,3 @@
userPrincipalName,displayName,givenName,surname,department,jobTitle,usageLocation,skuPartNumber,password
alice,Alice Zhang,Alice,Zhang,Sales,Manager,US,ENTERPRISEPACK,Temp123!2026
bob@contoso.com,Bob Li,Bob,Li,IT,Engineer,US,ENTERPRISEPACK,
1 userPrincipalName displayName givenName surname department jobTitle usageLocation skuPartNumber password
2 alice Alice Zhang Alice Zhang Sales Manager US ENTERPRISEPACK Temp123!2026
3 bob@contoso.com Bob Li Bob Li IT Engineer US ENTERPRISEPACK

View File

@@ -0,0 +1,3 @@
userPrincipalName
alice@contoso.com
bob@contoso.com
1 userPrincipalName
2 alice@contoso.com
3 bob@contoso.com

View File

@@ -0,0 +1,3 @@
userPrincipalName,password,forceChangePasswordNextSignIn
alice@contoso.com,Temp123!2026,true
bob@contoso.com,Another123!2026,true
1 userPrincipalName password forceChangePasswordNextSignIn
2 alice@contoso.com Temp123!2026 true
3 bob@contoso.com Another123!2026 true

View File

@@ -0,0 +1,3 @@
userPrincipalName,department,jobTitle,officeLocation,mobilePhone,accountEnabled,skuPartNumber
alice@contoso.com,Operations,Lead,New York,+15550000001,true,ENTERPRISEPACK
bob@contoso.com,Finance,Analyst,Seattle,,false,
1 userPrincipalName department jobTitle officeLocation mobilePhone accountEnabled skuPartNumber
2 alice@contoso.com Operations Lead New York +15550000001 true ENTERPRISEPACK
3 bob@contoso.com Finance Analyst Seattle false

View File

@@ -0,0 +1,542 @@
:root {
--bg: #f6f1e8;
--panel: rgba(255, 250, 244, 0.82);
--panel-strong: rgba(255, 252, 248, 0.94);
--text: #112238;
--muted: #5f6f80;
--line: rgba(17, 34, 56, 0.12);
--brand: #0f6c7b;
--brand-strong: #0a425a;
--accent: #c65d3a;
--danger: #a33a2b;
--success: #217253;
--shadow: 0 20px 50px rgba(17, 34, 56, 0.12);
--radius: 24px;
--radius-sm: 16px;
--font-sans: "Avenir Next", "Trebuchet MS", "PingFang SC", "Microsoft YaHei", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-sans);
color: var(--text);
background:
radial-gradient(circle at top left, rgba(15, 108, 123, 0.18), transparent 30%),
linear-gradient(135deg, #f8f4ec 0%, #f0ebe3 45%, #e2edf1 100%);
}
button,
input,
textarea {
font: inherit;
}
.background-glow {
position: fixed;
inset: auto;
width: 28rem;
height: 28rem;
border-radius: 999px;
filter: blur(70px);
opacity: 0.32;
pointer-events: none;
}
.glow-a {
top: -8rem;
right: -10rem;
background: #f4a261;
}
.glow-b {
left: -8rem;
bottom: -12rem;
background: #4da6b3;
}
.shell {
position: relative;
max-width: 1500px;
margin: 0 auto;
padding: 36px 24px 48px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.8fr) minmax(280px, 0.8fr);
gap: 24px;
align-items: stretch;
margin-bottom: 24px;
}
.hero-copy,
.hero-side,
.panel,
.metric-card {
background: var(--panel);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: var(--shadow);
}
.hero-copy {
padding: 28px;
border-radius: calc(var(--radius) + 4px);
}
.hero-copy h1 {
margin: 10px 0 12px;
font-size: clamp(2rem, 3vw, 3.4rem);
line-height: 1.05;
}
.eyebrow {
margin: 0;
font-size: 0.78rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--brand);
}
.hero-text {
margin: 0;
max-width: 48rem;
font-size: 1.02rem;
line-height: 1.7;
color: var(--muted);
}
.hero-side {
padding: 24px;
border-radius: calc(var(--radius) + 4px);
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 18px;
}
.status-card {
display: grid;
gap: 8px;
padding: 18px;
border-radius: var(--radius-sm);
background: linear-gradient(145deg, rgba(15, 108, 123, 0.09), rgba(198, 93, 58, 0.06));
}
.status-card strong {
font-size: 1.1rem;
}
.status-label,
.muted {
color: var(--muted);
}
.hero-actions,
.editor-actions,
.pager,
.form-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.dashboard {
display: grid;
gap: 24px;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.metric-card {
border-radius: 22px;
padding: 20px;
display: grid;
gap: 8px;
}
.metric-label {
color: var(--muted);
font-size: 0.92rem;
}
.metric-card strong {
font-size: clamp(1.8rem, 4vw, 2.6rem);
}
.metric-foot {
color: var(--muted);
font-size: 0.86rem;
}
.panel {
border-radius: var(--radius);
padding: 24px;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 18px;
}
.panel-head h2,
.batch-card h3 {
margin: 0 0 8px;
}
.panel-head p,
.batch-card p {
margin: 0;
color: var(--muted);
line-height: 1.65;
}
.search-form,
.inline-form {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: end;
}
.search-form input,
.inline-form input,
.user-form input,
.batch-card textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.72);
color: var(--text);
}
.inline-form label,
.user-form label {
display: grid;
gap: 8px;
color: var(--muted);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
padding: 11px 18px;
cursor: pointer;
text-decoration: none;
transition: transform 0.16s ease, opacity 0.16s ease, background 0.16s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.48;
cursor: not-allowed;
transform: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--brand), var(--brand-strong));
color: #fff;
}
.btn-secondary {
background: #d7ecef;
color: var(--brand-strong);
}
.btn-ghost {
background: rgba(17, 34, 56, 0.08);
color: var(--text);
}
.btn-danger {
background: linear-gradient(135deg, var(--danger), #7d241d);
color: #fff;
}
.table-wrap {
overflow: auto;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
}
.selection-toolbar {
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: var(--panel-strong);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.selection-meta,
.selection-actions,
.table-checkbox,
.batch-card-top {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.batch-card-top {
justify-content: space-between;
align-items: flex-start;
}
.table-checkbox {
color: var(--muted);
font-size: 0.82rem;
}
.check-col,
.check-cell {
width: 76px;
}
.check-cell {
text-align: center;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 980px;
}
th,
td {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th {
font-size: 0.84rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
tbody tr:hover {
background: rgba(15, 108, 123, 0.05);
}
.table-footer {
margin-top: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
font-size: 0.82rem;
}
.status-pill.active {
background: rgba(33, 114, 83, 0.14);
color: var(--success);
}
.status-pill.disabled {
background: rgba(163, 58, 43, 0.12);
color: var(--danger);
}
.actions-inline {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.actions-inline .btn {
padding: 8px 12px;
font-size: 0.88rem;
}
.user-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 16px;
}
.checkbox {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox input {
width: auto;
}
.form-actions {
grid-column: 1 / -1;
padding-top: 8px;
}
.license-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.license-card,
.empty-card {
padding: 16px 18px;
border-radius: 18px;
background: var(--panel-strong);
border: 1px solid var(--line);
}
.license-card strong {
display: block;
margin-bottom: 10px;
}
.batch-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.batch-card {
padding: 18px;
border-radius: 20px;
background: var(--panel-strong);
border: 1px solid var(--line);
display: grid;
gap: 12px;
}
.file-input {
width: 100%;
}
.batch-card textarea {
min-height: 180px;
resize: vertical;
}
.console {
margin: 0;
padding: 18px;
min-height: 220px;
border-radius: 18px;
background: #102338;
color: #edf6ff;
overflow: auto;
white-space: pre-wrap;
line-height: 1.6;
}
.task-status-card {
margin-bottom: 14px;
padding: 16px 18px;
border-radius: 18px;
background: var(--panel-strong);
border: 1px solid var(--line);
display: grid;
gap: 10px;
}
.task-status-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.task-progress-track {
width: 100%;
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(17, 34, 56, 0.1);
}
.task-progress-fill {
width: 0%;
height: 100%;
background: linear-gradient(135deg, var(--brand), var(--accent));
transition: width 0.2s ease;
}
.empty-cell,
.hidden {
display: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 999px;
background: rgba(15, 108, 123, 0.09);
color: var(--brand-strong);
font-size: 0.82rem;
}
@media (max-width: 1100px) {
.hero,
.metrics,
.batch-grid,
.user-form {
grid-template-columns: 1fr;
}
.panel-head,
.table-footer,
.selection-toolbar,
.batch-card-top {
flex-direction: column;
align-items: stretch;
}
.search-form input {
min-width: 100%;
}
}
@media (max-width: 720px) {
.shell {
padding: 18px 14px 28px;
}
.panel,
.hero-copy,
.hero-side {
padding: 18px;
border-radius: 20px;
}
}