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