From 8dcb768e5a014e9d1d933b672574794b7ed951f0 Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 17 Aug 2026 17:25:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20inline=20form=20validation=20errors?= =?UTF-8?q?=20=E2=80=94=20red=20border=20+=20inline=20text=20(v1.17=20E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/user-stories.md | 2 +- ui/README.md | 5 +++ ui/css/style.css | 12 +++++++ ui/js/app.js | 74 ++++++++++++++++++++++++++++++++++++++------ 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index d8260ad..a5cc295 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -9,7 +9,7 @@ > **v1.15(2026-08-17)· A 视觉美化(rant `2026-08-17T15:50:05`).** 侧边栏导航 emoji → **统一内联 SVG 线性图标**(同尺寸、currentColor、hover/active 态);卡片 hover 微交互(阴影/边框轻提升);表格斑马纹 + 行 hover 高亮;**空状态组件**(图标 + 文案 + 行动按钮:市场清除筛选 / 共享上架新 key / 设置生成 key / 部门清除搜索);状态徽章加**语义色点**(ok/warn/danger/dim 一眼可辨);响应式加固(窄屏侧边栏折叠配合 SVG 图标、表格横向滚动)。 > > **v1.16(2026-08-17)· B 交互(UE)优化(rant `2026-08-17T15:50:05`).** **toast 分级**(成功 success / 错误 error / 信息 info,39 个调用点全部分级着色);**按钮 loading 态**(充值 / 加额 / 部门确认 / 上架提交 → 转圈 + 禁用,模拟反馈后恢复);**API Key 复制反馈**(复制后按钮短暂变「已复制 ✓」,1.2s 恢复,降级路径「请 Ctrl+C」);**键盘可达**(充值金额 / 加额金额·原因 / 部门名称·配额 Enter 提交;topup/raise/dept 行内卡片 Esc 关闭;打开时自动聚焦)。**rant 15:50:05 全部验收项满足,UI 美化与 UE 优化波收官(PR #38/#39/#40,docs v1.14→v1.16)。** -> **v1.17(2026-08-17)· 多角度 UI/UE 深化(rant `2026-08-17T16:57:17`,分批 PR).** **A. 清除原生弹窗 ✓(PR #41)**:约 5 处原生确认/输入弹窗(key 删除、共享下架、部门删除、API Key 新建/改名、运营者/成员充值)全部改为**行内二次确认**(按钮变「确认删除?」红色态,3 秒无操作或 Esc 还原,再次点击执行)与**行内编辑**(行内展开输入框,Enter 确认 / Esc 取消);抽象可复用 `confirmInline` / `inlineForm` 组件全站统一;`grep ui/` 无原生弹窗残留。**B. 相对时间显示 ✓(PR #42)**:新增 `timeAgo` 格式化(`刚刚 / N 分钟前 / N 小时前 / 昨天 / MM-DD`,hover `title` 显示完整绝对时间),交易列表、加额申请列表、共享列表(新增「上架时间」列)与 API Key 最近使用时间统一使用。**C. 数字列对齐 ✓(PR #43)**:价格 / 点数 / 已用 / 额度 / 余额等**数字列统一右对齐 + 等宽字体**(`--mono` + `tabular-nums`,新增 `.table .num` 类,覆盖市场输入/输出价与上下文、交易 Token 用量/点数、共享已用/额度·单价·收益、成员配额/剩余、部门成员数·月分配·已用·剩余、运营者余额、加额申请点数),时间列 / 状态列保持左对齐;金额统一 `D.fmt`(整数千分位、小数 2 位)全覆盖检查无遗漏。其余 D–G(`:focus-visible` 与快捷键、行内校验错误、视图过渡、细节一致性)分批落地。**D. 键盘可达性 / 无障碍 ✓(PR #44)**:全局 `:focus-visible` 焦点环(accent 色 outline,Tab 导航清晰可见;输入类控件沿用边框高亮不叠加);**全局快捷键**:`/` 聚焦市场搜索框(输入框内不触发)、数字 **1-7** 切换侧边栏视图(仪表盘/市场/共享/钱包/交易/管理/设置,带 title 与右侧键位角标提示,Cmd/Ctrl+数字 不劫持)、Esc 关闭行内新建 Key;侧边栏导航按钮补 `title`(快捷键提示)。 +> **v1.17(2026-08-17)· 多角度 UI/UE 深化(rant `2026-08-17T16:57:17`,分批 PR).** **A. 清除原生弹窗 ✓(PR #41)**:约 5 处原生确认/输入弹窗(key 删除、共享下架、部门删除、API Key 新建/改名、运营者/成员充值)全部改为**行内二次确认**(按钮变「确认删除?」红色态,3 秒无操作或 Esc 还原,再次点击执行)与**行内编辑**(行内展开输入框,Enter 确认 / Esc 取消);抽象可复用 `confirmInline` / `inlineForm` 组件全站统一;`grep ui/` 无原生弹窗残留。**B. 相对时间显示 ✓(PR #42)**:新增 `timeAgo` 格式化(`刚刚 / N 分钟前 / N 小时前 / 昨天 / MM-DD`,hover `title` 显示完整绝对时间),交易列表、加额申请列表、共享列表(新增「上架时间」列)与 API Key 最近使用时间统一使用。**C. 数字列对齐 ✓(PR #43)**:价格 / 点数 / 已用 / 额度 / 余额等**数字列统一右对齐 + 等宽字体**(`--mono` + `tabular-nums`,新增 `.table .num` 类,覆盖市场输入/输出价与上下文、交易 Token 用量/点数、共享已用/额度·单价·收益、成员配额/剩余、部门成员数·月分配·已用·剩余、运营者余额、加额申请点数),时间列 / 状态列保持左对齐;金额统一 `D.fmt`(整数千分位、小数 2 位)全覆盖检查无遗漏。其余 D–G(`:focus-visible` 与快捷键、行内校验错误、视图过渡、细节一致性)分批落地。**D. 键盘可达性 / 无障碍 ✓(PR #44)**:全局 `:focus-visible` 焦点环(accent 色 outline,Tab 导航清晰可见;输入类控件沿用边框高亮不叠加);**全局快捷键**:`/` 聚焦市场搜索框(输入框内不触发)、数字 **1-7** 切换侧边栏视图(仪表盘/市场/共享/钱包/交易/管理/设置,带 title 与右侧键位角标提示,Cmd/Ctrl+数字 不劫持)、Esc 关闭行内新建 Key;侧边栏导航按钮补 `title`(快捷键提示)。**E. 表单行内校验错误 ✓(PR #45)**:新增 `setFieldError(input, msg)` / `clearFieldError(input)`(输入框**红边框** + 字段下方**红字小号行内文案**,输入修正自动清除,打开表单时重置);覆盖**充值金额**(自定义金额非法)、**申请加额**(点数/原因)、**部门表单**(名称/配额/重名)、**共享上架表单**(API Key/厂商·Plan·模型·额度),提交失败不再仅依赖 toast,聚焦首个错误字段。 > **v1.12(2026-08-17)· 移除「组织设置 Organization settings」表单.** 组织管理页只保留部门列表(部门 CRUD + 每月点数分配),删除底部「组织设置」表单(组织名称 / 默认成员配额 / 开关)。「关闭外部注册」属**部署配置项,不在 UI 中设置**(US-16 标注);「成员自助申请加额需管理员审批」开关移除后,申请加额流程**默认需审批**(US-20 说明)。 > **v1.11(2026-08-17)· 管理视图移除独立「Key 池」管理,统一走「上架 key」.** 管理员与普通用户一样通过**共享管理页「上架新 key」**(选厂商 → Plan → 模型、填 key、声明额度、可用时间段)配置上游 key——企业场景下管理员上架、员工消费,本质上就是 key 池;不再有独立的管理员 Key 池界面。移除 Admin「Key 池」tab(成员管理 / 用量报表 / 组织管理 / 平台运营保留)。「Key 池」保留为**数据层概念**(平台持有的上游 key 集合,见 docs/architecture.md 4.2)。更新 US-12 / 企业流程 / 对齐表。 > **v1.10(2026-08-17)· 上架表单"可用时间段"结构化.** 上架 key 的可用时间段改为**正式字段**(星期多选 + 起止时间,可多选;留空 = 全天不限),后端可解析生效;**备注只作纯文本**,不承载任何逻辑。共享列表展示可用时间段(如「周一~周五 09:00-18:00」),未设置显示「全天」。更新 US-8 / US-9 AC。 diff --git a/ui/README.md b/ui/README.md index 16bed89..353ec8d 100644 --- a/ui/README.md +++ b/ui/README.md @@ -106,6 +106,11 @@ ui/ - Esc → 关闭行内新建 Key(`#ak-new-inline`); - **导航提示**:nav-item 补 `title`("快捷键 N · 名称")+ 右侧 `.nav-key` 键位角标(管理视图带「管理员」tag 时省略角标)。 +## 行内校验错误约定(v1.17,rant 2026-08-17T16:57:17 E 表单校验) + +- **组件**:`setFieldError(input, msg)` 给输入框加 `.input-error`(红边框)并在其后插入 `.field-error`(红字小号行内文案),输入事件自动清除;`clearFieldError(input)` 手动清除;打开表单时重置; +- **覆盖**:充值自定义金额、申请加额(点数/原因)、部门表单(名称/配额/重名)、共享上架表单(API Key/厂商·Plan·模型·额度);提交校验失败聚焦首个错误字段,不依赖 toast。 + ## 数据说明 - 点数规则与机制细节见 `docs/user-stories.md`(v1.8:机制说明不再进入面向用户的界面文案);UI 只呈现结果(余额数字、模型价格点数、交易金额/类型/状态、可用/繁忙) diff --git a/ui/css/style.css b/ui/css/style.css index 0d72277..52061dd 100644 --- a/ui/css/style.css +++ b/ui/css/style.css @@ -129,6 +129,18 @@ select.input:focus-visible { outline: none; } font-size: 12px; line-height: 1.5; } +/* 行内校验错误(rant 16:57:17 E:红边框 + 字段下方红字小号文案,输入修正后自动清除) */ +.input.input-error { border-color: var(--danger); } +.input.input-error:focus { border-color: var(--danger); } +.field-error { + display: block; + color: var(--danger); + font-size: 12px; + line-height: 1.4; + margin-top: 2px; +} +select.input.input-error { border-color: var(--danger); } +select.input.input-error:focus { border-color: var(--danger); } select.input option { background: var(--bg-card); } .card { diff --git a/ui/js/app.js b/ui/js/app.js index 930c4fd..3987a76 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -47,6 +47,32 @@ }, ms || 320); } + /* --- 行内校验错误(rant 16:57:17 E:红边框 + 字段下方行内错误文案,修正后自动清除) --- */ + + // 在输入框下方显示行内错误文案,并给输入框加红边框;输入修正时自动清除 + function setFieldError(input, msg) { + if (!input) return; + input.classList.add("input-error"); + let err = input.parentNode.querySelector(".field-error"); + if (!err) { + err = document.createElement("span"); + err.className = "field-error"; + input.insertAdjacentElement("afterend", err); + } + err.textContent = msg; + if (!input.dataset.errBound) { + input.dataset.errBound = "1"; + input.addEventListener("input", () => clearFieldError(input), { once: false }); + } + } + + function clearFieldError(input) { + if (!input) return; + input.classList.remove("input-error"); + const err = input.parentNode.querySelector(".field-error"); + if (err) err.textContent = ""; + } + /* --- 行内二次确认 / 行内编辑(rant 16:57:17 A:清除原生确认/输入弹窗) --- */ // 行内二次确认:首次点击按钮变「确认删除?」红色态,3 秒无操作或 Esc 还原,再次点击执行 @@ -482,6 +508,7 @@ $$("#topup-card .topup-presets .btn").forEach((b) => b.classList.remove("active")); $("#topup-card").hidden = false; $("#raise-card").hidden = true; // 互斥:开充值收起加额 + clearFieldError($("#topup-custom")); $("#topup-custom").focus(); } @@ -497,8 +524,12 @@ else { const raw = String(customRaw).trim(); amt = Math.round(Number(raw) * 100) / 100; - if (!raw || isNaN(amt) || amt <= 0) { toast("请输入大于 0 的充值点数(未生效)", "error"); return; } + if (!raw || isNaN(amt) || amt <= 0) { + setFieldError($("#topup-custom"), "请输入大于 0 的充值点数"); + return; + } } + clearFieldError($("#topup-custom")); D.USER.balance = Math.round((D.USER.balance + amt) * 100) / 100; D.TRANSACTIONS.unshift({ id: Date.now(), time: nowTime(), type: "topup", partner: "—", @@ -515,6 +546,8 @@ function openRaise() { $("#raise-amount").value = ""; $("#raise-reason").value = ""; + clearFieldError($("#raise-amount")); + clearFieldError($("#raise-reason")); $("#raise-card").hidden = false; $("#topup-card").hidden = true; // 互斥:开加额收起充值 $("#raise-amount").focus(); @@ -528,8 +561,12 @@ const rawAmt = String($("#raise-amount").value).trim(); const reason = String($("#raise-reason").value).trim(); const amt = Number(rawAmt); - if (!rawAmt || !Number.isInteger(amt) || amt <= 0) { toast("请输入正整数申请点数(未生效)", "error"); return; } - if (!reason) { toast("请填写申请原因(未生效)", "error"); return; } + let firstErr = null; + if (!rawAmt || !Number.isInteger(amt) || amt <= 0) { setFieldError($("#raise-amount"), "请输入正整数申请点数"); firstErr = firstErr || $("#raise-amount"); } + else clearFieldError($("#raise-amount")); + if (!reason) { setFieldError($("#raise-reason"), "请填写申请原因"); firstErr = firstErr || $("#raise-reason"); } + else clearFieldError($("#raise-reason")); + if (firstErr) { firstErr.focus(); return; } // 加额申请默认需管理员审批(原「需审批」开关随组织设置表单移除,见 rant 10:59:23) D.RAISE_REQUESTS.unshift({ id: Date.now(), user: D.USER.name, email: D.USER.email, amount: amt, reason, status: "pending", time: nowTime(), @@ -996,6 +1033,8 @@ : "添加部门 Add department"; $("#dept-form-name").value = d ? d.name : ""; $("#dept-form-quota").value = d ? String(d.quota) : ""; + clearFieldError($("#dept-form-name")); + clearFieldError($("#dept-form-quota")); $("#dept-form-card").hidden = false; $("#dept-form-name").focus(); } @@ -1004,16 +1043,20 @@ const name = String($("#dept-form-name").value).trim(); const rawQ = String($("#dept-form-quota").value).trim(); const quota = Number(rawQ); - if (!name) { toast("请输入部门名称(未生效)", "error"); return; } - if (!rawQ || !Number.isInteger(quota) || quota <= 0) { toast("请输入正整数月分配点数(未生效)", "error"); return; } + let firstErr = null; + if (!name) { setFieldError($("#dept-form-name"), "请输入部门名称"); firstErr = firstErr || $("#dept-form-name"); } + else clearFieldError($("#dept-form-name")); + if (!rawQ || !Number.isInteger(quota) || quota <= 0) { setFieldError($("#dept-form-quota"), "请输入正整数月分配点数"); firstErr = firstErr || $("#dept-form-quota"); } + else clearFieldError($("#dept-form-quota")); + if (firstErr) { firstErr.focus(); return; } if (deptEditIndex == null) { - if (D.DEPARTMENTS.some((d) => d.name === name)) { toast("部门「" + name + "」已存在", "error"); return; } + if (D.DEPARTMENTS.some((d) => d.name === name)) { setFieldError($("#dept-form-name"), "部门「" + name + "」已存在"); $("#dept-form-name").focus(); return; } D.DEPARTMENTS.push({ id: Date.now(), name, quota }); toast("已添加部门「" + name + "」(月分配 " + D.fmt(quota) + " 点)", "success"); } else { const d = D.DEPARTMENTS[deptEditIndex]; if (!d) return; - if (name !== d.name && D.DEPARTMENTS.some((x) => x.name === name)) { toast("部门「" + name + "」已存在", "error"); return; } + if (name !== d.name && D.DEPARTMENTS.some((x) => x.name === name)) { setFieldError($("#dept-form-name"), "部门「" + name + "」已存在"); $("#dept-form-name").focus(); return; } const old = d.name; d.name = name; d.quota = quota; @@ -1269,7 +1312,12 @@ // 共享上架表单(默认收起;点添加展开,提交成功或取消后收起) const shareFormCard = () => $("#share-form-card"); - const showShareForm = () => { shareFormCard().hidden = false; $("#sf-key").focus(); }; + const showShareForm = () => { + clearFieldError($("#sf-key")); + clearFieldError($("#sf-quota")); + shareFormCard().hidden = false; + $("#sf-key").focus(); + }; const hideShareForm = () => { shareFormCard().hidden = true; }; $("#share-add-btn").addEventListener("click", showShareForm); @@ -1294,8 +1342,14 @@ const quota = Number($("#sf-quota").value || 0); const key = $("#sf-key").value.trim(); const note = $("#sf-note").value.trim(); - if (!key) { toast("请填写 API Key(上架 key 必须提供真实密钥)", "error"); return; } - if (!plan || !model || quota <= 0) { toast("请选择厂商 / Plan / 模型并填写有效额度", "error"); return; } + let firstErr = null; + if (!key) { setFieldError($("#sf-key"), "请填写 API Key(上架 key 必须提供真实密钥)"); firstErr = firstErr || $("#sf-key"); } + else clearFieldError($("#sf-key")); + if (!plan || !model || quota <= 0) { + setFieldError($("#sf-quota"), "请选择厂商 / Plan / 模型并填写有效额度"); + firstErr = firstErr || $("#sf-quota"); + } else clearFieldError($("#sf-quota")); + if (firstErr) { firstErr.focus(); return; } // 可用时间段:星期多选 + 起止时间;不选任何星期 = null(全天不限) const days = $$("#sf-days .chip input:not(#sf-days-all input)") .filter((cb) => cb.checked).map((cb) => Number(cb.value)).sort((a, b) => a - b);