又通读了一遍新版 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:646 的 fetch 没有 AbortController,index.html:721 的 busy = 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 / settingsBtn 的 addEventListener 全没绑上 → 所有按钮点了没反应
- 第 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-553。listStack 只存标签名,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 = true 再 await 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 条的新旧对比是实际跑出来的。其余几条是读代码得出的,替换代码我没有在真实页面上跑过,可能有看漏上下文的地方。
又通读了一遍新版
index.html,记了 5 个。第 1 条是中文用户几乎人人会踩的,第 2、3 条会让页面彻底卡住。每条都给了完整的替换代码,可以直接整段替换,不需要在原函数里挑着改。行号对应当前 main(
791b8d0),如果本地版本不一样,以引用的代码内容为准。涉及的位置:
index.html:763-765的 keydown 监听requestInterview()+interviewerTurn()index.html:581-586+index.html:636-639renderMD()整个函数startInterview()1. 中文输入法选词的那下回车会误发送
index.html:763-765:没有判断输入法组字状态。
复现:Chrome / Edge + 微软拼音或搜狗,在输入框打
wo juede zhege wenti,候选框弹出来,按 Enter 选词上屏。这一下浏览器会先派发一个isComposing: true的 keydown(Enter),代码直接preventDefault()+send()。input.value是空的,send()里if (!t)挡住了,表现是「选词失效,字没上屏」busy会锁住输入框,用户后面继续打的字全丢这是个纯中文界面的应用,拼音输入法是默认路径。
替换成:
keyCode === 229是给老 Safari 和部分安卓 WebView 兜底的,那些环境上isComposing不可靠。2.
fetch没有超时,请求挂住就是永久死锁index.html:646的fetch没有AbortController,index.html:721的busy = 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)整个替换成:再把
interviewerTurn()(index.html:698-726)整个替换成:改动有三处:超时、
finally里解锁(任何异常路径都不会再卡死)、以及回滚时补一个autosize()。最后这个是顺手修的另一个小问题 —— 现在写了 300 字的长回答发送失败,文本塞回输入框了,但输入框还是一行的高度,得在小框里滚动才能看全。3. 浏览器禁用存储时,整个页面会变成点不动的死页
index.html:581-586:这是顶层脚本执行流,不是事件回调。Safari 的「阻止所有 Cookie」、Firefox 关掉
dom.storage.enabled、某些企业策略下,访问localStorage会抛SecurityError。复现:Safari → 设置 → 隐私 → 阻止所有 Cookie → 打开页面。第 583 行抛错,脚本从这里整体中止,于是 587 行往后全都不执行:
startBtn/sendBtn/endBtn/backBtn/settingsBtn的addEventListener全没绑上 → 所有按钮点了没反应setControls()没跑 → 输入框保持 HTML 里的disabled用户看到的是一个渲染完整、但完全点不动的页面,而且没有任何报错提示。(打赏弹窗反倒是正常的,因为它绑在 583 行之前。)
把
index.html:581-586整段替换成:顺带说明中间那段:改成 Worker 代理之前的版本是让用户自己填 Key 的,存在
iprep_orKey/iprep_geminiKey里。现在的版本前端已经完全不碰 Key 了(这个改动是对的),但对用过旧版的人来说,他们的 Key 还原封不动躺在ainancool.github.io这个 origin 下面,新版本从来不清理。时间窗口很短影响面应该不大,但顺手的事。同样的问题在
index.html:636-639:#jd没有maxlength,粘一段特别大的 JD 进去触发QuotaExceededError,「保存并返回」按钮就彻底没反应了,用户被困在设置页。替换成:另外建议给
#jd加个maxlength="20000"。4.
renderMD()的有序列表,编号全部显示成 1index.html:546-553。listStack只存标签名,tag 一变就closeLists()重开一个<ol>。复现,把这段喂给它:
输出:
页面上就是 「1. 沟通表达 / 1. 技术深度 / 1. 总结」,子项也没有任何缩进层级。有序列表项之间隔个空行也一样会断开(空行走到 554 行
closeLists())。AI 生成的面试评价基本必然是「1./2./3. + 子要点」这个结构,所以这条是必现的。
同一段代码还有个问题:第 547 行
/^\s*\d+[.)、]\s+(.*)/要求顿号后面有空格,但中文写法从不加(1、沟通能力),所以顿号这个分支实际上是死代码 —— 中文编号会全部退化成普通段落。把
renderMD()(index.html:507-561)整个替换成:改动:列表按缩进宽度入栈出栈(真嵌套,
<ul>放在父级<li>里面);空行不再打断列表;顿号编号允许不带空格;标题放宽到 h6;行内*要求两侧紧贴非空白字符。我把新旧两版都抠出来在 node 里跑了 15 个用例对比,结果:
1. a+ 缩进子项 +2. b<ol><li>a<ul>…</ul></li><li>b</li></ol>✓1. a/ 空行 /2. b<ol><ol>三项 ✓1、沟通能力<p><ol><li>✓##### 五级标题<h5>✓计算 2 * 3 * 42 <em> 3 </em> 41.5 倍速不被当列表另外对所有用例做了标签配对检查,
<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 = true再await interviewerTurn("start");而回滚逻辑只处理mode === "reply",start 失败什么都不回滚。复现:断网 → 点「开始面试」→ 出现红色错误气泡。此时
interviewing=true, busy=false, ended=false,状态灯写着「面试进行中」,输入框和所有按钮都是可用的。接下来:history里只有一条user,以mode:"reply"发出去,历史里一条面试官发言都没有,AI 在没有开场问题的前提下「回复」history: []发mode:"evaluate",AI 凭空编一份面试评价出来把
startInterview()整个替换成:顺带加了个二次确认。「重新开始」和「结束并获取评价」是紧挨着的,移动端还是等宽贴在一起,点下去立刻清空
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 条的新旧对比是实际跑出来的。其余几条是读代码得出的,替换代码我没有在真实页面上跑过,可能有看漏上下文的地方。