diff --git a/docs/user-stories.md b/docs/user-stories.md index 1ee1f01..237861e 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -5,6 +5,8 @@ > **v1.13(2026-08-17)· UI 演化约束:尽量少用弹窗 / modal.** 新增「UI 约定」章节(§1.1):优先行内(inline)交互(行内展开表单 / 可折叠面板 / 就地编辑),弹窗仅限真正需要聚焦 / 阻塞的场景(如删除确认),**禁止弹窗嵌套**;存量 modal(充值 topup-modal、申请加额 raise-modal、模型消费 chat-modal)标记改造优先级,后续迭代分批改为行内形态。部门添加/编辑已由弹层改为**行内展开表单**(`#dept-form-card`)。 > > **v1.14(2026-08-17)· 充值 / 加额改行内(P1 完成).** 按 UI 美化与 UE 优化 rant(`2026-08-17T15:50:05`)「少弹窗改造(优先)」:充值(原 topup-modal)与申请加额(原 raise-modal)改为钱包页**行内展开卡片**(`#topup-card` / `#raise-card`,点按钮原地展开、两卡互斥、取消即收起),替代弹层;P1 存量 modal 清零,仅剩 P2 模型消费聊天(chat-modal)。 +> +> **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.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 498a987..e115ce5 100644 --- a/ui/README.md +++ b/ui/README.md @@ -61,12 +61,19 @@ python3 -m http.server 8000 --directory ui ``` ui/ ├── index.html # 入口(登录页 + 应用外壳 + 全部视图) -├── css/style.css # 设计系统(深色主题 · 强调色 #4ecdc4 · 响应式预留) +├── css/style.css # 设计系统(深色主题 · 强调色 #4ecdc4 · 响应式预留 · v1.15 视觉美化) ├── js/data.js # 内嵌 mock 数据(模型价格、交易、共享、成员等) -├── js/app.js # 交互逻辑(导航、筛选、表单、分页、Toast) +├── js/app.js # 交互逻辑(导航、筛选、表单、分页、Toast;v1.15 内联 SVG 图标 + 空状态组件) └── README.md # 本文件 ``` +## 视觉约定(v1.15,rant 2026-08-17T15:50:05 A 视觉美化) + +- 侧边栏导航图标用**统一内联 SVG**(线性风格、同尺寸、currentColor,禁用 emoji 散落); +- 卡片 hover 阴影/边框轻提升;表格**斑马纹 + 行 hover 高亮**; +- 空状态统一 `empty-state` 组件(图标 + 文案 + 行动按钮),禁用裸文本占位; +- 状态徽章统一带**语义色点**(ok/warn/danger/dim),配色与 `badge.*` 类一致。 + ## 数据说明 - 点数规则与机制细节见 `docs/user-stories.md`(v1.8:机制说明不再进入面向用户的界面文案);UI 只呈现结果(余额数字、模型价格点数、交易金额/类型/状态、可用/繁忙) diff --git a/ui/css/style.css b/ui/css/style.css index feb10bd..e258bf3 100644 --- a/ui/css/style.css +++ b/ui/css/style.css @@ -111,9 +111,13 @@ select.input option { background: var(--bg-card); } border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; + transition: border-color .18s ease, box-shadow .18s ease; } .card h3 { font-size: 15px; margin-bottom: 14px; color: var(--text); } +/* 卡片 hover 微交互(rant 15:50:05 A.3:阴影/边框轻提升) */ +.card:hover { border-color: #3a4a63; box-shadow: 0 12px 30px rgba(0,0,0,.38); } + .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); @@ -161,16 +165,26 @@ select.input option { background: var(--bg-card); } white-space: nowrap; } .table td { padding: 10px 12px; border-bottom: 1px solid rgba(42,51,66,.5); white-space: nowrap; } +/* 斑马纹(rant 15:50:05 A.3:行 hover 高亮保留,hover 覆盖斑马纹) */ +.table tbody tr:nth-child(even) { background: rgba(255,255,255,.018); } .table tbody tr:hover { background: var(--bg-hover); } .badge { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 6px; padding: 2px 10px; border-radius: 999px; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim); } +/* 语义色点(rant 15:50:05 A.5:状态徽章配色统一、一眼可辨) */ +.badge::before { + content: ""; + width: 6px; height: 6px; border-radius: 50%; + background: currentColor; opacity: .85; flex-shrink: 0; +} .badge.ok { color: var(--ok); border-color: rgba(62,207,142,.4); background: rgba(62,207,142,.08); } .badge.warn { color: var(--warn); border-color: rgba(229,180,91,.4); background: rgba(229,180,91,.08); } .badge.danger { color: var(--danger); border-color: rgba(229,102,106,.4); background: rgba(229,102,106,.08); } @@ -291,6 +305,21 @@ select.input option { background: var(--bg-card); } } .bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent), #7ee8b8); border-radius: 999px; } +/* 空状态(rant 15:50:05 A.4:图标 + 文案 + 可选行动按钮) */ +.empty-state { + display: flex; flex-direction: column; align-items: center; + gap: 8px; padding: 40px 16px; text-align: center; + color: var(--text-dim); +} +.empty-state .es-ico { width: 40px; height: 40px; color: var(--text-mute); opacity: .75; } +.empty-state p { font-size: 13px; } +.empty-state p.muted { font-size: 12px; } +.empty-state .btn { margin-top: 8px; } +.empty-state.compact { padding: 16px 8px; } +.empty-state.compact .es-ico { width: 26px; height: 26px; } +.empty-cell { padding: 0 !important; } +.empty-cell .empty-state { padding: 36px 16px; } + /* ---------- 登录页 ---------- */ .login-view { @@ -357,7 +386,8 @@ select.input option { background: var(--bg-card); } color: var(--text-dim); cursor: pointer; font-size: 13.5px; border: none; background: transparent; text-align: left; width: 100%; } -.nav-item .ico { width: 18px; text-align: center; font-size: 15px; } +.nav-item .ico { width: 18px; text-align: center; font-size: 15px; display: inline-flex; align-items: center; justify-content: center; } +.nav-item .ico svg { width: 18px; height: 18px; display: block; } .nav-tag { margin-left: auto; font-size: 10px; diff --git a/ui/js/app.js b/ui/js/app.js index c67357b..2d7d4e8 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -33,6 +33,18 @@ esc(labels[status] ? labels[status].text : status) + ""; } + // 空状态组件(rant 15:50:05 A.4:列表/表格为空时给出图标 + 文案 + 可选行动按钮) + const EMPTY_ICON = ''; + + function emptyState(text, sub, actionHtml) { + return '
' + EMPTY_ICON + "

