Skip to content

5 个前端 bug:输入法回车误发送、fetch 无超时导致死锁、禁用存储时页面失效、有序列表编号错乱 #3

Description

@TeddyCzy

又通读了一遍新版 index.html,记了 5 个。第 1 条是中文用户几乎人人会踩的,第 2、3 条会让页面彻底卡住。

每条都给了完整的替换代码,可以直接整段替换,不需要在原函数里挑着改。行号对应当前 main(791b8d0),如果本地版本不一样,以引用的代码内容为准。

涉及的位置:

# 问题 改哪里
1 输入法回车误发送 index.html:763-765 的 keydown 监听
2 fetch 无超时导致死锁 requestInterview() + interviewerTurn()
3 禁用存储时整页点不动 index.html:581-586 + index.html:636-639
4 有序列表编号全是 1 renderMD() 整个函数
5 开场失败后状态错乱 startInterview()

1. 中文输入法选词的那下回车会误发送

index.html:763-765:

$("input").addEventListener("keydown", e => {
  if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
});

没有判断输入法组字状态。

复现:Chrome / Edge + 微软拼音或搜狗,在输入框打 wo juede zhege wenti,候选框弹出来,按 Enter 选词上屏。这一下浏览器会先派发一个 isComposing: true 的 keydown(Enter),代码直接 preventDefault() + send()

  • 如果此时 input.value 是空的,send()if (!t) 挡住了,表现是「选词失效,字没上屏」
  • 如果之前已经有内容,就把半句话直接发给面试官了,而且 busy 会锁住输入框,用户后面继续打的字全丢

这是个纯中文界面的应用,拼音输入法是默认路径。

替换成:

$("input").addEventListener("keydown", e => {
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
    e.preventDefault();
    send();
  }
});

keyCode === 229 是给老 Safari 和部分安卓 WebView 兜底的,那些环境上 isComposing 不可靠。


2. fetch 没有超时,请求挂住就是永久死锁

index.html:646fetch 没有 AbortController,index.html:721busy = false 也不在 finally 里。

复现:面试中点发送,遇到 Worker 冷启动卡住,或者手机从 4G 切到 WiFi 导致 TCP 半开。这种情况下 fetch 既不 resolve 也不 reject,能挂好几分钟直到 OS 层超时。

这段时间里 busy === true,而 setControls()(index.html:689-694)会把 input / sendBtn / endBtn / startBtn 全部 disabled ——「面试官正在思考」一直转,页面上没有任何能点的东西,只能刷新。而刷新等于整场面试丢光(history 只在内存里,没有任何持久化)。

生成评价那次输出最长、最容易超时,偏偏也是最不能失败的一次。

requestInterview()(index.html:644-666)整个替换成:

async function requestInterview(mode, c) {
  if (!API_BASE_URL || API_BASE_URL.includes("YOUR_WORKER")) throw new Error("服务尚未配置,请联系维护者");
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 90000);
  try {
    const response = await fetch(API_BASE_URL + "/api/interview", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      signal: ctrl.signal,
      body: JSON.stringify({
        mode,
        config: {
          company: c.company,
          position: c.position,
          jd: c.jd,
          roundInfo: c.roundInfo,
          persona: c.persona
        },
        history: history.filter(m => !m.hidden).map(m => ({ role: m.role, text: m.text }))
      })
    });
    let data = {};
    try { data = await response.json(); } catch (_) {}
    if (!response.ok) throw new Error(data.error || `请求失败 (HTTP ${response.status})`);
    if (!data.answer) throw new Error("没有收到回复");
    return data.answer;
  } catch (e) {
    if (e.name === "AbortError") throw new Error("请求超时(90 秒),请重试");
    throw e;
  } finally {
    clearTimeout(timer);
  }
}

再把 interviewerTurn()(index.html:698-726)整个替换成:

async function interviewerTurn(mode = "reply") {
  const c = cfg();
  busy = true;
  setControls();
  const bubble = addBubble("interviewer", "<span class='typing'>面试官正在思考</span>");
  let succeeded = false;
  try {
    const finalText = await requestInterview(mode, c);
    bubble.innerHTML = renderMD(finalText);
    history.push({ role: "assistant", text: finalText });
    pendingUserMessage = null;
    succeeded = true;
  } catch (e) {
    bubble.parentElement.classList.add("error");
    bubble.innerHTML = "出错了:" + esc(e.message) + "<br>请稍后重试;如果问题持续,请联系维护者。";
    if (mode === "reply" && history.length && history[history.length - 1].role === "user") {
      const lastUser = history.pop();
      $("input").value = lastUser.text;
      autosize();
      pendingUserMessage?.remove();
      pendingUserMessage = null;
    }
  } finally {
    busy = false;
    $("msgs").scrollTop = $("msgs").scrollHeight;
    setControls();
    $("input").focus();
  }
  return succeeded;
}

