diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index 82b5fe7..148dabe 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -99,12 +99,12 @@ const EN_END: &str = "\n };"; /// /// ⚠️ `T_LITERAL_COUNT` 是 `T("…")` **调用点**总数,不是键数,也不是去重后的键数 —— /// 三个集合各不相同(坑 99);说「这个数不该变」之前先确认它在数哪个集合。 -const ZH_KEY_COUNT: usize = 796; -const EN_KEY_COUNT: usize = 796; +const ZH_KEY_COUNT: usize = 799; +const EN_KEY_COUNT: usize = 799; const STATIC_ATTR_COUNT: usize = 335; const STATIC_ATTR_DISTINCT: usize = 309; -const T_LITERAL_COUNT: usize = 546; -const T_LITERAL_DISTINCT: usize = 435; +const T_LITERAL_COUNT: usize = 549; +const T_LITERAL_DISTINCT: usize = 437; /// 切出语言包区段(起点标记 → 终点标记,含起点)。 fn pack_region<'a>(src: &'a str, start_mark: &str, end_mark: &str) -> &'a str { @@ -1242,7 +1242,8 @@ const UNREACHABLE_PACK_KEYS: &[(&str, &str)] = &[ ("common.close", "old-design"), ("common.none", "old-design"), ("common.ok", "old-design"), - ("common.save", "old-design"), + // ⚠️ `common.save` 曾在这张清单上(old-design)。rant 2026-09-30T13:12:07 的编辑表单把它接了线 + // (编辑态的提交按钮改挂 `data-i18n="common.save"`)⇒ 按本清单的规矩**同时移出**(门禁会逐条点名)。 ("common.search", "old-design"), ("login.demo", "old-design"), ("login.subtitle", "old-design"), diff --git a/src/state_gate.rs b/src/state_gate.rs index 9a59579..3f5ae71 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -18814,3 +18814,574 @@ fn the_r117_scanners_have_teeth() { "摘监听有两处,却认出了唯一收尾" ); } + +// ═══════════════ R134:编辑表单绝不把服务端的掩码写回可提交字段 ═══════════════ +// +// 轴(rant 2026-09-30T13:12:07 第 5 条):服务端只把**掩码成品**(`sk-****xxxx`)交给前端, +// 编辑表单的语义是「留空 / 省略 = 保留原密文,填新值 = 加密替换」。掩码串一旦被当成新 key +// 提交,后端会把它加密写回 `encrypted_key` ⇒ 原 key 永久失效(之后每次调用 401),而界面 +// **没有任何异常提示**:列表照旧印掩码、行照旧在架、用户只会看到调用开始失败。 +// +// 本门禁证的是**形状**(射程=词法;浏览器事实归 jsdom 探针,仓内 CI 无 JS 运行器): +// R1 载荷里那个 key 成员只有一个入口 —— 可提交字段的 `value`,且**只在非空时**才带上 +// (载荷字面量里不得出现该成员,否则它就是无条件带上的)。 +// R2 可提交字段的**每一个**程序化写点都只写空串 ⇒ 没有任何代码路径能把掩码(或任何文本) +// 放进那个框里;框里的内容只能来自用户键入。 +// R3 反:掩码必须**真的**被显示 —— 该字段的 `placeholder` 写点唯一,且它的入参来自共享行 +// 的那个成员(掩码不许悄悄不再展示 —— 少了一句提示,用户就不知道框里为什么是空的)。 +// +// 三条规则的输入**全部派生**:字段选择器取自 R1 那条链(守卫绑定 → 元素读),成员名取自载荷里 +// 那个赋值本身 —— `#sf-key` / `key` 一个都没写死在门禁里。 + +/// 行语料:**纯注释行**置空(`//` 行与块注释形态都算),其余**原样保留缩进**。 +/// +/// 与 `code_text_by_line` 的区别:那个会 `trim`,而本门禁既要靠缩进认函数体收尾(恰为 ` }`), +/// 又要让行号与 `lines()` 对齐。行尾注释仍留在行里 —— 取值一律**截到第一个 `;`** 为止, +/// 所以 `… = ""; // 说明` 与 `… = "";` 读出同一个值(射程=词法,见上)。 +fn r134_code_lines(src: &str) -> Vec { + let mut out = Vec::new(); + let mut in_block = false; + for line in src.lines() { + let mut rest = line; + if in_block { + match rest.find("*/") { + Some(i) => { + rest = &rest[i + 2..]; + in_block = false; + } + None => { + out.push(String::new()); + continue; + } + } + } + if is_comment_line(rest) { + out.push(String::new()); + continue; + } + let (code, opened) = strip_inline_blocks(rest); + if opened { + in_block = true; + } + out.push(code); + } + out +} + +/// 标识符(本门禁只认 `[A-Za-z0-9_$]`,且不以数字开头)。 +fn r134_is_ident(s: &str) -> bool { + !s.is_empty() + && !s.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(true) + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +/// 一行里的 `if () . = ;` —— 四段全取出来。 +/// +/// `cond` 必须是**裸标识符**(守卫绑定),否则不是本轴认的「非空才带上」的形状: +/// `if (a && b)` / `if (!k)` 一律不算(那些条件的真值不直接等于「填了新 key」)。 +fn r134_guarded_assign(line: &str) -> Option<(String, String, String, String)> { + let t = line.trim(); + let rest = t.strip_prefix("if (")?; + let close = rest.find(')')?; + let cond = rest[..close].trim(); + let tail = rest[close + 1..].trim(); + let tail = tail.split(';').next()?.trim(); + let (lhs, rhs) = tail.split_once('=')?; + if rhs.starts_with('=') { + return None; // `==` + } + let lhs = lhs.trim(); + let (obj, member) = lhs.rsplit_once('.')?; + let (obj, member) = (obj.trim(), member.trim()); + if !r134_is_ident(obj) || !r134_is_ident(member) || !r134_is_ident(cond) { + return None; + } + Some(( + cond.to_string(), + obj.to_string(), + member.to_string(), + rhs.trim().to_string(), + )) +} + +/// 行语料里某一行的**直属具名函数**(往回找最近的 `function NAME(`)与它的体区间。 +/// +/// ⚠️ 不枚举全部函数:行语料上没有可靠的「函数体收尾」判据(嵌套函数与同缩进的外层体都收在 +/// ` }`),枚举会把外层函数的体一起圈进来(实测 15 个假命中)。往回找最近的那个头是稳的。 +fn r134_enclosing_span(lines: &[String], idx: usize) -> Option<(usize, usize, String)> { + let head = (0..idx) + .rev() + .find(|&i| function_name(&lines[i]).is_some())?; + let name = function_name(&lines[head])?.to_string(); + let end = (head..lines.len()).find(|&i| lines[i] == " }")?; + Some((head, end, name)) +} + +/// `const = () => { … }` 的头部 —— 只认**块体**箭头(表达式体没有体能装收尾动作)。 +fn r134_arrow_head(line: &str) -> Option<(String, Vec)> { + let t = line.trim(); + let rest = t.strip_prefix("const ")?; + let (name, tail) = rest.split_once(" = (")?; + let name = name.trim(); + if !r134_is_ident(name) { + return None; + } + let close = tail.find(") =>")?; + if !tail[close..] + .trim_start_matches(") =>") + .trim_start() + .starts_with('{') + { + return None; + } + let params = tail[..close] + .split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(); + Some((name.to_string(), params)) +} + +/// `$(""). = ;` 的全部写点:`(行号, rhs)`。rhs 截到第一个 `;`。 +fn r134_writes(lines: &[String], sel: &str, member: &str) -> Vec<(usize, String)> { + let needle = format!("$(\"{sel}\").{member}"); + let mut out = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let mut from = 0usize; + while let Some(rel) = line[from..].find(&needle) { + let at = from + rel + needle.len(); + let rest = line[at..].trim_start(); + if let Some(after) = rest.strip_prefix('=') { + if !after.starts_with('=') { + out.push(( + i + 1, + after.split(';').next().unwrap_or(after).trim().to_string(), + )); + } + } + from = at; + if from >= line.len() { + break; + } + } + } + out +} + +/// `const = ;` 的 rhs。 +fn r134_binding_rhs(lines: &[String], guard: &str) -> Option { + let head = format!("const {guard} = "); + for l in lines { + if let Some(rest) = l.trim_start().strip_prefix(&head) { + return Some(rest.split(';').next().unwrap_or(rest).trim().to_string()); + } + } + None +} + +/// 从表达式里取 `$("")` 的选择器(元素读的左侧)。 +fn r134_element_sel(expr: &str) -> Option { + let at = expr.find("$(\"")?; + let rest = &expr[at + 3..]; + let close = rest.find('"')?; + Some(rest[..close].to_string()) +} + +/// `const = { … };` 的**成员名**(逐行取 `:` 或 `,` 之前那一段,缩写在列)。 +/// +/// 守卫要的是「这个成员是不是**无条件**在载荷里」—— 所以 `key: v,` 与 `key,` 必须一视同仁。 +fn r134_object_members(lines: &[String], obj: &str) -> Vec { + let head = format!("const {obj} = {{"); + let start = match lines.iter().position(|l| l.trim_start().starts_with(&head)) { + Some(i) => i, + None => return Vec::new(), + }; + let mut out = Vec::new(); + for l in &lines[start + 1..] { + let t = l.trim(); + if t == "};" || t == "}" { + break; + } + if t.is_empty() { + continue; + } + let name = t.split([':', ',']).next().unwrap_or("").trim(); + if r134_is_ident(name) { + out.push(name.to_string()); + } + } + out +} + +/// `f(args)` 的实参(顶层逗号切分;只认标识符边界前的 `f`)。 +fn r134_call_args(line: &str, fname: &str) -> Vec { + let needle = format!("{fname}("); + let mut out = Vec::new(); + let mut from = 0usize; + while let Some(rel) = line[from..].find(&needle) { + let at = from + rel; + let before_ok = at == 0 + || !line.as_bytes()[at - 1].is_ascii_alphanumeric() + && line.as_bytes()[at - 1] != b'_' + && line.as_bytes()[at - 1] != b'$'; + let open = at + needle.len(); + let mut depth = 1usize; + let mut end = open; + for (k, c) in line[open..].char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = open + k; + break; + } + } + _ => {} + } + } + if before_ok && end > open { + out.push(line[open..end].trim().to_string()); + } + from = open; + if from >= line.len() { + break; + } + } + out +} + +/// 本轴的读数。 +struct R134Reading { + r1: bool, + r2: bool, + r3: bool, + sel: String, + member: String, + payload_fn: String, + detail: String, +} + +impl R134Reading { + fn verdicts(&self) -> (bool, bool, bool) { + (self.r1, self.r2, self.r3) + } + fn report(&self) -> String { + format!( + "r1={} r2={} r3={} | sel={:?} member={:?} payload_fn={:?} {}", + self.r1, self.r2, self.r3, self.sel, self.member, self.payload_fn, self.detail + ) + } +} + +/// 一条也读不出来时的读数(**不是**「全绿」—— 派生失败就是红,见 `r134_read` 的文档)。 +fn r134_blank(detail: String) -> R134Reading { + R134Reading { + r1: false, + r2: false, + r3: false, + sel: String::new(), + member: String::new(), + payload_fn: String::new(), + detail, + } +} + +/// 读 `ui/js/app.js` 的三条规则。 +/// +/// ⚠️ 派生链断了(找不到载荷构造器 / 守卫绑定 / 元素选择器)时返回**全红**,不是全绿: +/// 门禁的主张是「这些形状**在**」,读不出来就意味着主张没有被证明,而不是被满足。 +fn r134_read(app: &str) -> R134Reading { + let lines = r134_code_lines(app); + // ① 载荷里那个「守卫赋值」必须**唯一** —— 两处各造一个 key 字段=两个入口。 + // 候选 = 「守卫赋值」且那个对象是**本函数里声明出来的对象字面量**(=一份载荷)。 + // 只要求形状 `if (k) o.m = …;` 太宽:全仓 19 处(`if (prev) prev.disabled = …` 也算), + // 而载荷是**建出来的**那个对象 —— 这条把「顺手改个字段」的语句排掉。 + let mut sites: Vec<(usize, (String, String, String, String))> = Vec::new(); + for (i, l) in lines.iter().enumerate() { + let a = match r134_guarded_assign(l) { + Some(a) => a, + None => continue, + }; + let (_, obj, _, _) = a.clone(); + let decl = format!("const {obj} = {{"); + let in_fn = match r134_enclosing_span(&lines, i) { + Some((h, e, _)) => (h..=e).any(|k| lines[k].trim_start().starts_with(&decl)), + None => false, + }; + if in_fn { + sites.push((i, a)); + } + } + if sites.len() != 1 { + return r134_blank(format!( + "`if (…) . = …;` 形状的载荷赋值有 {} 处,须恰 1 处", + sites.len() + )); + } + let (site, assign) = sites[0].clone(); + // 只拆出前三段:`rhs` 由 `assign.3` 单独比对(R1 要求「带上的正好是那个守卫」)。 + let (guard, obj, member, _rhs) = assign.clone(); + // 载荷构造器 = 往回找**最近**的那个具名函数。 + // ⚠️ 不枚举全部函数:行语料上没有可靠的「函数体收尾」判据(嵌套函数与同缩进的外层 + // 体都收在 ` }`),枚举会把外层函数的体一起圈进来 —— 实测 15 个「命中」。 + let (head, end, fn_name) = match r134_enclosing_span(&lines, site) { + Some((h, e, n)) => (h, e, n), + None => return r134_blank("载荷赋值不在任何具名函数体里".to_string()), + }; + let body: Vec = lines[head..=end].to_vec(); + // ② 守卫绑定的初始化式 → 可提交字段的选择器。 + let init = match r134_binding_rhs(&body, &guard) { + Some(r) => r, + None => { + return r134_blank(format!( + "`{fn_name}` 里找不到守卫绑定 `const {guard} = …;` 的初始化式" + )) + } + }; + let sel = match r134_element_sel(&init) { + Some(s) => s, + None => return r134_blank(format!("守卫绑定 `{guard}` 的初始化式不是元素读:{init:?}")), + }; + if !init.contains(&format!("\"{sel}\").value")) { + return r134_blank(format!( + "守卫绑定 `{guard}` 的初始化式读的不是 `\"{sel}\").value`:{init:?}" + )); + } + + // R1:那个成员只有一个入口,且**只在非空时**才带上。 + let members = r134_object_members(&lines, &obj); + let unconditional = members.iter().any(|m| m == &member); + let all_assigns = lines + .iter() + .filter(|l| l.contains(&format!("{obj}.{member} ="))) + .count(); + let r1 = !unconditional + && all_assigns == 1 + && assign.0 == guard + && assign.1 == obj + && assign.2 == member + && assign.3 == guard; + + // R2:可提交字段的每一个程序化写点都只写空串。 + let writes = r134_writes(&lines, &sel, "value"); + let r2 = !writes.is_empty() && writes.iter().all(|(_, v)| v == "\"\"" || v == "''"); + + // R3(反):掩码必须真的被显示,且只经 `placeholder`。 + let ph = r134_writes(&lines, &sel, "placeholder"); + let mut r3 = false; + // 延后初始化(每个分支都赋值):先给个初值只会被下面那句覆盖,`clippy` 会按 + // `unused_assignments` 点名 —— 而这里的初值本来也不该存在(读不出来时的措辞由分支决定)。 + let ph_detail: String; + if ph.len() == 1 { + let (line_no, ph_rhs) = &ph[0]; + let idx = line_no - 1; + let arrow = (0..idx).rev().find_map(|i| { + let (n, p) = r134_arrow_head(&lines[i])?; + Some((i, n, p)) + }); + match arrow { + None => ph_detail = "占位符那个写点之前没有任何块体箭头声明".to_string(), + Some((_, arrow_name, params)) => { + let carrier = params + .iter() + .find(|p| mentions_identifier(ph_rhs, p)) + .cloned() + .unwrap_or_default(); + if carrier.is_empty() { + ph_detail = + format!("`{arrow_name}` 的形参 {params:?} 都没出现在 {ph_rhs:?} 里"); + } else { + let args: Vec = lines + .iter() + .flat_map(|l| r134_call_args(l, &arrow_name)) + .collect(); + let from_row = args.iter().find(|a| { + a.rsplit_once('.') + .map(|(lhs, f)| r134_is_ident(lhs) && f == member) + .unwrap_or(false) + }); + r3 = from_row.is_some(); + ph_detail = format!( + "placeholder={ph_rhs:?} carrier={carrier:?} arrow={arrow_name:?} \ + call_args={args:?} from_row={from_row:?}" + ); + } + } + } + } else { + ph_detail = format!("placeholder 写点 {} 个(须恰 1 个):{ph:?}", ph.len()); + } + + R134Reading { + r1, + r2, + r3, + sel, + member, + payload_fn: fn_name, + detail: format!( + "guard={guard:?} obj={obj:?} members={members:?} assigns={all_assigns} \ + writes={writes:?} {ph_detail}" + ), + } +} + +/// 共享编辑表单绝不把服务端的掩码串写回可提交字段 —— 一份事实一份入口。 +#[test] +fn the_share_edit_form_never_submits_the_stored_key_mask() { + let rd = r134_read(APP_JS); + assert_eq!( + rd.verdicts(), + (true, true, true), + "共享编辑表单不再保证「掩码只进 placeholder、绝不进可提交字段」:{}", + rd.report() + ); +} + +/// 阳性对照:抽取器真的落在活树的目标上(不是空串恒真),且派生出的字段真的被写 / 被显示。 +#[test] +fn the_r134_roster_is_derived() { + let rd = r134_read(APP_JS); + assert_eq!(rd.verdicts(), (true, true, true), "{}", rd.report()); + assert!( + rd.sel.starts_with('#') && rd.sel.len() > 1, + "派生出的不是 DOM 选择器:{:?}", + rd.sel + ); + assert!( + r134_is_ident(&rd.member), + "派生出的成员名不是标识符:{:?}", + rd.member + ); + assert!( + !rd.payload_fn.is_empty() && APP_JS.contains(&format!("function {}(", rd.payload_fn)), + "派生出的载荷构造器 {:?} 在源码里找不到", + rd.payload_fn + ); + // 两项提取都**真的**读到了东西:写点非空(否则 R2 会靠空集恒真)。 + let lines = r134_code_lines(APP_JS); + let writes = r134_writes(&lines, &rd.sel, "value"); + assert!( + !writes.is_empty(), + "可提交字段 {:?} 一个 `value` 写点都没扫到 —— 扫描器落在空处", + rd.sel + ); + assert!( + !r134_writes(&lines, &rd.sel, "placeholder").is_empty(), + "可提交字段 {:?} 的 placeholder 写点没扫到 —— 扫描器落在空处", + rd.sel + ); +} + +/// 每个变异体只打翻它针对的那一条(基线=已知为绿的活树)。 +#[test] +fn the_r134_rules_have_teeth() { + let live = r134_read(APP_JS); + assert_eq!( + live.verdicts(), + (true, true, true), + "基线不是绿的 —— 变异体的读数无从解释:{}", + live.report() + ); + + // ① 载荷的那个成员改从**显示出来的掩码**取(`payload.key = $("#sf-key").placeholder;`) + // ⇒ 只翻 R1:字段本身没被动过,显示也还在。 + let app = APP_JS.replacen( + "if (key) payload.key = key;", + "if (key) payload.key = $(\"#sf-key\").placeholder;", + 1, + ); + assert!(app != APP_JS, "变异体 ① 没改动任何字节 —— 锚点过期"); + let rd = r134_read(&app); + assert_eq!(rd.verdicts(), (false, true, true), "{}", rd.report()); + + // ①b 该成员被搬进载荷**字面量**(无条件带上)⇒ 也只翻 R1。 + let app = APP_JS.replacen( + " note,\n };\n if (key)", + " note,\n key,\n };\n if (key)", + 1, + ); + assert!(app != APP_JS, "变异体 ①b 没改动任何字节 —— 锚点过期"); + let rd = r134_read(&app); + assert_eq!(rd.verdicts(), (false, true, true), "{}", rd.report()); + + // ② 某个程序化写点不再只写空串(掩码进了那个框)⇒ 只翻 R2。 + let app = APP_JS.replacen( + "$(\"#sf-key\").value = \"\";", + "$(\"#sf-key\").value = mask;", + 1, + ); + assert!(app != APP_JS, "变异体 ② 没改动任何字节 —— 锚点过期"); + let rd = r134_read(&app); + assert_eq!(rd.verdicts(), (true, false, true), "{}", rd.report()); + + // ③ 掩码不再被交进表单(入参换成常量)⇒ 只翻 R3。 + let app = APP_JS.replacen("syncShareFormMode(s.key);", "syncShareFormMode(\"\");", 1); + assert!(app != APP_JS, "变异体 ③ 没改动任何字节 —— 锚点过期"); + let rd = r134_read(&app); + assert_eq!(rd.verdicts(), (true, true, false), "{}", rd.report()); +} + +/// 扫描器自证:判别式对**形状相近但语义相反**的输入必须给否,否则规则会因错误的原因变绿。 +#[test] +fn the_r134_scanners_have_teeth() { + // 守卫条件必须是**裸标识符**:`if (k && t)` / `if (!k)` 的真值不直接等于「填了新 key」。 + assert!(r134_guarded_assign("if (key) payload.key = key;").is_some()); + assert!(r134_guarded_assign("if (k && t) payload.key = k;").is_none()); + assert!(r134_guarded_assign("if (!key) payload.key = key;").is_none()); + // 比较不是赋值。 + assert!(r134_guarded_assign("if (key) payload.key == key;").is_none()); + // 不是语句的话不算(`while` / 裸表达式)。 + assert!(r134_guarded_assign("while (key) payload.key = key;").is_none()); + + // 元素选择器只从 `$("")` 取:别的东西读不出选择器。 + assert_eq!( + r134_element_sel("$(\"#sf-key\").value.trim()").as_deref(), + Some("#sf-key") + ); + assert!(r134_element_sel("document.querySelector(\"#sf-key\").value").is_none()); + + // 实参切分是**深度**感知的:内层逗号 / 括号不切。 + assert_eq!(r134_call_args("f(a.b);", "f"), vec!["a.b".to_string()]); + assert_eq!( + r134_call_args("f(g(x.y), 2);", "f"), + vec!["g(x.y), 2".to_string()] + ); + // 名字必须整词命中:`xf(` 不是 `f(`。 + assert!(r134_call_args("xf(a.b);", "f").is_empty()); + + // 块体箭头 vs 表达式体箭头:只有前者有体能装收尾动作。 + assert!(r134_arrow_head(" const a = (x) => {").is_some()); + assert!(r134_arrow_head(" const a = () => $(\"#x\");").is_none()); + assert_eq!( + r134_arrow_head(" const a = (x, y) => {").map(|(_, p)| p), + Some(vec!["x".to_string(), "y".to_string()]) + ); + + // 载荷字面量的成员名:`key: v,` 与 `key,` 一视同仁(缩写在列)。 + let lines: Vec = [ + " const p = {", + " a,", + " b: c,", + " key,", + " };", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!( + r134_object_members(&lines, "p"), + vec!["a".to_string(), "b".to_string(), "key".to_string()] + ); + + // 派生链断掉时是**全红**,不是全绿(把守卫赋值整段删掉)。 + let app = APP_JS.replacen("if (key) payload.key = key;", "// gone", 1); + assert!(app != APP_JS); + assert_eq!( + r134_read(&app).verdicts(), + (false, false, false), + "派生失败必须是红" + ); +} diff --git a/ui/README.md b/ui/README.md index 373ed33..8f20da7 100644 --- a/ui/README.md +++ b/ui/README.md @@ -50,7 +50,7 @@ python3 -m http.server 8000 --directory ui 1. 仪表盘 Dashboard — 点数余额、本月用量、共享收益、**本月点数变化(近 1 月按类型汇总收支:赠送/过期/收益/消费/充值/提现 + 净变化,取代静态"点数来源"分组)** 2. 模型市场 Marketplace — 模型浏览、**搜索(输入防抖 ~150ms + 关键词 `` 高亮 + 清空 × 按钮,v1.18 D)**、厂商筛选、排序(按价格/上下文);可用性标注「多 key · 自动故障转移」(该模型配置多个上游 key,见 `docs/architecture.md` §3 模块表 `router.rs` 行);每行「使用 / 消费」入口 → 聊天 Mock 模拟调用(按模型输出参考价扣小数点数,如 -0.38 点,产生消费交易;余额不足时阻断并提示) -3. 共享管理 Sharing — 默认只显示统计 + 我的共享列表(key 脱敏展示,可暂停/恢复/重新上架/彻底删除;列表展示「厂商 · Plan / 模型 · 可用时间段」,如「智谱 · GLM Coding Plan / glm-5.2 · 周一~周五 09:00-18:00」,未设置显示「全天」);点击"+ 添加 / 上架新 key"展开上架表单(**三级联动:厂商 → Plan → 模型**——内置国内已知 Plan 清单(阿里云百炼 / 智谱 / 火山方舟 / Kimi / MiniMax / DeepSeek,每家含「API(按量)」= 按量计价的 key),选 Plan 后显示 Plan 类型(按量 / 订阅);**可用时间段为结构化字段**:星期多选 chips + 起止时间(留空 = 全天不限),备注仅纯文本;须填 API Key,平台加密托管;分享者只填声明额度,单价由平台按模型定价自动计算并展示参考价),提交成功或取消后自动收起 +3. 共享管理 Sharing — 默认只显示统计 + 我的共享列表(key 脱敏展示,可**编辑**(复用同一张行内表单 `#share-form-card`,打开即预填该行当前值)/ 暂停 / 恢复 / 重新上架 / 彻底删除;列表展示「厂商 · Plan / 模型 · 可用时间段」,如「智谱 · GLM Coding Plan / glm-5.2 · 周一~周五 09:00-18:00」,未设置显示「全天」);点击"+ 添加 / 上架新 key"展开上架表单(**三级联动:厂商 → Plan → 模型**——内置国内已知 Plan 清单(阿里云百炼 / 智谱 / 火山方舟 / Kimi / MiniMax / DeepSeek,每家含「API(按量)」= 按量计价的 key),选 Plan 后显示 Plan 类型(按量 / 订阅);**可用时间段为结构化字段**:星期多选 chips + 起止时间(留空 = 全天不限),备注仅纯文本;须填 API Key,平台加密托管;分享者只填声明额度,单价由平台按模型定价自动计算并展示参考价),提交成功或取消后自动收起 4. 钱包 Wallet — 点数余额、**本月点数变化(近 1 月按类型汇总收支 + 净变化,与仪表盘一致)**、**充值入口(US-4:模拟流程——输入点数 → 余额增加 → topup 交易记录;文案注明"演示,真实支付后续接入";提现仍 disabled)**、**申请加额(US-20:企业成员余额低时申请更多点数 → 提交后等待管理员审批,默认需审批)**;收支明细已去重,统一到【交易记录】(页内提供跳转提示) 5. 交易记录 Transactions — 消费/收益/充值/提现/赠送/过期(gift / expiry)唯一明细入口,Tab 筛选 + MRT 风格表格(列排序/列筛选/分页,与 Tab 叠加生效) 6. 设置 Settings — 账户、**API Key 管理(独占整行全宽,表格字段不挤压;完整增删改查:生成带名字、改名、删除需确认「删除后该 key 立即失效」、按名字搜索(防抖+高亮+清空,v1.18 D)、一键复制完整 key——列表脱敏展示 atk_live_****xxxx,复制为完整值;file:// 下 clipboard API 受限时降级为选中+Ctrl/Cmd+C)**、通知、偏好 @@ -1275,3 +1275,14 @@ key**(正常操作)于是让运营者看到红色故障警报,而屏幕上 - **`.delta` 刻意保留**:原型 `statCard()` 会真的吐出 `
`,它是设计基线词汇、不是残留 —— 这也是把原型语料算进来的原因(实测全表只有它靠这条腿过)。 - **门禁** `src/layout_gate.rs::every_selector_lands_on_an_element`(R4;token 名册从选择器文本派生,脚本名册借用 `js_gate` 那份 ⇒ 新增 `ui/js/*.js` 不会被静默漏掉,⛔ 零手写类名清单)。 - ⚠️ **射程**:**词法**且刻意**宽口径** —— 它证「这个名字有落点」,**不证**落点就是它要服务的东西(注释里、字符串里、别处的死代码里的一次同名出现都算数;提到名字 ≠ 穿在身上,与 `js_gate` 同源),也**不**证屏幕上的像素。原型语料是一个**刻意的逃生口**(原型里出现过的任何词都放行)。 + +## 共享上架 / 编辑表单的 `key` 三态(R134,2026-09-30) + +- **不变量**:服务端只把**掩码成品**(`sharing_row` 的 `mask_upstream_key`:前 2 位 + `-` + 后 4 位)交给前端,所以编辑表单里那个 key 输入框的语义是「**留空 / 省略 = 保留原密文**,填新值 = 加密替换」。掩码串**只进 `placeholder`**(用户看得见它是什么),**绝不进 `value`** —— 进了 `value` 就会被当成新 key 提交,后端把掩码加密写回 `encrypted_key`,原 key 永久失效(之后每次调用 401),而界面**没有任何异常提示**:列表照旧印掩码、行照旧在架。 +- **一份入口**:上架与编辑**共用** `#share-form-card` 这一张行内表单(模式只由模块级的 `editingShareId` 决定),也**共用**一份请求载荷(`sharePayload`)、一份校验、一份时段编码 —— 镜像后端 `create` / `patch` 共用 `validate_listing` 的形状。⛔ 不新建第二张表单,也不做「行内就地编辑整行」。 +- **门禁** `src/state_gate.rs::the_share_edit_form_never_submits_the_stored_key_mask`,三条规则的输入**全部派生**(字段选择器取自「守卫绑定 → 元素读」那条链,成员名取自载荷里那个赋值本身,⛔ `#sf-key` / `key` 一个都不写死在门禁里): + - **R1** 载荷里那个成员只有一个入口 —— 可提交字段的 `value`,且**只在非空时**才带上(载荷字面量里不得出现该成员,否则它就是无条件带上的); + - **R2** 可提交字段的**每一个**程序化写点都只写空串 ⇒ 框里的内容只能来自用户键入; + - **R3** 反:掩码必须**真的**被显示 —— 该字段的 `placeholder` 写点唯一,且它的入参来自共享行的那个成员(少一句提示,用户就不知道框里为什么是空的)。 + - 配 `the_r134_roster_is_derived`(派生出的选择器 / 成员名 / 载荷构造器都真的落在活树上)、`the_r134_rules_have_teeth`(四个变异体各只打翻它针对的那一条)、`the_r134_scanners_have_teeth`(判别式对形状相近的输入给否;派生链断掉时是**全红**、不是全绿)。 +- ⚠️ **射程**:**词法** —— 证「掩码的去处只有 `placeholder`、载荷里那份只能来自用户键入」,**不证**浏览器里那一刻的行为(那半归 jsdom 探针,仓内 CI 无 JS 运行器,与本文档其余形状门禁同款)。 diff --git a/ui/index.html b/ui/index.html index 7ad0361..271246b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -325,7 +325,7 @@

上架新 key

- +
@@ -861,7 +861,7 @@

使用模型

- - + + diff --git a/ui/js/app.js b/ui/js/app.js index a13c5d0..b2ffe51 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1033,6 +1033,33 @@ }; const shareToggle = (status) => SHARE_TOGGLE[status] || SHARE_TOGGLE.off; + // 可用时段的星期列存的是 JSON 串(后端 `available_days`)⇒ 全仓**唯一**的解析点 + //(列表渲染 `sharingsToView` 与编辑预填都读它)。两处各写一个 `JSON.parse` 就会重演 + // 「同一份数据两种读法」——坏串在两处各自回落,界面与表单随后各说各话。 + function parseShareDays(raw) { + try { return JSON.parse(raw || "[]"); } catch (e) { return []; } + } + + // 上架与编辑**共用**的请求载荷(后端 `create`/`patch` 也是共用一份 `validate_listing`)。 + // + // `key` 刻意**不随大流**:只有真的填了新明文才把字段带上 ⇒ 省略 = 后端保留原密文 + //(PATCH 的三态:省略 / 空白都不动 `encrypted_key`)。服务端只回传掩码串,而掩码串 + // 进过 `value` 就会被当成新 key 提交、原 key 永久失效 —— 因此本函数里 `key` 的**唯一** + // 入口是那个输入框,且必须走 `if (key)` 这道守卫。 + function sharePayload(plan, model, quota, available, note) { + const key = $("#sf-key").value.trim(); + const payload = { + provider: plan.provider, + plan: plan.id, + model, + quota, + available, + note, + }; + if (key) payload.key = key; + return payload; + } + function renderSharing() { // 零 mock(rant 2026-08-19T15:54:06):登录态绝不 fallback D.SHARINGS; // 加载失败 → 空态 + 重试(loadErrorRow,tbody 内合法) @@ -1119,7 +1146,11 @@ '+' + D.fmt(s.earned) + " " + T("common.points") + "" + "" + esc(fmtAvailable(s)) + "" + "" + badge(s.status, SHARE_STATUS) + "" + - " " + + " " + ""; }).join("") : emptyRow(8, T("share.empty"), T("share.empty.sub"), @@ -3607,8 +3638,7 @@ function sharingsToView(list) { return list.map((s) => { - let days = []; - try { days = JSON.parse(s.available_days || "[]"); } catch (e) { days = []; } + const days = parseShareDays(s.available_days); return { id: s.id, provider: s.provider, @@ -4073,20 +4103,50 @@ // 共享上架表单(默认收起;点添加展开,提交成功或取消后收起) const shareFormCard = () => $("#share-form-card"); + // 编辑中的行 id(null = 上架新 key)。上架与编辑**共用同一张行内表单**,模式只由这一个变量决定 + //(rant 2026-09-30T13:12:07 第 1 条:不新建第二张表单、不做「就地编辑整行」)。 + let editingShareId = null; // 「每天」快捷勾选是用 property 式给单日 chip 打 disabled 的(`cb.disabled = allCb.checked`), // 而 `form.reset()` 只还原「值 / 勾选态」、**不清 property** ⇒ 成功上架后表单被回收, // 七个星期 chip 会保持「未勾选 + 禁用」直到重开 —— 必须在回收 / 重开路径上显式清一次。 const resetShareAvail = () => { $$("#sf-days .chip input").forEach((cb) => { cb.disabled = false; }); }; + // 表单标题 / 提交按钮 / key 输入框的**钩子**随模式切换,然后走一次 `applyStatic()` 重渲染。 + // + // 写的是 `data-i18n*` **属性**,不是 textContent / placeholder 的**成品**:切语言时 + // `applyStatic()` 会按属性重渲染这一块,写死的文本会被它覆盖回「上架」(R94/R95 的同形问题)。 + // 编辑态下 `#sf-key` **摘掉** `data-i18n-ph`,placeholder 换成服务端的掩码串 —— + // 掩码串**只进 placeholder、绝不进 `value`**:进了 `value` 就会被当成新 key 提交, + // 后端把掩码加密写回 `encrypted_key`,原 key 永久失效且界面无任何异常提示。 + const syncShareFormMode = (mask) => { + const edit = editingShareId !== null; + // 卡片标题按**结构**取(卡片里只有这一个 `h3`):`id` 一旦插在卡片与 `
` 之间, + // R139 门禁那条「`` 之前那个 id 就是卡片」的派生就会静默指向别处(实测会红)。 + $("#share-form-card h3").dataset.i18n = edit ? "share.form.editTitle" : "share.form.title"; + $("#sf-submit").dataset.i18n = edit ? "common.save" : "share.form.list"; + // `#sf-key` 的每个写点都按**字面选择器**写(不借本地别名):`state_gate` 按行读 + // 「谁在给这个可提交字段写 `value`」—— 别名会把写点藏起来,那条规则就会静默变空。 + $("#sf-key").value = ""; // 明文一律清空(上一次输入 / 上一次编辑的残留都不许跟着提交) + if (edit) { delete $("#sf-key").dataset.i18nPh; $("#sf-key").placeholder = mask || ""; } + else { $("#sf-key").dataset.i18nPh = "share.form.key.ph"; } + I18n.applyStatic(); + }; + // 上架入口:把表单交回 HTML 的默认值(quota 5000 / note 空 / key 空 / 星期全不选), + // 否则上一次编辑留下的值会被当成新上架的设置提交。 const showShareForm = () => { + editingShareId = null; + $("#share-form").reset(); + resetShareAvail(); // property 式 `disabled` 不在 `reset()` 的还原范围内(见上) + $("#sf-quota").value = 5000; + const p = $("#sf-provider"); p.value = ""; p.dispatchEvent(new Event("change")); clearFieldError($("#sf-key")); clearFieldError($("#sf-quota")); - resetShareAvail(); + syncShareFormMode(); shareFormCard().hidden = false; $("#sf-key").focus(); }; - const hideShareForm = () => { shareFormCard().hidden = true; }; + const hideShareForm = () => { editingShareId = null; shareFormCard().hidden = true; }; $("#share-add-btn").addEventListener("click", showShareForm); $("#sf-cancel").addEventListener("click", hideShareForm); @@ -4099,11 +4159,53 @@ }); }); - // 共享上架表单(选 厂商 → Plan → 模型;单价由平台按模型定价自动计算) + // 编辑入口:把该行**当前值**回填进同一张表单。取的是**原始载荷**(`Live.sharings[i]`), + // 不是视图行 —— `sharingsToView` 已经把 plan 换成了显示名、key 换成了掩码成品。 + const openShareEdit = (i) => { + const s = Live.sharings && Live.sharings[i]; + if (!s || !s.id) return; + editingShareId = s.id; + clearFieldError($("#sf-key")); + clearFieldError($("#sf-quota")); + resetShareAvail(); + // 厂商 → Plan → 模型 是**三级联动**:每一级都要 `change` 一次,下一级的选项才会被重建 + // (`fillPlans` / `fillModels` 只挂在下拉自身的 change 上,直接赋 value 会留下一个空下拉)。 + const p = $("#sf-provider"); + const pl = $("#sf-plan"); + const mo = $("#sf-model"); + p.value = s.provider || ""; + p.dispatchEvent(new Event("change")); + pl.value = s.plan || ""; + pl.dispatchEvent(new Event("change")); + mo.value = s.model || ""; + showPriceHint(mo.value); + // 可用时段:「每天」快捷勾选的互斥 property 与七个 chip 的勾选态一起还原 + //(`resetShareAvail` 只清 disabled,勾选态由这里按行里的星期集合回填)。 + const days = parseShareDays(s.available_days); + const chips = $$("#sf-days .chip input").filter((cb) => cb !== allCb); + chips.forEach((cb) => { cb.checked = days.indexOf(Number(cb.value)) >= 0; }); + const wholeWeek = chips.length > 0 && chips.every((cb) => cb.checked); + if (allCb) { + allCb.checked = wholeWeek; + chips.forEach((cb) => { cb.disabled = wholeWeek; }); + } + $("#sf-start").value = s.available_start || ""; + $("#sf-end").value = s.available_end || ""; + $("#sf-quota").value = s.quota; + $("#sf-note").value = s.note || ""; + syncShareFormMode(s.key); + shareFormCard().hidden = false; + $("#sf-key").focus(); + }; + + // 共享上架 / 编辑表单(选 厂商 → Plan → 模型;单价由平台按模型定价自动计算) + // 上架与编辑走**同一个**提交处理器,差别只有两处:目标端点/方法与 key 字段的可省略性 —— + // 校验、时段编码、载荷字段一律共用(后端 `create`/`patch` 也是共用一份 `validate_listing`)。 $("#share-form").addEventListener("submit", (e) => { e.preventDefault(); const submitBtn = e.target.querySelector('button[type="submit"]'); const done = () => { + const editId = editingShareId; // 先取一份:`hideShareForm` 会把它清掉 const model = $("#sf-model").value; const planId = $("#sf-plan").value; const plan = planById(planId); @@ -4111,7 +4213,8 @@ const key = $("#sf-key").value.trim(); const note = $("#sf-note").value.trim(); let firstErr = null; - if (!key) { setFieldError($("#sf-key"), T("share.err.key")); firstErr = firstErr || $("#sf-key"); } + // 上架必须给明文 key;编辑留空 = 保留原密文(后端三态:省略 / 空白都不动 `encrypted_key`) + if (!editId && !key) { setFieldError($("#sf-key"), T("share.err.key")); firstErr = firstErr || $("#sf-key"); } else clearFieldError($("#sf-key")); if (!plan || !model || quota <= 0) { setFieldError($("#sf-quota"), T("share.err.plan")); @@ -4125,15 +4228,7 @@ const end = $("#sf-end").value; const available = days.length ? { days, start: start || "", end: end || "" } : null; const price = autoPrice(model); - const payload = { - provider: plan.provider, - plan: plan.id, - model, - key, - quota, - available, - note, - }; + const payload = sharePayload(plan, model, quota, available, note); const afterOk = () => { e.target.reset(); resetShareAvail(); @@ -4141,12 +4236,24 @@ $("#sf-quota").value = 5000; hideShareForm(); const label = provLabel(plan.provider) + " · " + planLabelById(planId); - toast(T("share.list.ok", { label: label, model: model, price: D.fmt(price) }), "success"); + if (editId) toast(T("share.edit.ok", { label: label, model: model }), "success"); + else toast(T("share.list.ok", { label: label, model: model, price: D.fmt(price) }), "success"); }; if (!loggedIn()) { toast(T("chat.login.need"), "error"); return; } + if (editId) { + // 编辑**原行**:同一条 `id`,上架时间与收益归属不动(后端 `UPDATE … WHERE id=? AND owner_id=?`) + api.patch("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/api/sharings/" + editId, payload).then(async () => { + await loadSharing(); + if (activeView === "dashboard") renderDashboard(); + afterOk(); + }).catch((err) => { + toast((err && err.message) ? I18n.mapErr(err.message) : T("share.edit.fail"), "error"); + }); + return; + } // P2-B:真实上架(共享管理仅登录可达,零 mock rant 15:54:06) api.post("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/api/sharings", payload).then(async () => { await loadSharing(); @@ -4159,8 +4266,10 @@ withLoading(submitBtn, done); }); - // 共享列表操作(事件委托:暂停/恢复/重新上架 + 删除[行内二次确认] + 空状态上架) + // 共享列表操作(事件委托:编辑 + 暂停/恢复/重新上架 + 删除[行内二次确认] + 空状态上架) $("#share-body").addEventListener("click", (e) => { + const ed = e.target.closest("[data-share-edit]"); + if (ed) { openShareEdit(Number(ed.dataset.shareEdit)); return; } const b = e.target.closest("[data-share-toggle]"); if (b) { toggleSharing(Number(b.dataset.shareToggle)); return; } const d = e.target.closest("[data-share-delete]"); diff --git a/ui/js/i18n.js b/ui/js/i18n.js index bf8788c..8f5b086 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -238,6 +238,7 @@ "share.crumb": "/ 共享管理", "share.col.avail": "可用时段", "share.form.title": "上架新 key", + "share.form.editTitle": "编辑共享", "share.form.provider": "厂商", "share.form.plan": "Plan", "share.form.model": "模型", @@ -294,6 +295,8 @@ "share.err.plan": "请选择厂商 / Plan / 模型并填写有效额度", "share.list.ok": "已上架「{label} · {model}」(key 已加密托管,单价 {price} 点/1M(自动))", "share.list.fail": "上架失败", + "share.edit.ok": "已保存「{label} · {model}」(未填新 key,原密钥保持不变)", + "share.edit.fail": "保存失败", "share.select.provider": "选择厂商", "share.select.plan": "选择 Plan", "share.select.model": "选择模型", @@ -1068,6 +1071,7 @@ "share.crumb": "/ Sharing", "share.col.avail": "Time window", "share.form.title": "List a key", + "share.form.editTitle": "Edit listing", "share.form.provider": "Provider", "share.form.plan": "Plan", "share.form.model": "Model", @@ -1124,6 +1128,8 @@ "share.err.plan": "Please select provider / Plan / model and enter a valid quota", "share.list.ok": "Listed “{label} · {model}” (key encrypted, price {price} pts/1M (auto))", "share.list.fail": "List failed", + "share.edit.ok": "Saved “{label} · {model}” (no new key entered, stored key unchanged)", + "share.edit.fail": "Save failed", "share.select.provider": "Select provider", "share.select.plan": "Select Plan", "share.select.model": "Select model",