huangning
2026-07-18 679e8bd227bed42ceb86e9268ad4a585a0e12b2c
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) });