改动有三处:超时、finally 里解锁(任何异常路径都不会再卡死)、以及回滚时补一个 autosize()。最后这个是顺手修的另一个小问题 —— 现在写了 300 字的长回答发送失败,文本塞回输入框了,但输入框还是一行的高度,得在小框里滚动才能看全。


3. 浏览器禁用存储时,整个页面会变成点不动的死页

index.html:581-586:

FIELDS.forEach(f => {
  const el = $(f);
  const saved = localStorage.getItem("iprep_" + f);   // ← 裸调用

这是顶层脚本执行流,不是事件回调。Safari 的「阻止所有 Cookie」、Firefox 关掉 dom.storage.enabled、某些企业策略下,访问 localStorage 会抛 SecurityError

复现:Safari → 设置 → 隐私 → 阻止所有 Cookie → 打开页面。第 583 行抛错,脚本从这里整体中止,于是 587 行往后全都不执行:

  • startBtn / sendBtn / endBtn / backBtn / settingsBtnaddEventListener 全没绑上 → 所有按钮点了没反应
  • 第 641 行首次进设置页的逻辑没跑 → 新用户停在主界面
  • 第 788 行 setControls() 没跑 → 输入框保持 HTML 里的 disabled

用户看到的是一个渲染完整、但完全点不动的页面,而且没有任何报错提示。(打赏弹窗反倒是正常的,因为它绑在 583 行之前。)

index.html:581-586 整段替换成:

const store = {
  get(k) { try { return localStorage.getItem(k); } catch { return null; } },
  set(k, v) { try { localStorage.setItem(k, v); return true; } catch { return false; } },
  remove(k) { try { localStorage.removeItem(k); } catch {} }
};

/* 旧版本(用户自带 Key 那版)残留的键,清掉 */
["orKey", "orModel", "geminiKey", "geminiModel", "interviewer"].forEach(k => store.remove("iprep_" + k));

FIELDS.forEach(f => {
  const el = $(f);
  const saved = store.get("iprep_" + f);
  if (saved !== null && saved !== "") el.value = saved;
  el.addEventListener("change", () => store.set("iprep_" + f, el.value.trim()));
});

顺带说明中间那段:改成 Worker 代理之前的版本是让用户自己填 Key 的,存在 iprep_orKey / iprep_geminiKey 里。现在的版本前端已经完全不碰 Key 了(这个改动是对的),但对用过旧版的人来说,他们的 Key 还原封不动躺在 ainancool.github.io 这个 origin 下面,新版本从来不清理。时间窗口很短影响面应该不大,但顺手的事。

同样的问题在 index.html:636-639:

$("backBtn").addEventListener("click", () => {
  FIELDS.forEach(f => localStorage.setItem("iprep_" + f, $(f).value.trim()));  // 抛出即中断
  showSettings(false);                                                          // 永远到不了
});

#jd 没有 maxlength,粘一段特别大的 JD 进去触发 QuotaExceededError,「保存并返回」按钮就彻底没反应了,用户被困在设置页。替换成:

$("backBtn").addEventListener("click", () => {
  let ok = true;
  FIELDS.forEach(f => { if (!store.set("iprep_" + f, $(f).value.trim())) ok = false; });
  if (!ok) alert("配置未能保存到本地(内容过大或浏览器禁用了存储),但不影响本次使用。");
  showSettings(false);
});

另外建议给 #jd 加个 maxlength="20000"


4. renderMD() 的有序列表,编号全部显示成 1

index.html:546-553listStack 只存标签名,tag 一变就 closeLists() 重开一个 <ol>

复现,把这段喂给它:

1. 沟通表达
   - 条理清晰
   - 语速偏快
2. 技术深度
   - 缺少细节
3. 总结

输出:

<ol><li>沟通表达</li></ol><ul><li>条理清晰</li><li>语速偏快</li></ul>
<ol><li>技术深度</li></ol><ul><li>缺少细节</li></ul><ol><li>总结</li></ol>

页面上就是 「1. 沟通表达 / 1. 技术深度 / 1. 总结」,子项也没有任何缩进层级。有序列表项之间隔个空行也一样会断开(空行走到 554 行 closeLists())。

AI 生成的面试评价基本必然是「1./2./3. + 子要点」这个结构,所以这条是必现的。

同一段代码还有个问题:第 547 行 /^\s*\d+[.)、]\s+(.*)/ 要求顿号后面有空格,但中文写法从不加(1、沟通能力),所以顿号这个分支实际上是死代码 —— 中文编号会全部退化成普通段落。

renderMD()(index.html:507-561)整个替换成:

function renderMD(src) {
  const lines = src.split("\n");
  let html = "", inCode = false, codeBuf = [];
  const stack = [];          // [{tag, indent}] 当前打开的列表
  let liOpen = false;        // 最内层列表是否有未闭合的 <li>

  const openList = (tag, indent) => { html += "<" + tag + ">"; stack.push({ tag, indent }); liOpen = false; };
  const closeInnermost = () => {
    if (liOpen) html += "</li>";
    html += "</" + stack.pop().tag + ">";
    liOpen = stack.length > 0;     // 回到父级,父级的 <li> 仍开着
  };
  const closeLists = () => { while (stack.length) closeInnermost(); };

  const inline = t => esc(t)
    .replace(/`([^`]+)`/g, "<code>$1</code>")
    .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
    .replace(/(^|[^*])\*([^\s*][^*\n]*?)\*(?!\*)/g, "$1<em>$2</em>");

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];

    if (/^```/.test(line)) {
      if (inCode) { html += "<pre><code>" + esc(codeBuf.join("\n")) + "</code></pre>"; codeBuf = []; }
      else closeLists();
      inCode = !inCode;
      continue;
    }
    if (inCode) { codeBuf.push(line); continue; }

    if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s\-:|]+\|\s*$/.test(lines[i+1])) {
      closeLists();
      const cells = r => r.trim().replace(/^\||\|$/g,"").split("|").map(c => inline(c.trim()));
      html += "<table><thead><tr>" + cells(line).map(c=>"<th>"+c+"</th>").join("") + "</tr></thead><tbody>";
      i += 2;
      while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
        html += "<tr>" + cells(lines[i]).map(c=>"<td>"+c+"</td>").join("") + "</tr>";
        i++;
      }
      i--;
      html += "</tbody></table>";
      continue;
    }

    const h = line.match(/^(#{1,6})\s+(.*)/);
    if (h) { closeLists(); html += `<h${h[1].length}>` + inline(h[2]) + `</h${h[1].length}>`; continue; }
    if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { closeLists(); html += "<hr>"; continue; }
    if (/^>\s?/.test(line)) { closeLists(); html += "<blockquote>" + inline(line.replace(/^>\s?/,"")) + "</blockquote>"; continue; }

    const ul = line.match(/^(\s*)[-*+]\s+(.*)/);
    const ol = line.match(/^(\s*)(?:\d+[.)]\s+|\d+\s*)(.*)/);
    const m = ul || ol;
    if (m) {
      const tag = ul ? "ul" : "ol";
      const indent = m[1].length;
      while (stack.length && indent < stack[stack.length-1].indent) closeInnermost();
      if (!stack.length || indent > stack[stack.length-1].indent) {
        openList(tag, indent);
      } else if (stack[stack.length-1].tag !== tag) {
        closeInnermost();
        openList(tag, indent);
      } else if (liOpen) { html += "</li>"; liOpen = false; }
      html += "<li>" + inline(m[2]);
      liOpen = true;
      continue;
    }

    if (line.trim() === "") continue;   // 空行不再打断列表
    closeLists();
    html += "<p>" + inline(line) + "</p>";
  }
  if (inCode && codeBuf.length) html += "<pre><code>" + esc(codeBuf.join("\n")) + "</code></pre>";
  closeLists();
  return html;
}

