From 679e8bd227bed42ceb86e9268ad4a585a0e12b2c Mon Sep 17 00:00:00 2001
From: huangning <huangning@boborobots.com>
Date: Sat, 18 Jul 2026 01:32:27 +0800
Subject: [PATCH] 完善研发治理与员工改密最小闭环
---
outputs/sales_crm_full_demo.html | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 271 insertions(+), 4 deletions(-)
diff --git a/outputs/sales_crm_full_demo.html b/outputs/sales_crm_full_demo.html
index bbbbd40..6afcf0c 100644
--- a/outputs/sales_crm_full_demo.html
+++ b/outputs/sales_crm_full_demo.html
@@ -111,10 +111,26 @@
.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>
@@ -184,7 +200,7 @@
<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">
@@ -578,6 +594,34 @@
</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>
@@ -623,6 +667,8 @@
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商家", "公司自营", "渠道经销商", "线上经销商", "其他"];
@@ -688,6 +734,165 @@
}
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() {
@@ -1129,6 +1334,7 @@
? `当前账号:${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");
@@ -1229,12 +1435,32 @@
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() {
@@ -2338,11 +2564,52 @@
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) });
--
Gitblit v1.9.3