diff --git a/Cargo.lock b/Cargo.lock index 59dc3d8..6c07f24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.3.0" +version = "0.3.1" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 703bea2..97cb602 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.3.0" +version = "0.3.1" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 17443ab..a126810 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -139,11 +139,33 @@ pub async fn login( }))) } -/// 组装路由 +/// GET /api/me:当前登录用户信息(P2-A 前端会话)→ {id, email, name, role} +pub async fn me( + State(st): State, + auth: AuthUser, +) -> Result, ApiErr> { + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let (email, name, role): (String, String, String) = conn + .query_row( + "SELECT email, name, role FROM users WHERE id = ?1", + [auth.user_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .map_err(internal)?; + Ok(Json(serde_json::json!({ + "id": auth.user_id, + "email": email, + "name": name, + "role": role, + }))) +} + +/// 组装路由:API 路由优先,其余请求回退到 ui/ 静态托管(P2-A) pub fn router() -> Router { Router::new() .route("/healthz", get(healthz)) .route("/api/auth/login", post(login)) + .route("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/api/me", get(me)) .route("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/api/api-keys", post(api_keys::create).get(api_keys::list)) .route("/v1/chat/completions", post(gateway::chat_completions)) .route("/anthropic/v1/messages", post(gateway::anthropic_messages)) @@ -158,6 +180,8 @@ pub fn router() -> Router { .route("/api/admin/credits", post(admin::credits)) .route("/api/admin/users", get(admin::users)) .route("/api/admin/usage", get(admin::usage)) + // P2-A:静态托管(ui/ 目录;API 路由优先,未命中回退到文件服务) + .fallback_service(tower_http::services::ServeDir::new("ui")) } #[cfg(test)] @@ -429,4 +453,37 @@ mod tests { let v2: serde_json::Value = serde_json::from_str(&body2).unwrap(); assert_eq!(v2["gift_balance"], 1.0, "同天不重复: {body2}"); } + + #[tokio::test] + async fn static_hosting_serves_ui_index() { + // P2-A:GET / → ui/index.html(200 + html);GET /css/style.css → 200 + let st = test_state("serve"); + let (s, body) = get(st.clone(), "/", None).await; + assert_eq!(s, StatusCode::OK, "根路径应返回 index.html: {body:?}"); + assert!( + body.contains("") || body.contains("AITokenPool - + @@ -528,6 +528,7 @@

使用模型