改动:列表按缩进宽度入栈出栈(真嵌套,<ul> 放在父级 <li> 里面);空行不再打断列表;顿号编号允许不带空格;标题放宽到 h6;行内 * 要求两侧紧贴非空白字符。

我把新旧两版都抠出来在 node 里跑了 15 个用例对比,结果:

用例
1. a + 缩进子项 + 2. b 编号全是 1,子项不缩进 <ol><li>a<ul>…</ul></li><li>b</li></ol>
1. a / 空行 / 2. b 三个独立 <ol> 一个 <ol> 三项 ✓
1、沟通能力 退化成 <p> <ol><li>
##### 五级标题 井号原样显示 <h5>
计算 2 * 3 * 4 2 <em> 3 </em> 4 原样 ✓
三级嵌套无序表 全拍平成同级 三层正确嵌套 ✓
表格 / 代码块 / 未闭合代码块 / 普通列表 / 加粗 / 正常斜体 / 列表后接段落 / 1.5 倍速 不被当列表 输出与旧版完全一致(9 个用例无回归)

另外对所有用例做了标签配对检查,<ul>/<ol>/<li>/<p>/<table>/… 全部正确闭合。

已知没修的:行内代码里的 markdown 会被二次解析(`a*b*c`<code>a<em>b</em>c</code>),因为 inline() 是先替换 code 再替换 em/strong。这个要修得调整替换顺序或者做占位符保护,改动比较大,而且实际影响很小,就没动。


