Initial commit: Office365 web management platform
This commit is contained in:
839
office365_admin/static/app.js
Normal file
839
office365_admin/static/app.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -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,
|
||||
|
@@ -0,0 +1,3 @@
|
||||
userPrincipalName
|
||||
alice@contoso.com
|
||||
bob@contoso.com
|
||||
|
@@ -0,0 +1,3 @@
|
||||
userPrincipalName,password,forceChangePasswordNextSignIn
|
||||
alice@contoso.com,Temp123!2026,true
|
||||
bob@contoso.com,Another123!2026,true
|
||||
|
@@ -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,
|
||||
|
542
office365_admin/static/styles.css
Normal file
542
office365_admin/static/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user