" + esc(text) + "

" + + (sub ? '

' + esc(sub) + "

" : "") + (actionHtml || "") + "
"; + } + + function emptyRow(colspan, text, sub, actionHtml) { + return '' + emptyState(text, sub, actionHtml) + ""; + } + // 自动单价:单价是模型×厂商的客观属性,由平台按模型定价自动计算(输出 1M tokens 折算点数) function autoPrice(model) { const m = D.MODELS.find((x) => x.model === model); @@ -74,17 +86,28 @@ /* ---------------- 导航 ---------------- */ + // 统一内联 SVG 图标(线性风格、同尺寸、currentColor,替代 emoji;rant 15:50:05 A.2) + const ICONS = { + dashboard: '', + marketplace: '', + sharing: '', + wallet: '', + transactions: '', + admin: '', + settings: '', + }; + const NAV = [ { g: "主导航", items: [ - { id: "dashboard", ico: "📊", label: "仪表盘 Dashboard" }, - { id: "marketplace", ico: "🛒", label: "模型市场 Marketplace" }, - { id: "sharing", ico: "🔗", label: "共享管理 Sharing" }, - { id: "wallet", ico: "👛", label: "钱包 Wallet" }, - { id: "transactions", ico: "🧾", label: "交易记录 Transactions" }, + { id: "dashboard", icon: "dashboard", label: "仪表盘 Dashboard" }, + { id: "marketplace", icon: "marketplace", label: "模型市场 Marketplace" }, + { id: "sharing", icon: "sharing", label: "共享管理 Sharing" }, + { id: "wallet", icon: "wallet", label: "钱包 Wallet" }, + { id: "transactions", icon: "transactions", label: "交易记录 Transactions" }, ]}, { g: "角色视图", items: [ - { id: "admin", ico: "🛠️", label: "管理视图 Admin", role: "admin" }, - { id: "settings", ico: "⚙️", label: "设置 Settings" }, + { id: "admin", icon: "admin", label: "管理视图 Admin", role: "admin" }, + { id: "settings", icon: "settings", label: "设置 Settings" }, ]}, ]; @@ -102,7 +125,7 @@ nav.innerHTML = ""; const groups = isGuest ? [{ g: "游客浏览", items: [ - { id: "marketplace", ico: "🛒", label: "模型市场 Marketplace" }, + { id: "marketplace", icon: "marketplace", label: "模型市场 Marketplace" }, ]}] : NAV; groups.forEach((group) => { @@ -114,7 +137,7 @@ const b = document.createElement("button"); b.className = "nav-item" + (item.id === activeView ? " active" : ""); b.dataset.view = item.id; - b.innerHTML = '' + item.ico + '' + esc(item.label) + ""; + b.innerHTML = '' + (ICONS[item.icon] || "") + '' + esc(item.label) + ""; if (item.role) { const tag = document.createElement("span"); tag.className = "nav-tag"; @@ -173,7 +196,7 @@ '
' + esc(s.model) + "
" + '
' + esc(s.plan || "API") + " · 已用 " + D.fmt(s.used) + " / " + D.fmt(s.quota) + " 点 · 单价 " + D.fmt(s.price) + " 点/1M
" + '
+' + D.fmt(s.earned) + "
累计收益
" - ).join("") + (on.length ? "" : '