5. 开场失败之后,会进入一个「假面试中」的状态

index.html:728-738,startInterview() 先置 interviewing = trueawait interviewerTurn("start");而回滚逻辑只处理 mode === "reply",start 失败什么都不回滚。

复现:断网 → 点「开始面试」→ 出现红色错误气泡。此时 interviewing=true, busy=false, ended=false,状态灯写着「面试进行中」,输入框和所有按钮都是可用的。接下来:

  • 打字发送 → history 里只有一条 user,以 mode:"reply" 发出去,历史里一条面试官发言都没有,AI 在没有开场问题的前提下「回复」
  • 或者直接点「结束并获取评价」→ history: []mode:"evaluate",AI 凭空编一份面试评价出来

startInterview() 整个替换成:

async function startInterview() {
  const c = cfg();
  if (!cfgReady(c)) { showSettings(true); return; }
  if (interviewing && history.length && !confirm("重新开始会清空当前对话记录,确定吗?")) return;
  history = [];
  interviewing = true;
  ended = false;
  $("personaStatus").textContent = "面试进行中";
  $("msgs").innerHTML = "";
  refreshTop();
  if (!await interviewerTurn("start")) {
    interviewing = false;
    $("personaStatus").textContent = "开始失败,请重试";
    setControls();
  }
}

顺带加了个二次确认。「重新开始」和「结束并获取评价」是紧挨着的,移动端还是等宽贴在一起,点下去立刻清空 history 和 DOM,没有 confirm 也没有 undo。


另外几个小的,没给代码,看你要不要处理

  • 错误气泡会永久留在对话流里(interviewerTurn 的 catch 分支):只 remove() 了用户气泡,面试官那条红色「出错了…」没人清理,也不在 history 里。重试成功之后,对话里就卡着一条孤零零的报错,而它上面对应的提问已经被删掉了。导出的 md 里也没有它,屏幕内容和导出内容对不上。
  • history 没有上限,每轮全量重发(index.html:658):连续问答二三十轮之后会顶到上游的上下文上限,然后就变成「发一次错一次」—— 回滚把消息塞回输入框,用户重试,history 一模一样,必然再次失败,除了「重新开始」没有出路。可以考虑超过阈值时提示「对话较长,建议结束并获取评价」。顺便,hidden 这个字段目前从来没被赋过值,正好可以用来标记被截断的消息。
  • 面试中途改配置会静默生效:settingsBtn 在面试中不 disable,改完人设之后 cfg() 现读 DOM,面试官性格会中途突变,而且 addBubble() 是渲染时才读 $("persona").value,所以同一段对话里前后是两个头像。另外 send() 只检查 busy/interviewing/ended,不检查 cfgReady —— 面试中把 JD 清空,顶栏会变成「未配置完整」,但还是能继续发送。
  • 移动端 100vh(index.html:316,#chatCard { min-height: calc(100vh - 130px) }):iOS Safari 的 100vh 取的是地址栏收起时的大视口,比实际可见高度多 60-90px,所以输入栏天生落在首屏之外,得先滚动文档才够得到;而新消息只滚动内层 #msgs,不会带动外层。改成 100dvh 能缓解。软键盘遮挡的具体表现我没有真机验证过,但这个嵌套滚动的结构问题从 CSS 上是能看出来的。

第 4 条的新旧对比是实际跑出来的。其余几条是读代码得出的,替换代码我没有在真实页面上跑过,可能有看漏上下文的地方。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions