fix(webui): 中文用户名登录后所有写操作静默失败

为什么:headers() 把用户名塞进 X-Farm-Id 请求头,而 HTTP 头只允许
Latin-1 字符——中文用户名(如"阳")会让 fetch 在发送前直接抛
TypeError(String contains non ISO-8859-1 code point)。登录时
me 还是 null(头为 ASCII 的 default)所以能登录成功,登录之后
me 变成中文,所有走 api() 的写操作(播种/浇水/收获/卖出)全部
静默失败;页面靠 SSE 推送照常渲染,看起来一切正常,用户表现为
"点播种没反应"。

- headers():非 ASCII 的牧场名/令牌不再塞头。登录态本来就由
  Cookie 鉴权,X-Farm-Id 只是匿名脚本模式的备用通道;ASCII 名
  (default、bot 等)行为不变。
- api():捕获 fetch 网络异常并 toast 提示。此前 Promise 拒绝
  无人接住,这类故障完全不可见,正是本 bug 难排查的原因。
- localStorage 读写加 try/catch 兜底:sandbox iframe 若不带
  allow-same-origin,裸访问即抛 SecurityError,会炸掉整个启动
  脚本(启动段就有三处裸调用)。

验证:headless Chrome 真机复现 → 修复 → UI 全流程回归(登录
"阳" → 点播种 → 选小麦 → toast 与地块状态正确更新);ruff /
pyright / 528 项测试全绿。
This commit is contained in:
snowy-yang committed 2026-09-23 22:28:40 +08:00
1 parent 698a5e497a
commit 2676927c1a
1 file changed
+33 -6
+33 -6
View File
@@ -212,6 +212,15 @@ function goodsName(key) {
return cropNames[key] || key;
}
/* sandbox iframe 可能禁用 localStorage(无 allow-same-origin 时访问即抛
SecurityError),任何一处裸调用都会炸掉整个启动脚本。 */
function lsGet(key) {
try { return localStorage.getItem(key); } catch (e) { return null; }
}
function lsSet(key, val) {
try { localStorage.setItem(key, val); } catch (e) {}
}
function toast(text) {
const el = $("toast");
el.textContent = text;
@@ -228,14 +237,32 @@ function setStatus(text, cls) {
function farmName() { return me || "default"; }
function token() { return ($("token").value || "").trim(); }
/* HTTP 头只允许 Latin-1 字符:中文用户名塞进 X-Farm-Id 会让 fetch 在发送前
就抛 TypeError,登录后所有写操作静默失败(表现为“点播种没反应”)。
登录态由 Cookie 鉴权,非 ASCII 的值一律不塞头。 */
function latin1(s) {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 32 || c > 126) return false;
}
return true;
}
function headers() {
const h = { "Content-Type": "application/json", "X-Farm-Id": farmName() };
if (token()) h["X-API-Token"] = token();
const h = { "Content-Type": "application/json" };
if (latin1(farmName())) h["X-Farm-Id"] = farmName();
if (token() && latin1(token())) h["X-API-Token"] = token();
return h;
}
async function api(path, options) {
const res = await fetch(path, options);
let res;
try {
res = await fetch(path, options);
} catch (e) {
toast("网络错误:" + e.message);
return null;
}
let data = null;
try { data = await res.json(); } catch (e) { data = { ok: false, message: "响应不是 JSON" }; }
if (!res.ok || data.ok === false) {
@@ -507,7 +534,7 @@ function applyIdentity() {
$("logout").classList.toggle("hidden", !me);
$("loginmask").classList.toggle("show", !me);
if (me) {
localStorage.setItem("sf.farm", me);
lsSet("sf.farm", me);
connect();
} else if (es) {
try { es.close(); } catch (e) {}
@@ -534,9 +561,9 @@ $("logout").addEventListener("click", async () => {
$("loginname").focus();
});
/* ---- 启动 ---- */
$("token").value = localStorage.getItem("sf.token") || "";
$("token").value = lsGet("sf.token") || "";
$("token").addEventListener("change", () => {
localStorage.setItem("sf.token", token());
lsSet("sf.token", token());
if (me) connect();
});
whoami();