diff --git a/docs/user-stories.md b/docs/user-stories.md
index bfca3d6..c57b053 100644
--- a/docs/user-stories.md
+++ b/docs/user-stories.md
@@ -10,6 +10,7 @@
>
> **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`(快捷键提示)。**E. 表单行内校验错误 ✓(PR #45)**:新增 `setFieldError(input, msg)` / `clearFieldError(input)`(输入框**红边框** + 字段下方**红字小号行内文案**,输入修正自动清除,打开表单时重置);覆盖**充值金额**(自定义金额非法)、**申请加额**(点数/原因)、**部门表单**(名称/配额/重名)、**共享上架表单**(API Key/厂商·Plan·模型·额度),提交失败不再仅依赖 toast,聚焦首个错误字段。**F. 视图切换过渡 ✓(PR #46)**:页面切换加轻量动画(`.view:not(.hidden)` **fade + 轻微上移 6px,150ms** `viewIn`,替代生硬切换);数据表格更新无闪烁(`.table tbody` **fade-in** `tbodyIn`,新增 `pulseTbody` 在静态 tbody 重渲染后重启动画,动态表格整表重建自动生效)。**G. 细节一致性 ✓(PR #47)**:一致性审计确认——**所有数字/金额统一 `D.fmt`(千分位/2 位小数)**(侧边栏余额、统计卡、表格、钱包点数格式一致)、已用/额度展示统一;表格操作按钮统一小号样式(`padding:4px 10px;font-size:12px`);**行内编辑校验统一走行内错误**(inlineForm 校验失败由 toast 改为 `setFieldError` 红边框+行内文案,API Key 改名/运营者充值/成员充值 3 处对齐 item E);子文本字号统一(11px→12px);上架 toast 单价措辞统一「点/1M(自动)」;移除 3 处「(未生效)」残留文案。**rant 16:57:17 全部验收项满足(A–G 七项,PR #41–#47),UI/UE 深化波收官。**
+> **v1.18(2026-08-17)· UI/UE 第三轮多角度深化(rant `2026-08-17T18:06:09`,分批 PR).** **A. 仪表盘数据可视化 ✓(PR #48)**:纯 SVG sparkline(零外部依赖,手写 path,颜色用现有 CSS 变量):「本月点数变化」卡片按天聚合近 7 日净变化画迷你折线图(渐变填充,每点 `
` hover 显示当天数值);「我的共享」卡显示共享收益累计趋势 sparkline(`--ok` 色),无上架 key 时保留空状态不画图。其余 B–G(亮色主题、移动端卡片化、搜索增强、动效与系统偏好、动态标题、其他细节)分批落地。
> **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 a99dd69..99a66ff 100644
--- a/ui/README.md
+++ b/ui/README.md
@@ -123,6 +123,12 @@ ui/
- **行内编辑校验**:`inlineForm` 的 `opts.validate` 失败走 `setFieldError`(红边框+行内文案),与 E 项表单校验一致,不使用 toast 承载错误;
- 子文本(邮箱/模型等)统一 `font-size:12px`;单价措辞统一「点/1M」。
+## 数据可视化约定(v1.18,rant 2026-08-17T18:06:09 A 仪表盘图表)
+
+- **零外部依赖**:手写 SVG(`sparkline(values, opts)`),`--accent` / `--ok` / `--danger` 等现有 CSS 变量着色;每点带 ``(hover 显示日期 + 数值);
+- **使用**:`lastDayLabels(n)` 生成近 n 天 MM-DD 标签;`dailySeries(days, filter)` 按天聚合交易点数;仪表盘「本月点数变化」画近 7 日净变化折线(渐变填充),「我的共享」画收益累计趋势(`--ok` 色,无数据保留空状态);
+- 样式 `.sparkline`(宽 100% / 高 38px)。
+
## 数据说明
- 点数规则与机制细节见 `docs/user-stories.md`(v1.8:机制说明不再进入面向用户的界面文案);UI 只呈现结果(余额数字、模型价格点数、交易金额/类型/状态、可用/繁忙)
diff --git a/ui/css/style.css b/ui/css/style.css
index 37ec751..77c8a49 100644
--- a/ui/css/style.css
+++ b/ui/css/style.css
@@ -99,6 +99,9 @@ body {
/* 相对时间(rant 16:57:17 B):hover 提示完整绝对时间 */
.timeago { white-space: nowrap; cursor: help; }
+/* 数据可视化(rant 18:06:09 A:纯 SVG 迷你折线图) */
+.sparkline { display: block; width: 100%; height: 38px; margin: 2px 0 10px; }
+
/* 键盘可达性(rant 16:57:17 D):Tab 导航清晰焦点环(accent 色 outline) */
:focus-visible {
outline: 2px solid var(--accent);
diff --git a/ui/js/app.js b/ui/js/app.js
index c2f404b..5c864cf 100644
--- a/ui/js/app.js
+++ b/ui/js/app.js
@@ -322,6 +322,15 @@
'' + esc(s.plan || "API") + " · 已用 " + D.fmt(s.used) + " / " + D.fmt(s.quota) + " 点 · 单价 " + D.fmt(s.price) + " 点/1M
" +
'+' + D.fmt(s.earned) + "累计收益
"
).join("") + (on.length ? "" : '' + EMPTY_ICON + '
还没有上架的 key
去「共享管理」把闲置 key 放进池子
');
+ // 共享收益累计趋势 sparkline(rant 18:06:09 A;无上架 key 时保留空状态,不画图)
+ if (on.length) {
+ const days = lastDayLabels(7);
+ const earn = dailySeries(days, (t) => t.type === "earn");
+ let cum = 0;
+ const cumSeries = earn.map((v) => { cum = Math.round((cum + v) * 100) / 100; return cum; });
+ $("#dash-sharings").insertAdjacentHTML("afterbegin",
+ sparkline(cumSeries, { labels: days, fmt: (v) => "+" + D.fmt(v), stroke: "var(--ok)" }));
+ }
renderMonthChanges();
}
@@ -487,6 +496,65 @@
'' + sign + D.fmt(pts) + "
";
}
+ /* --- 数据可视化(rant 18:06:09 A:纯 SVG 迷你折线图,零外部依赖) --- */
+
+ let _sparkId = 0;
+
+ // 生成 SVG sparkline:values 数值数组 → 折线 + 渐变填充,每点带 (hover 显示当天数值)
+ // opts: { labels: 与 values 等长的日期标签, fmt: 数值格式化, stroke, w, h }
+ function sparkline(values, opts) {
+ opts = opts || {};
+ const w = opts.w || 120, h = opts.h || 34, pad = 2;
+ const vals = values.length ? values : [0, 0];
+ const max = Math.max.apply(null, vals.concat([0.0001]));
+ const min = Math.min.apply(null, vals.concat([0]));
+ const span = (max - min) || 1;
+ const pts = vals.map((v, i) => {
+ const x = vals.length <= 1 ? w / 2 : pad + (i * (w - pad * 2)) / (vals.length - 1);
+ const y = h - pad - ((v - min) / span) * (h - pad * 2);
+ return [x, y];
+ });
+ const line = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");
+ const last = pts[pts.length - 1], first = pts[0];
+ const area = line + " L" + last[0].toFixed(1) + " " + h + " L" + first[0].toFixed(1) + " " + h + " Z";
+ const stroke = opts.stroke || "var(--accent)";
+ const gid = "spark-grad-" + (++_sparkId);
+ const fmt = opts.fmt || ((v) => v);
+ const titles = pts.map((p, i) =>
+ "" + esc((opts.labels && opts.labels[i] ? opts.labels[i] + " " : "") + fmt(vals[i])) + "").join("");
+ return '";
+ }
+
+ // 最近 n 天的日期标签(MM-DD),今天在前
+ function lastDayLabels(n) {
+ const p = (x) => String(x).padStart(2, "0");
+ const out = [];
+ for (let i = n - 1; i >= 0; i--) {
+ const d = new Date();
+ d.setDate(d.getDate() - i);
+ out.push(p(d.getMonth() + 1) + "-" + p(d.getDate()));
+ }
+ return out;
+ }
+
+ // 按天聚合交易点数(filter 可选:只统计某类型),返回与 days 等长的序列
+ function dailySeries(days, filter) {
+ const map = {};
+ D.TRANSACTIONS.forEach((t) => {
+ if (filter && !filter(t)) return;
+ const day = String(t.time || "").slice(0, 5);
+ map[day] = (map[day] || 0) + t.pts;
+ });
+ return days.map((d) => Math.round((map[d] || 0) * 100) / 100);
+ }
+
function renderMonthChanges() {
const sums = {};
D.TRANSACTIONS.forEach((t) => { sums[t.type] = (sums[t.type] || 0) + t.pts; });
@@ -499,7 +567,12 @@
const walletEl = $("#month-changes");
if (walletEl) walletEl.innerHTML = html;
const dashEl = $("#dash-month-changes");
- if (dashEl) dashEl.innerHTML = html;
+ if (dashEl) {
+ // 迷你折线图(rant 18:06:09 A:按天聚合净变化,hover 显示当天数值)
+ const days = lastDayLabels(7);
+ const net = dailySeries(days);
+ dashEl.innerHTML = sparkline(net, { labels: days, fmt: (v) => (v > 0 ? "+" : "") + D.fmt(v) }) + html;
+ }
}
/* --- 钱包 --- */