diff --git a/docs/user-stories.md b/docs/user-stories.md
index cc7b379..08d2f90 100644
--- a/docs/user-stories.md
+++ b/docs/user-stories.md
@@ -9,6 +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–G(相对时间、数字列对齐、`:focus-visible` 与快捷键、行内校验错误、视图过渡、细节一致性)分批落地。
> **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。
@@ -46,6 +47,7 @@
- **P0 高频 ✓**:上架表单(`#share-form-card` 行内卡片)、部门添加/编辑(`#dept-form-card` 行内卡片)
- **P1 ✓(2026-08-17)**:充值(`#topup-card` 行内卡片,替代 topup-modal)、申请加额(`#raise-card` 行内卡片,替代 raise-modal)——钱包页点按钮原地展开,两卡互斥
- **P2**:模型消费聊天(chat-modal → 独立页或行内面板,待后续迭代)
+- **原生弹窗清零 ✓(rant `2026-08-17T16:57:17` A,v1.17)**:删除/下架/部门删除等确认一律走**行内二次确认**(按钮变「确认删除?」红色态,3 秒无操作或 Esc 还原);新建/改名/充值等输入一律走**行内编辑**(输入框 Enter 确认 / Esc 取消);抽象复用 `confirmInline` / `inlineForm` 组件,`grep ui/` 无原生 confirm/prompt 残留。
- 该原则约束后续所有 UI 演化;与 UI 原型(`ui/`)对齐。
### 核心机制(Core mechanism)
diff --git a/ui/README.md b/ui/README.md
index ffa5e3c..ef6aa4a 100644
--- a/ui/README.md
+++ b/ui/README.md
@@ -81,6 +81,11 @@ ui/
- **复制反馈**:API Key 复制后按钮短暂变「已复制 ✓」(1.2s 恢复);降级路径显示「请 Ctrl+C」;
- **键盘可达**:行内表单 Enter 提交、Esc 关闭行内卡片、打开时自动聚焦焦点。
+## 行内组件约定(v1.17,rant 2026-08-17T16:57:17 A 清除原生弹窗)
+
+- **禁用原生弹窗**:`grep ui/` 不得出现原生 confirm/prompt 调用;确认走 `confirmInline(btn, onConfirm, text)`(按钮变「确认删除?」红色态 `.confirming`,3 秒无操作或 Esc 还原,再次点击执行),输入走 `inlineForm(cell, opts)`(行内展开 input + 确认/取消,Enter 确认 / Esc 取消,`opts.validate` 返回错误文案时 toast + 重新聚焦);
+- 已覆盖:key 删除、共享下架、部门删除(confirmInline);API Key 新建/改名、运营者充值、成员充值(inlineForm);新建 key 行内输入框在 `#ak-new-inline`(Enter/Esc 绑定)。
+
## 数据说明
- 点数规则与机制细节见 `docs/user-stories.md`(v1.8:机制说明不再进入面向用户的界面文案);UI 只呈现结果(余额数字、模型价格点数、交易金额/类型/状态、可用/繁忙)
diff --git a/ui/css/style.css b/ui/css/style.css
index c40bf52..888a090 100644
--- a/ui/css/style.css
+++ b/ui/css/style.css
@@ -72,6 +72,17 @@ body {
.btn-block { width: 100%; }
.btn:disabled { opacity: .5; cursor: not-allowed; pointer-events: none; }
+/* 行内二次确认(rant 16:57:17 A:按钮变「确认删除?」红色态,3 秒无操作还原) */
+.btn.confirming {
+ background: rgba(229, 102, 106, 0.15);
+ border-color: var(--danger);
+ color: #ff9d9f;
+ font-weight: 700;
+}
+/* 行内编辑容器(输入框 + 确认/取消,Enter 确认 / Esc 取消) */
+.inline-edit { display: inline-flex; gap: 6px; align-items: center; vertical-align: middle; }
+.inline-edit .input { width: auto; }
+
.input, select.input {
width: 100%;
background: var(--bg-soft);
diff --git a/ui/index.html b/ui/index.html
index e303c77..84f7bb2 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -291,6 +291,12 @@
diff --git a/ui/js/app.js b/ui/js/app.js
index 3545e43..270417b 100644
--- a/ui/js/app.js
+++ b/ui/js/app.js
@@ -47,6 +47,71 @@
}, ms || 320);
}
+ /* --- 行内二次确认 / 行内编辑(rant 16:57:17 A:清除原生确认/输入弹窗) --- */
+
+ // 行内二次确认:首次点击按钮变「确认删除?」红色态,3 秒无操作或 Esc 还原,再次点击执行
+ function confirmInline(btn, onConfirm, confirmText) {
+ if (!btn) return;
+ if (btn.dataset.confirm === "1") {
+ clearTimeout(btn._confirmT);
+ delete btn.dataset.confirm;
+ btn.classList.remove("confirming");
+ onConfirm();
+ return;
+ }
+ btn.dataset.confirm = "1";
+ const orig = btn.innerHTML;
+ btn.innerHTML = confirmText || "确认删除?";
+ btn.classList.add("confirming");
+ btn._confirmT = setTimeout(() => revert(), 3000);
+ const revert = () => {
+ clearTimeout(btn._confirmT);
+ if (btn.dataset.confirm === "1") delete btn.dataset.confirm;
+ btn.classList.remove("confirming");
+ btn.innerHTML = orig;
+ };
+ document.addEventListener("keydown", function esc(e) {
+ if (e.key === "Escape") { revert(); document.removeEventListener("keydown", esc); }
+ });
+ }
+
+ // 行内编辑表单:把容器替换为 input + 确认/取消,Enter 确认 / Esc 取消
+ // opts: { value, placeholder, type, width, validate(val)->err|null, onSubmit(val), onCancel }
+ function inlineForm(cell, opts) {
+ const wrap = document.createElement("span");
+ wrap.className = "inline-edit";
+ wrap.style.cssText = "display:inline-flex;gap:6px;align-items:center";
+ const input = document.createElement("input");
+ input.type = opts.type || "text";
+ input.className = "input";
+ input.value = opts.value || "";
+ input.placeholder = opts.placeholder || "";
+ input.style.cssText = "padding:4px 8px;font-size:12px;width:" + (opts.width || "140px");
+ const ok = document.createElement("button");
+ ok.type = "button"; ok.className = "btn btn-primary"; ok.textContent = "确认";
+ ok.style.cssText = "padding:4px 10px;font-size:12px";
+ const cancel = document.createElement("button");
+ cancel.type = "button"; cancel.className = "btn btn-ghost"; cancel.textContent = "取消";
+ cancel.style.cssText = "padding:4px 10px;font-size:12px";
+ wrap.append(input, ok, cancel);
+ cell.innerHTML = "";
+ cell.appendChild(wrap);
+ input.focus();
+ if (input.select) input.select();
+ const finish = () => {
+ const val = String(input.value).trim();
+ const err = opts.validate ? opts.validate(val) : null;
+ if (err) { toast(err, "error"); input.focus(); return; }
+ opts.onSubmit(val);
+ };
+ ok.addEventListener("click", finish);
+ cancel.addEventListener("click", opts.onCancel);
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") finish();
+ else if (e.key === "Escape") opts.onCancel();
+ });
+ }
+
function badge(status, labels) {
return '' +
esc(labels[status] ? labels[status].text : status) + "";
@@ -345,7 +410,6 @@
function deleteSharing(i) {
const s = D.SHARINGS[i];
if (!s) return;
- if (!window.confirm("确认彻底下架 " + s.model + " 的 key?删除后不再共享,不可恢复。")) return;
D.SHARINGS.splice(i, 1);
renderSharing();
if (activeView === "dashboard") renderDashboard();
@@ -747,33 +811,48 @@
}
}
- // 生成新 API Key(带名字;列表展示脱敏、复制给完整 id)
- function generateApiKey() {
- const input = window.prompt("新 key 名字(留空则默认「未命名」):", "未命名");
- if (input == null) return;
- const name = String(input).trim() || "未命名";
+ // 生成新 API Key(行内编辑,替代原生输入弹窗,Enter 确认 / Esc 取消)
+ function openNewKeyInline() {
+ const wrap = $("#ak-new-inline");
+ wrap.hidden = false;
+ $("#ak-new-name").value = "";
+ $("#ak-new-name").focus();
+ }
+
+ function closeNewKeyInline() {
+ $("#ak-new-inline").hidden = true;
+ }
+
+ function commitNewKey() {
+ const raw = String($("#ak-new-name").value).trim();
+ const name = raw || "未命名";
const id = "atk_live_" + Array.from({ length: 12 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
D.API_KEYS.unshift({ id, name, created: "2026-08-14", last: "从未", status: "active" });
renderSettings();
+ closeNewKeyInline();
toast("已生成新 API Key「" + name + "」(完整 key 已展示在列表,可复制)", "success");
}
+ // API Key 改名:行内编辑(替代原生输入弹窗)
function renameKey(i) {
const k = D.API_KEYS[i];
if (!k) return;
- const input = window.prompt("修改 key 名字:", k.name);
- if (input == null) return;
- const name = String(input).trim();
- if (!name) { toast("名字不能为空(未生效)", "error"); return; }
- k.name = name;
- renderSettings();
- toast("已改名为「" + name + "」", "success");
+ const row = document.querySelector('#api-keys tr:nth-child(' + (i + 1) + ')');
+ const cell = row ? row.children[0] : null;
+ if (!cell) return;
+ inlineForm(cell, {
+ value: k.name,
+ placeholder: "key 名字",
+ width: "160px",
+ validate: (v) => v ? null : "名字不能为空(未生效)",
+ onSubmit: (name) => { k.name = name; renderSettings(); toast("已改名为「" + name + "」", "success"); },
+ onCancel: () => renderSettings(),
+ });
}
function deleteKey(i) {
const k = D.API_KEYS[i];
if (!k) return;
- if (!window.confirm("删除后该 key 立即失效,确认删除「" + k.name + "」?")) return;
D.API_KEYS.splice(i, 1);
renderSettings();
toast("已删除 key「" + k.name + "」", "success");
@@ -941,11 +1020,6 @@
function deleteDept(i) {
const d = D.DEPARTMENTS[i];
if (!d) return;
- const members = deptMemberCount(d.name);
- const msg = members > 0
- ? "该部门有 " + members + " 名成员,删除后这些成员将失去部门归属。确认删除部门「" + d.name + "」?"
- : "确认删除部门「" + d.name + "」?";
- if (!window.confirm(msg)) return;
D.DEPARTMENTS.splice(i, 1);
renderAdmin();
toast("已删除部门「" + d.name + "」", "success");
@@ -984,6 +1058,36 @@
).join("") : emptyRow(4, "没有匹配的用户", "试试其他用户名 / 邮箱");
}
+ // 运营者给用户充值:行内编辑(替代原生输入弹窗,Enter 确认 / Esc 取消)
+ function inlineOpsTopup(u, btn) {
+ const row = btn.closest("tr");
+ if (!row) return;
+ const cell = row.children[3];
+ inlineForm(cell, {
+ value: "100",
+ placeholder: "点数(可为小数)",
+ type: "number",
+ width: "120px",
+ validate: (raw) => {
+ const amt = Math.round(Number(raw) * 100) / 100;
+ return (!raw || isNaN(amt) || amt <= 0) ? "请输入大于 0 的点数金额(未生效)" : null;
+ },
+ onSubmit: (raw) => {
+ const amt = Math.round(Number(raw) * 100) / 100;
+ u.balance = Math.round((u.balance + amt) * 100) / 100;
+ if (u.email === D.USER.email) D.USER.balance = u.balance;
+ D.TRANSACTIONS.unshift({
+ id: Date.now(), time: nowTime(), type: "topup", partner: "运营者",
+ detail: "充值 · 运营者发放(永久有效)", tokens: "—", pts: amt, status: "成功",
+ });
+ renderAdmin();
+ $("#side-balance").textContent = D.fmt(D.USER.balance);
+ toast("已给「" + u.name + "」充值 " + D.fmt(amt) + " 点(永久有效)", "success");
+ },
+ onCancel: () => renderAdmin(),
+ });
+ }
+
/* --- 消费模拟(US-6:市场页「使用 / 消费」→ 聊天 Mock,按模型参考价扣小数点数) --- */
let chatModel = null;
@@ -1113,27 +1217,14 @@
$("#chat-input").addEventListener("keydown", (e) => { if (e.key === "Enter") sendChat(); });
$("#chat-modal").addEventListener("click", (e) => { if (e.target === $("#chat-modal")) closeChat(); });
- // 平台运营者(G1 / US-运营2):搜索定位用户 + 充值(永久有效点数,产生交易记录)
+ // 平台运营者(G1 / US-运营2):搜索定位用户 + 充值(行内编辑,永久有效点数,产生交易记录)
$("#ops-search").addEventListener("input", renderAdmin);
$("#ops-body").addEventListener("click", (e) => {
const b = e.target.closest("[data-ops-topup]");
if (!b) return;
const u = D.OPERATOR_USERS.find((x) => x.id === Number(b.dataset.opsTopup));
if (!u) return;
- const input = window.prompt("为「" + u.name + "」充值点数(永久有效,可为小数):", "100");
- if (input == null) return;
- const raw = String(input).trim();
- const amt = Math.round(Number(raw) * 100) / 100;
- if (!raw || isNaN(amt) || amt <= 0) { toast("请输入大于 0 的点数金额(未生效)", "error"); return; }
- u.balance = Math.round((u.balance + amt) * 100) / 100;
- if (u.email === D.USER.email) D.USER.balance = u.balance;
- D.TRANSACTIONS.unshift({
- id: Date.now(), time: nowTime(), type: "topup", partner: "运营者",
- detail: "充值 · 运营者发放(永久有效)", tokens: "—", pts: amt, status: "成功",
- });
- renderAdmin();
- $("#side-balance").textContent = D.fmt(D.USER.balance);
- toast("已给「" + u.name + "」充值 " + D.fmt(amt) + " 点(永久有效)", "success");
+ inlineOpsTopup(u, b);
});
// 共享上架表单(默认收起;点添加展开,提交成功或取消后收起)
@@ -1185,12 +1276,12 @@
withLoading(submitBtn, done);
});
- // 共享列表操作(事件委托:暂停/恢复/重新上架 + 删除 + 空状态上架)
+ // 共享列表操作(事件委托:暂停/恢复/重新上架 + 删除[行内二次确认] + 空状态上架)
$("#share-body").addEventListener("click", (e) => {
const b = e.target.closest("[data-share-toggle]");
if (b) { toggleSharing(Number(b.dataset.shareToggle)); return; }
const d = e.target.closest("[data-share-delete]");
- if (d) { deleteSharing(Number(d.dataset.shareDelete)); return; }
+ if (d) { confirmInline(d, () => deleteSharing(Number(d.dataset.shareDelete)), "确认彻底下架?"); return; }
if (e.target.closest("[data-share-add]")) showShareForm();
});
@@ -1223,10 +1314,16 @@
// 交易 Tab
$$("#tx-tabs .tab").forEach((b) => b.addEventListener("click", () => { txTab = b.dataset.txTab; txTable.page = 1; renderTransactions(); }));
- // API Key 生成(带名字;列表展示脱敏、复制给完整 id)
- $("#new-api-key-btn").addEventListener("click", generateApiKey);
+ // API Key 生成(行内编辑;列表展示脱敏、复制给完整 id)
+ $("#new-api-key-btn").addEventListener("click", openNewKeyInline);
+ $("#ak-new-ok").addEventListener("click", commitNewKey);
+ $("#ak-new-cancel").addEventListener("click", closeNewKeyInline);
+ $("#ak-new-name").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") commitNewKey();
+ else if (e.key === "Escape") closeNewKeyInline();
+ });
- // API Key 搜索 + 行内操作(复制 / 改名 / 删除)
+ // API Key 搜索 + 行内操作(复制 / 改名 / 删除[行内二次确认])
$("#ak-search").addEventListener("input", renderSettings);
$("#api-keys").addEventListener("click", (e) => {
@@ -1235,8 +1332,8 @@
const rn = e.target.closest("[data-key-rename]");
if (rn) { renameKey(Number(rn.dataset.keyRename)); return; }
const dl = e.target.closest("[data-key-del]");
- if (dl) { deleteKey(Number(dl.dataset.keyDel)); return; }
- if (e.target.closest("[data-new-key]")) generateApiKey();
+ if (dl) { confirmInline(dl, () => deleteKey(Number(dl.dataset.keyDel)), "确认删除?"); return; }
+ if (e.target.closest("[data-new-key]")) openNewKeyInline();
});
// 管理台 Tabs
@@ -1246,6 +1343,7 @@
renderAdmin();
}));
+ // 成员充值(管理台):行内编辑(替代原生输入弹窗,Enter 确认 / Esc 取消)
$("#emp-body").addEventListener("click", (e) => {
const dd = e.target.closest("[data-emp-dept]");
if (dd) { editEmpDept(Number(dd.dataset.empDept)); return; }
@@ -1253,17 +1351,26 @@
if (!b) return;
const emp = D.EMPLOYEES[Number(b.dataset.empTopup)];
if (!emp) return;
- const input = prompt("为 " + emp.name + " 充值点数(正整数,任意金额):", "5000");
- if (input == null) return; // 用户取消
- const raw = String(input).trim();
- const amt = Number(raw);
- if (!raw || !Number.isInteger(amt) || amt <= 0) {
- toast("请输入正整数点数金额(未生效)", "error");
- return;
- }
- emp.quota += amt;
- renderAdmin();
- toast("已给 " + emp.name + " 充值 " + D.fmt(amt) + " 点", "success");
+ const row = b.closest("tr");
+ if (!row) return;
+ const cell = row.children[5];
+ inlineForm(cell, {
+ value: "5000",
+ placeholder: "点数(正整数)",
+ type: "number",
+ width: "120px",
+ validate: (raw) => {
+ const amt = Number(raw);
+ return (!raw || !Number.isInteger(amt) || amt <= 0) ? "请输入正整数点数金额(未生效)" : null;
+ },
+ onSubmit: (raw) => {
+ const amt = Number(raw);
+ emp.quota += amt;
+ renderAdmin();
+ toast("已给 " + emp.name + " 充值 " + D.fmt(amt) + " 点", "success");
+ },
+ onCancel: () => renderAdmin(),
+ });
});
// 加额申请审批(US-20:批准 → 成员余额+申请点数;驳回 → 仅更新状态)
@@ -1289,7 +1396,7 @@
const ed = e.target.closest("[data-dept-edit]");
if (ed) { openDeptForm(Number(ed.dataset.deptEdit)); return; }
const dl = e.target.closest("[data-dept-del]");
- if (dl) { deleteDept(Number(dl.dataset.deptDel)); return; }
+ if (dl) { confirmInline(dl, () => deleteDept(Number(dl.dataset.deptDel)), "确认删除部门?"); return; }
if (e.target.closest("[data-dept-clear-search]")) {
$("#od-search").value = "";
renderAdmin();