+ diff --git a/ui/js/api.js b/ui/js/api.js new file mode 100644 index 0000000..be88d1a --- /dev/null +++ b/ui/js/api.js @@ -0,0 +1,97 @@ +/* AITokenPool — API 客户端层(P2-A,rant 2026-08-18T11:49:52) + * + * 封装 fetch:api.get / api.post / api.patch,自动带 Bearer token; + * 统一错误处理:401 → 清 token 回登录页;非 2xx → 抛 {status, message}。 + * base URL:默认同源(''),支持 ?api= 查询参数覆盖(部署时指向网关)。 + */ +const api = (() => { + // base URL 覆盖:?api=https://gateway.example.com + const base = (() => { + try { + const q = new URLSearchParams(window.location.search).get("api"); + return q ? q.replace(/\/+$/, "") : ""; + } catch (e) { + return ""; + } + })(); + + // 记住我 → token 存 localStorage(长期);否则 sessionStorage(关闭即失效) + const rememberKey = "atp-remember"; + const tokenKey = "atp_token"; + + function isRemember() { + try { return localStorage.getItem(rememberKey) === "1"; } catch (e) { return false; } + } + + function saveToken(token) { + const s = isRemember() ? localStorage : sessionStorage; + try { + s.setItem(tokenKey, token); + // 双写清理:确保另一 storage 无残留(从记住切到不记住等场景) + (isRemember() ? sessionStorage : localStorage).removeItem(tokenKey); + } catch (e) { /* 隐私模式忽略 */ } + } + + function getToken() { + try { + return (isRemember() ? localStorage : sessionStorage).getItem(tokenKey) || ""; + } catch (e) { + return ""; + } + } + + function clearToken() { + try { localStorage.removeItem(tokenKey); } catch (e) { /* ignore */ } + try { sessionStorage.removeItem(tokenKey); } catch (e) { /* ignore */ } + } + + // 401 → 清 token 回登录页(登出态;调用方无需重复处理) + function handleUnauthorized() { + clearToken(); + if (window.__atpLogout) window.__atpLogout(); + } + + async function request(method, path, body) { + const headers = { "content-type": "application/json" }; + const token = getToken(); + if (token) headers.authorization = "Bearer " + token; + let resp; + try { + resp = await fetch(base + path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (e) { + throw { status: 0, message: "网络不可用,请检查后端服务是否启动" }; + } + if (resp.status === 401) { + handleUnauthorized(); + throw { status: 401, message: "登录已过期,请重新登录" }; + } + const text = await resp.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch (e) { data = { raw: text }; } + } + if (!resp.ok) { + // 取后端 error.message 或 error 字段 + const message = (data && (data.error && (data.error.message || data.error))) || (data && data.message) || ("请求失败(HTTP " + resp.status + ")"); + const err = new Error(message); + err.status = resp.status; + throw err; + } + return data; + } + + return { + base, + get: (path) => request("GET", path), + post: (path, body) => request("POST", path, body), + patch: (path, body) => request("PATCH", path, body), + saveToken, + getToken, + clearToken, + isRemember, + }; +})(); diff --git a/ui/js/app.js b/ui/js/app.js index a78068a..d8c060c 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -452,11 +452,15 @@ function renderNav() { const nav = $("#nav"); nav.innerHTML = ""; + // P2-A:角色视图按当前用户 role 显隐(admin 项仅 role=admin 可见) + const roleNav = NAV + .map((g) => ({ g: g.g, items: g.items.filter((it) => !it.role || D.USER.role === it.role) })) + .filter((g) => g.items.length > 0); const groups = isGuest ? [{ g: "游客浏览", items: [ { id: "marketplace", icon: "marketplace", label: "模型市场 Marketplace" }, ]}] - : NAV; + : roleNav; groups.forEach((group) => { const g = document.createElement("div"); g.className = "nav-group"; @@ -489,6 +493,11 @@ toast("请先登录后再访问「" + (VIEW_TITLE[id] || id) + "」", "error"); return; } + // 角色限制(P2-A):管理视图仅 admin(hash 直达 / 快捷键也兜底) + if (id === "admin" && D.USER.role !== "admin") { + toast("管理视图仅管理员可见", "error"); + return; + } activeView = id; $$(".view").forEach((v) => v.classList.add("hidden")); $("#view-" + id).classList.remove("hidden"); @@ -1743,14 +1752,52 @@ $("#login-view").classList.remove("hidden"); } + /* ---------------- 会话(P2-A:/api/me + /api/wallet) ---------------- */ + + // 拉当前用户信息 + 钱包余额(替代 mock D.USER.*);余额失败 → 0 + 红色提示 + async function loadSession() { + const me = await api.get("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/api/me"); + D.USER.name = (me && me.name) || (me && me.email ? me.email.split("@")[0] : "用户"); + D.USER.email = (me && me.email) || D.USER.email; + D.USER.role = (me && me.role) || "user"; + try { + const w = await api.get("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/api/wallet"); + D.USER.balance = (w && typeof w.available === "number") ? w.available : (w ? w.balance : 0); + } catch (e) { + D.USER.balance = 0; + toast("余额加载失败,显示 0", "error"); + } + } + + // 进入主界面(登录成功 / 会话恢复共用) + function enterApp() { + isGuest = false; + pendingHashView = null; + document.querySelector(".user-chip").classList.remove("hidden"); + $("#login-view").classList.add("hidden"); + $("#app").classList.remove("hidden"); + $("#side-balance").textContent = D.fmt(D.USER.balance); + renderNav(); + // URL hash 路由:登录后恢复刷新前的视图(无 hash 则仪表盘) + switchView(viewFromHash() || "dashboard"); + maybeStartTour(); // 首次登录引导(rant 20:46:57 A:atp-tour-done 未标记才触发) + } + + // api.js 401 钩子:token 失效 → 清 token 回登录页 + window.__atpLogout = () => { + api.clearToken(); + exitGuest(); + toast("登录已过期,请重新登录", "error"); + }; + /* ---------------- 事件 ---------------- */ function bindEvents() { // 游客浏览(US-1:登录页入口 → 免登录进入市场) $("#guest-browse-btn").addEventListener("click", enterGuest); - // 登录(单一入口,角色由账号决定;rant 20:39:30 G:空邮箱/密码行内错误 + 记住我) - $("#login-form").addEventListener("submit", (e) => { + // 登录(P2-A:对接 POST /api/auth/login;失败行内报错;成功存 token + 拉会话) + $("#login-form").addEventListener("submit", async (e) => { e.preventDefault(); const email = $("#login-email").value.trim(); const pass = $("#login-pass").value; @@ -1760,22 +1807,33 @@ if (!pass) { setFieldError($("#login-pass"), "请输入密码"); firstErr = firstErr || $("#login-pass"); } else clearFieldError($("#login-pass")); if (firstErr) { firstErr.focus(); return; } - // 记住我(原型:仅记忆演示账号偏好,localStorage) + // 记住我(P2-A:token 存 localStorage 长期 / sessionStorage 关闭失效) try { localStorage.setItem("atp-remember", $("#login-remember").checked ? "1" : "0"); } catch (err) { /* 隐私模式忽略 */ } - isGuest = false; - document.querySelector(".user-chip").classList.remove("hidden"); - $("#login-view").classList.add("hidden"); - $("#app").classList.remove("hidden"); - // URL hash 路由:登录后恢复刷新前的视图(无 hash 则仪表盘) - switchView(pendingHashView || "dashboard"); - pendingHashView = null; - maybeStartTour(); // 首次登录引导(rant 20:46:57 A:atp-tour-done 未标记才触发) - toast("欢迎回来,阿零(演示账号)", "info"); + const btn = e.target.querySelector('button[type="submit"]'); + if (btn) { btn.disabled = true; btn.textContent = "登录中…"; } + try { + const r = await api.post("/api/auth/login", { email, password: pass }); + api.saveToken(r.api_key); + await loadSession(); // /api/me → 用户信息;/api/wallet → 余额 + enterApp(); + toast("欢迎回来," + (D.USER.name || email), "success"); + } catch (err) { + if (err && err.status === 401) { + setFieldError($("#login-pass"), "邮箱或密码错误"); + clearFieldError($("#login-email")); + $("#login-pass").focus(); + } else { + toast((err && err.message) || "登录失败,请稍后重试", "error"); + } + } finally { + if (btn) { btn.disabled = false; btn.textContent = "登 录"; } + } }); $("#logout-btn").addEventListener("click", () => { + api.clearToken(); exitGuest(); - toast("已退出(静态演示)", "info"); + toast("已退出登录", "info"); }); // 市场筛选(搜索防抖 ~150ms + 高亮 + 清空按钮,rant 18:06:09 D) @@ -2059,6 +2117,18 @@ renderView("dashboard"); $("#side-balance").textContent = D.fmt(D.USER.balance); + // P2-A 会话恢复:已有 token → 拉 /api/me + /api/wallet 直接进 app;401 自动清 token 回登录页 + (async () => { + if (!api.getToken()) return; + try { + await loadSession(); + enterApp(); + } catch (e) { + // 401 已由 api.js 清 token;其余错误保持登录页并提示 + if (!(e && e.status === 401)) toast("会话恢复失败,请重新登录", "error"); + } + })(); + // 主题(rant 18:06:09 B):localStorage 记忆,首次加载尊重 prefers-color-scheme const savedTheme = (() => { try { return localStorage.getItem("atp-theme"); } catch (e) { return null; } })(); const initialTheme = savedTheme === "light" || savedTheme === "dark" @@ -2110,7 +2180,7 @@ } if (e.key >= "1" && e.key <= "7") { const item = NAV_ORDER[Number(e.key) - 1]; - if (item) switchView(item.id); + if (item && (!item.role || D.USER.role === item.role)) switchView(item.id); } }); $("#help-close").addEventListener("click", () => toggleHelp(false)); diff --git a/ui/js/data.js b/ui/js/data.js index 559badb..426756d 100644 --- a/ui/js/data.js +++ b/ui/js/data.js @@ -71,8 +71,8 @@ deepseek: "DeepSeek", }, - // 当前用户(公共版) - USER: { name: "阿零", email: "demo@aitokenpool.local", balance: 12471 }, + // 当前用户(公共版;P2-A 登录后由 /api/me 覆盖 name/email/role、/api/wallet 覆盖 balance) + USER: { name: "阿零", email: "demo@aitokenpool.local", role: "user", balance: 12471 }, // 加额申请(US-20:企业成员申请 → 管理员批准 / 驳回;mock 内嵌数据) RAISE_REQUESTS: [