还没有上架的 key

'); + ).join("") + (on.length ? "" : '
' + EMPTY_ICON + '

还没有上架的 key

去「共享管理」把闲置 key 放进池子

'); renderMonthChanges(); } @@ -206,7 +229,8 @@ (m.multi ? ' 多 key · 自动故障转移' : "") + "" + "" + "
成功率 " + m.success + "%
" - ).join("") : '没有匹配的模型'; + ).join("") : emptyRow(7, "没有匹配的模型", "试试调整搜索或筛选条件", + ''); } /* --- 可用时间段(rant 10:54:48:结构化字段,备注只作纯备注) --- */ @@ -284,7 +308,7 @@ fillPlans(); } - $("#share-body").innerHTML = D.SHARINGS.map((s, i) => + $("#share-body").innerHTML = D.SHARINGS.length ? D.SHARINGS.map((s, i) => "" + esc(D.PROVIDER_LABELS[s.provider] || s.provider) + " · " + esc(s.plan || "API") + "
" + esc(s.model) + " · " + esc(fmtAvailable(s)) + "
" + "" + esc(maskKey(s.key)) + "" + @@ -295,7 +319,8 @@ " " + "" - ).join(""); + ).join("") : emptyRow(7, "还没有上架的 key", "把闲置 key 放进池子,开始赚点数", + ''); } function deleteSharing(i) { @@ -445,7 +470,7 @@ "" : '' + esc(r.time) + "") + "" ).join("") + "" - : '

暂无加额申请

