| | |
| | | .login-card h2 { margin: 0 0 6px; font-size: 22px; } |
| | | .login-card .field { margin-top: 14px; } |
| | | .account-chip { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; } |
| | | .password-modal { display: none; position: fixed; inset: 0; z-index: 30; align-items: center; justify-content: center; padding: 16px; background: rgba(15, 23, 42, .48); } |
| | | .password-modal.active { display: flex; } |
| | | .password-card { width: min(460px, 100%); max-height: calc(100vh - 32px); overflow-y: auto; background: #fff; border: 1px solid #d9e0ea; border-radius: 10px; box-shadow: 0 20px 50px rgba(15, 23, 42, .24); } |
| | | .password-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 18px 12px; border-bottom: 1px solid #e5eaf1; } |
| | | .password-head h2 { margin: 0; font-size: 20px; } |
| | | .password-form { display: grid; gap: 14px; padding: 18px; } |
| | | .password-form .field label { color: #334155; font-size: 13px; font-weight: 700; } |
| | | .password-form input:focus, .password-form button:focus-visible, #changePasswordButton:focus-visible { outline: 3px solid rgba(37, 99, 235, .3); outline-offset: 2px; } |
| | | .password-form input[aria-invalid="true"] { border-color: #c24136; } |
| | | .password-hint { color: #66758b; font-size: 12px; margin-top: 5px; } |
| | | .password-status { display: none; border: 1px solid #fecaca; background: #fff1f1; color: #991b1b; padding: 10px 12px; border-radius: 8px; } |
| | | .password-status.show { display: block; } |
| | | .password-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 2px; } |
| | | .password-actions .btn { min-height: 44px; } |
| | | .alert.notice { border-color: #cfe0ff; background: #eef5ff; color: #1d4ed8; } |
| | | .permission-grid { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 8px 12px; padding: 10px; border: 1px solid #dde3ec; border-radius: 8px; background: #fbfcfe; } |
| | | .permission-grid label { display: flex; gap: 7px; align-items: center; color: #334155; font-size: 13px; margin: 0; } |
| | | .permission-grid input { width: auto; } |
| | | @media (max-width: 960px) { body { overflow: auto; } .app { grid-template-columns: 1fr; height: auto; } header { height: auto; padding: 14px; flex-direction: column; align-items: flex-start; } .stats, .grid, .form, .permission-grid, .sheet-top, .sheet-lower, .settle-grid { grid-template-columns: 1fr; } .wide { grid-column: span 1; } aside { position: static; height: auto; max-height: 46vh; } main { height: auto; overflow: visible; } .panel { overflow-x: auto; } table { min-width: 860px; } .sheet-card { inset: 10px; min-width: 0; } } |
| | | @media (max-width: 960px) { body { overflow: auto; } .app { grid-template-columns: 1fr; height: auto; } header { height: auto; padding: 14px; flex-direction: column; align-items: flex-start; } .account-chip { justify-content: flex-start; } .stats, .grid, .form, .permission-grid, .sheet-top, .sheet-lower, .settle-grid { grid-template-columns: 1fr; } .wide { grid-column: span 1; } aside { position: static; height: auto; max-height: 46vh; } main { height: auto; overflow: visible; } .panel { overflow-x: auto; } table { min-width: 860px; } .sheet-card { inset: 10px; min-width: 0; } } |
| | | @media (max-width: 480px) { .password-modal { align-items: flex-start; padding: 16px; overflow-y: auto; } .password-card { max-height: none; } .password-head, .password-form { padding-left: 16px; padding-right: 16px; } .password-actions { flex-direction: column-reverse; } .password-actions .btn { width: 100%; } } |
| | | </style> |
| | | </head> |
| | | <body> |
| | |
| | | <main> |
| | | <header> |
| | | <div><h1 id="title">工作台</h1><div class="sub" id="subtitle">请先登录账号。</div></div> |
| | | <div class="account-chip"><span class="tag blue-tag" id="accountBadge">未登录</span><button class="btn" onclick="logout()">退出</button></div> |
| | | <div class="account-chip"><span class="tag blue-tag" id="accountBadge">未登录</span><button class="btn" id="changePasswordButton" type="button" onclick="openPasswordChangeModal()" hidden>修改密码</button><button class="btn" type="button" onclick="logout()">退出</button></div> |
| | | </header> |
| | | <div class="workspace-tabs" id="workspaceTabs"></div> |
| | | <section class="content"> |
| | |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="password-modal" id="passwordChangeModal" aria-hidden="true"> |
| | | <div class="password-card" role="dialog" aria-modal="true" aria-labelledby="passwordChangeTitle" aria-describedby="passwordChangeDescription"> |
| | | <div class="password-head"> |
| | | <div><h2 id="passwordChangeTitle">修改密码</h2><div class="sub" id="passwordChangeDescription">修改成功后将退出当前登录,请使用新密码重新登录。</div></div> |
| | | </div> |
| | | <form class="password-form" id="passwordChangeForm" novalidate> |
| | | <div class="field"> |
| | | <label for="currentPassword">当前密码</label> |
| | | <input id="currentPassword" name="currentPassword" type="password" autocomplete="current-password" aria-describedby="passwordChangeAlert" required> |
| | | </div> |
| | | <div class="field"> |
| | | <label for="newPassword">新密码</label> |
| | | <input id="newPassword" name="newPassword" type="password" autocomplete="new-password" aria-describedby="newPasswordHint passwordChangeAlert" required> |
| | | <div class="password-hint" id="newPasswordHint">8至64个Unicode码点,输入内容不会被裁剪或规范化。</div> |
| | | </div> |
| | | <div class="field"> |
| | | <label for="confirmPassword">确认新密码</label> |
| | | <input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" aria-describedby="passwordChangeAlert" required> |
| | | </div> |
| | | <div class="password-status" id="passwordChangeAlert" role="alert" aria-live="assertive" tabindex="-1"></div> |
| | | <div class="password-actions"> |
| | | <button class="btn" id="passwordChangeCancel" type="button" onclick="closePasswordChangeModal()">取消</button> |
| | | <button class="btn primary" id="passwordChangeSubmit" type="submit">确认修改</button> |
| | | </div> |
| | | </form> |
| | | </div> |
| | | </div> |
| | | <div class="toast" id="toast"></div> |
| | | |
| | | <script> |
| | |
| | | const localStoreKey = "sales_crm_full_demo_state_v1"; |
| | | const accountStoreKey = "sales_crm_account_v1"; |
| | | let auth = null; |
| | | let passwordChangeSubmitting = false; |
| | | let passwordChangeReturnFocus = null; |
| | | localStorage.removeItem(accountStoreKey); |
| | | const dealStatuses = ["已成交", "待发货/执行", "已交付", "售后维护中", "复购跟进中"]; |
| | | const customerCategoryOptions = ["公众号", "快手", "抖音", "淘宝店铺", "天猫", "京东POP", "京东自营", "百度", "视频号", "360", "阿里店铺", "阿里新店", "爱采购", "微信小店", "小红书", "1688商家", "公司自营", "渠道经销商", "线上经销商", "其他"]; |
| | |
| | | } |
| | | throw err; |
| | | }); |
| | | } |
| | | |
| | | const passwordChangeErrorMessages = Object.freeze({ |
| | | INVALID_REQUEST: "请求无效,请检查输入后重试。", |
| | | AUTHENTICATION_REQUIRED: "登录状态已失效,请重新登录。", |
| | | ACCOUNT_DISABLED: "账号不可用,请联系主管。", |
| | | CURRENT_PASSWORD_INCORRECT: "当前密码不正确。", |
| | | PASSWORD_LENGTH_INVALID: "新密码必须为8至64个Unicode码点。", |
| | | NEW_PASSWORD_SAME_AS_CURRENT: "新密码不能与当前密码相同。", |
| | | PASSWORD_UPDATE_CONFLICT: "账号状态已变化,请重新登录后再试。", |
| | | PASSWORD_CHANGE_UNAVAILABLE: "修改密码服务暂时不可用,请稍后手动重试。", |
| | | INTERNAL_ERROR: "修改失败,请稍后重试。" |
| | | }); |
| | | |
| | | async function requestPasswordChange(currentPassword, newPassword) { |
| | | if (!auth?.token) { |
| | | const error = new Error("Password change authentication required"); |
| | | error.code = "AUTHENTICATION_REQUIRED"; |
| | | throw error; |
| | | } |
| | | const controller = new AbortController(); |
| | | const timeout = setTimeout(() => controller.abort(), 15000); |
| | | try { |
| | | const response = await fetch(apiBase + "/api/v1/me/password/change", { |
| | | method: "POST", |
| | | headers: { |
| | | "Content-Type": "application/json", |
| | | Authorization: `Bearer ${auth.token}` |
| | | }, |
| | | body: JSON.stringify({ currentPassword, newPassword }), |
| | | cache: "no-store", |
| | | signal: controller.signal |
| | | }); |
| | | if (response.status === 204) return; |
| | | |
| | | let code = ""; |
| | | try { |
| | | const data = await response.json(); |
| | | if (typeof data?.error?.code === "string") code = data.error.code; |
| | | } catch { |
| | | code = ""; |
| | | } |
| | | const error = new Error("Password change failed"); |
| | | error.code = Object.prototype.hasOwnProperty.call(passwordChangeErrorMessages, code) ? code : "UNKNOWN_RESPONSE"; |
| | | throw error; |
| | | } catch (error) { |
| | | if (error.name === "AbortError" || error instanceof TypeError) { |
| | | const networkError = new Error("Password change unavailable"); |
| | | networkError.code = "PASSWORD_CHANGE_UNAVAILABLE"; |
| | | throw networkError; |
| | | } |
| | | throw error; |
| | | } finally { |
| | | clearTimeout(timeout); |
| | | } |
| | | } |
| | | |
| | | function clearPasswordChangeFeedback() { |
| | | const alert = document.getElementById("passwordChangeAlert"); |
| | | alert.textContent = ""; |
| | | alert.classList.remove("show"); |
| | | document.querySelectorAll("#passwordChangeForm input").forEach(input => input.removeAttribute("aria-invalid")); |
| | | } |
| | | |
| | | function clearPasswordChangeForm() { |
| | | document.getElementById("passwordChangeForm").reset(); |
| | | clearPasswordChangeFeedback(); |
| | | } |
| | | |
| | | function setPasswordChangeError(message, fieldName = "") { |
| | | clearPasswordChangeFeedback(); |
| | | const alert = document.getElementById("passwordChangeAlert"); |
| | | alert.textContent = message; |
| | | alert.classList.add("show"); |
| | | const field = fieldName ? document.querySelector(`#passwordChangeForm [name="${fieldName}"]`) : null; |
| | | if (field) { |
| | | field.setAttribute("aria-invalid", "true"); |
| | | field.focus(); |
| | | } else { |
| | | alert.focus?.(); |
| | | } |
| | | } |
| | | |
| | | function setPasswordChangeBusy(busy) { |
| | | passwordChangeSubmitting = busy; |
| | | document.querySelectorAll("#passwordChangeForm input, #passwordChangeForm button").forEach(control => { control.disabled = busy; }); |
| | | const submit = document.getElementById("passwordChangeSubmit"); |
| | | submit.textContent = busy ? "正在修改…" : "确认修改"; |
| | | document.getElementById("passwordChangeForm").setAttribute("aria-busy", busy ? "true" : "false"); |
| | | } |
| | | |
| | | function openPasswordChangeModal() { |
| | | if (!auth?.token || passwordChangeSubmitting) return; |
| | | passwordChangeReturnFocus = document.activeElement; |
| | | clearPasswordChangeForm(); |
| | | const modal = document.getElementById("passwordChangeModal"); |
| | | modal.classList.add("active"); |
| | | modal.setAttribute("aria-hidden", "false"); |
| | | const app = document.querySelector(".app"); |
| | | app.inert = true; |
| | | app.setAttribute("aria-hidden", "true"); |
| | | document.body.style.overflow = "hidden"; |
| | | document.getElementById("currentPassword").focus(); |
| | | } |
| | | |
| | | function closePasswordChangeModal(options = {}) { |
| | | if (passwordChangeSubmitting && !options.force) return; |
| | | clearPasswordChangeForm(); |
| | | const modal = document.getElementById("passwordChangeModal"); |
| | | modal.classList.remove("active"); |
| | | modal.setAttribute("aria-hidden", "true"); |
| | | const app = document.querySelector(".app"); |
| | | app.inert = false; |
| | | app.removeAttribute("aria-hidden"); |
| | | document.body.style.overflow = ""; |
| | | if (options.restoreFocus !== false && passwordChangeReturnFocus?.focus) passwordChangeReturnFocus.focus(); |
| | | passwordChangeReturnFocus = null; |
| | | } |
| | | |
| | | function validatePasswordChange(form) { |
| | | clearPasswordChangeFeedback(); |
| | | const currentPassword = form.elements.currentPassword.value; |
| | | const newPassword = form.elements.newPassword.value; |
| | | const confirmPassword = form.elements.confirmPassword.value; |
| | | if (!currentPassword) return { message: "请输入当前密码。", field: "currentPassword" }; |
| | | if (!newPassword) return { message: "请输入新密码。", field: "newPassword" }; |
| | | if (!confirmPassword) return { message: "请再次输入新密码。", field: "confirmPassword" }; |
| | | const codePointLength = Array.from(newPassword).length; |
| | | if (codePointLength < 8 || codePointLength > 64) return { message: passwordChangeErrorMessages.PASSWORD_LENGTH_INVALID, field: "newPassword" }; |
| | | if (newPassword === currentPassword) return { message: passwordChangeErrorMessages.NEW_PASSWORD_SAME_AS_CURRENT, field: "newPassword" }; |
| | | if (confirmPassword !== newPassword) return { message: "两次输入的新密码不一致。", field: "confirmPassword" }; |
| | | return { currentPassword, newPassword }; |
| | | } |
| | | |
| | | function handlePasswordChangeError(error) { |
| | | const code = error?.code || "UNKNOWN_RESPONSE"; |
| | | if (code === "AUTHENTICATION_REQUIRED") { |
| | | logout(passwordChangeErrorMessages[code]); |
| | | return; |
| | | } |
| | | if (code === "ACCOUNT_DISABLED") { |
| | | logout(passwordChangeErrorMessages[code]); |
| | | return; |
| | | } |
| | | if (code === "PASSWORD_UPDATE_CONFLICT") { |
| | | logout(passwordChangeErrorMessages[code]); |
| | | return; |
| | | } |
| | | if (code === "CURRENT_PASSWORD_INCORRECT") { |
| | | document.getElementById("currentPassword").value = ""; |
| | | setPasswordChangeError(passwordChangeErrorMessages[code], "currentPassword"); |
| | | return; |
| | | } |
| | | if (code === "PASSWORD_LENGTH_INVALID" || code === "NEW_PASSWORD_SAME_AS_CURRENT") { |
| | | setPasswordChangeError(passwordChangeErrorMessages[code], "newPassword"); |
| | | return; |
| | | } |
| | | const message = passwordChangeErrorMessages[code] || "修改失败,未确认任何更改,请稍后重试。"; |
| | | setPasswordChangeError(message); |
| | | } |
| | | |
| | | function loadLocalState() { |
| | |
| | | ? `当前账号:${currentUser}。可管理账号权限和全部业务。` |
| | | : `当前账号:${currentUser}。已开通 ${permissionCount} 项权限,按权限显示对应功能。`; |
| | | document.getElementById("accountBadge").textContent = `${currentUser}|${roleText}`; |
| | | document.getElementById("changePasswordButton").hidden = false; |
| | | const accountsNav = document.getElementById("accountsNav"); |
| | | if (accountsNav) accountsNav.style.display = hasPerm("accounts:manage") ? "" : "none"; |
| | | if (!hasPerm("accounts:manage") && activeView === "accounts") setView("dashboard"); |
| | |
| | | document.getElementById("loginScreen").classList.remove("active"); |
| | | } |
| | | |
| | | function logout() { |
| | | function logout(message = "", notice = false) { |
| | | closePasswordChangeModal({ force: true, restoreFocus: false }); |
| | | auth = null; |
| | | localStorage.removeItem(accountStoreKey); |
| | | state = { customers: [], reminders: [] }; |
| | | role = "sales"; |
| | | currentUser = ""; |
| | | activeView = "dashboard"; |
| | | activeFeature = "dashboard"; |
| | | openedTabs = [{ view: "dashboard", feature: "dashboard", title: "工作台" }]; |
| | | closeDrawer(); |
| | | document.getElementById("accountBadge").textContent = "未登录"; |
| | | document.getElementById("changePasswordButton").hidden = true; |
| | | document.getElementById("subtitle").textContent = "请先登录账号。"; |
| | | document.querySelectorAll(".view").forEach(view => view.classList.toggle("active", view.id === "dashboard")); |
| | | document.querySelectorAll(".nav button").forEach(button => button.classList.toggle("active", button.dataset.view === "dashboard")); |
| | | document.getElementById("title").textContent = "工作台"; |
| | | render(); |
| | | const loginForm = document.getElementById("loginForm"); |
| | | loginForm.elements.password.value = ""; |
| | | const loginAlert = document.getElementById("loginAlert"); |
| | | loginAlert.textContent = message; |
| | | loginAlert.classList.toggle("show", !!message); |
| | | loginAlert.classList.toggle("notice", !!message && notice); |
| | | showLogin(); |
| | | setTimeout(() => loginForm.elements.username.focus(), 0); |
| | | } |
| | | |
| | | function updateOwnerControls() { |
| | |
| | | setPermissionChecks(defaultPermissionSet(event.target.value)); |
| | | }); |
| | | renderPermissionChecks(defaultPermissionSet("sales")); |
| | | document.getElementById("passwordChangeForm").addEventListener("submit", async (event) => { |
| | | event.preventDefault(); |
| | | if (passwordChangeSubmitting) return; |
| | | const form = event.currentTarget; |
| | | const values = validatePasswordChange(form); |
| | | if (values.message) { |
| | | setPasswordChangeError(values.message, values.field); |
| | | return; |
| | | } |
| | | setPasswordChangeBusy(true); |
| | | try { |
| | | await requestPasswordChange(values.currentPassword, values.newPassword); |
| | | setPasswordChangeBusy(false); |
| | | logout("密码已修改,请使用新密码重新登录。", true); |
| | | } catch (error) { |
| | | setPasswordChangeBusy(false); |
| | | handlePasswordChangeError(error); |
| | | } |
| | | }); |
| | | document.getElementById("passwordChangeModal").addEventListener("keydown", (event) => { |
| | | if (!event.currentTarget.classList.contains("active")) return; |
| | | if (event.key === "Escape") { |
| | | if (!passwordChangeSubmitting) { |
| | | event.preventDefault(); |
| | | closePasswordChangeModal(); |
| | | } |
| | | return; |
| | | } |
| | | if (event.key !== "Tab" || passwordChangeSubmitting) return; |
| | | const focusable = [...event.currentTarget.querySelectorAll("input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])")]; |
| | | if (!focusable.length) return; |
| | | const first = focusable[0]; |
| | | const last = focusable[focusable.length - 1]; |
| | | if (event.shiftKey && document.activeElement === first) { |
| | | event.preventDefault(); |
| | | last.focus(); |
| | | } else if (!event.shiftKey && document.activeElement === last) { |
| | | event.preventDefault(); |
| | | first.focus(); |
| | | } |
| | | }); |
| | | document.getElementById("loginForm").addEventListener("submit", async (event) => { |
| | | event.preventDefault(); |
| | | const form = event.target; |
| | | const loginAlert = document.getElementById("loginAlert"); |
| | | loginAlert.classList.remove("show"); |
| | | loginAlert.classList.remove("show", "notice"); |
| | | try { |
| | | const body = Object.fromEntries(new FormData(form).entries()); |
| | | const result = await api("/api/login", { method: "POST", body: JSON.stringify(body) }); |