'; + : emptyState("暂无加额申请", "成员提交的加额申请会显示在这里,批准后自动加点数"); } function approveRaise(i) { @@ -580,7 +605,7 @@ } }); html += ""; - if (!pageRows.length) html += '没有匹配的记录'; + if (!pageRows.length) html += '' + emptyState("没有匹配的记录", "试试调整筛选条件") + ""; pageRows.forEach((row) => { html += ""; columns.forEach((col) => { @@ -666,7 +691,8 @@ " " + " " + "" - ).join("") : '没有匹配的 key'; + ).join("") : emptyRow(6, "还没有 API Key", "生成一个 key,用于本地工具 / 代码接入平台", + ''); } // 一键复制完整 key;file:// 下 clipboard API 受限 → 降级:临时 textarea 选中 + execCommand("copy"),仍失败则提示 Ctrl+C @@ -693,6 +719,17 @@ } } + // 生成新 API Key(带名字;列表展示脱敏、复制给完整 id) + function generateApiKey() { + const input = window.prompt("新 key 名字(留空则默认「未命名」):", "未命名"); + if (input == null) return; + const name = String(input).trim() || "未命名"; + 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(); + toast("已生成新 API Key「" + name + "」(完整 key 已展示在列表,可复制)"); + } + function renameKey(i) { const k = D.API_KEYS[i]; if (!k) return; @@ -829,7 +866,8 @@ "" + st + "" + " " + ""; - }).join("") : '没有匹配的部门'; + }).join("") : emptyRow(7, "没有匹配的部门", "试试调整搜索关键词", + ''); } /* --- 部门添加/编辑:行内展开表单(UI 原则:少用弹窗,优先行内交互;继承 rant 10:59:47 的可靠响应) --- */ @@ -915,7 +953,7 @@ "" + esc(u.email) + "" + "" + D.fmt(u.balance) + " 点" + "" - ).join("") : '没有匹配的用户'; + ).join("") : emptyRow(4, "没有匹配的用户", "试试其他用户名 / 邮箱"); } /* --- 消费模拟(US-6:市场页「使用 / 消费」→ 聊天 Mock,按模型参考价扣小数点数) --- */ @@ -1029,9 +1067,18 @@ // 市场页:使用 / 消费(G4:聊天 Mock 扣小数点数并产生 consume 交易;游客需先登录 US-1) $("#mk-body").addEventListener("click", (e) => { const b = e.target.closest("[data-use-model]"); - if (!b) return; - if (isGuest) { toast("请先登录后再使用 / 消费模型"); return; } - openChat(Number(b.dataset.useModel)); + if (b) { + if (isGuest) { toast("请先登录后再使用 / 消费模型"); return; } + openChat(Number(b.dataset.useModel)); + return; + } + // 空状态:清除筛选 + if (e.target.closest("[data-mk-clear-filters]")) { + $("#mk-search").value = ""; + $("#mk-provider").value = ""; + $("#mk-sort").value = "default"; + renderMarketplace(); + } }); $("#chat-send").addEventListener("click", sendChat); $("#chat-close").addEventListener("click", closeChat); @@ -1106,12 +1153,13 @@ hideShareForm(); }); - // 共享列表操作(事件委托:暂停/恢复/重新上架 + 删除) + // 共享列表操作(事件委托:暂停/恢复/重新上架 + 删除 + 空状态上架) $("#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)); + if (d) { deleteSharing(Number(d.dataset.shareDelete)); return; } + if (e.target.closest("[data-share-add]")) showShareForm(); }); // 钱包按钮(充值:US-4 行内卡片;申请加额:US-20 行内卡片;提现仍 disabled) @@ -1135,15 +1183,7 @@ $$("#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", () => { - const input = window.prompt("新 key 名字(留空则默认「未命名」):", "未命名"); - if (input == null) return; - const name = String(input).trim() || "未命名"; - 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(); - toast("已生成新 API Key「" + name + "」(完整 key 已展示在列表,可复制)"); - }); + $("#new-api-key-btn").addEventListener("click", generateApiKey); // API Key 搜索 + 行内操作(复制 / 改名 / 删除) $("#ak-search").addEventListener("input", renderSettings); @@ -1154,7 +1194,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)); + if (dl) { deleteKey(Number(dl.dataset.keyDel)); return; } + if (e.target.closest("[data-new-key]")) generateApiKey(); }); // 管理台 Tabs @@ -1203,7 +1244,11 @@ 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)); + if (dl) { deleteDept(Number(dl.dataset.deptDel)); return; } + if (e.target.closest("[data-dept-clear-search]")) { + $("#od-search").value = ""; + renderAdmin(); + } }); }