Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aitokenpool"
version = "0.3.0"
version = "0.3.1"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down
59 changes: 58 additions & 1 deletion src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,33 @@ pub async fn login(
})))
}

/// 组装路由
/// GET /api/me:当前登录用户信息(P2-A 前端会话)→ {id, email, name, role}
pub async fn me(
State(st): State<AppState>,
auth: AuthUser,
) -> Result<Json<serde_json::Value>, 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<AppState> {
Router::new()
.route("/healthz", get(healthz))
.route("/api/auth/login", post(login))
.route("/api/me", get(me))
.route("/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))
Expand All @@ -158,6 +180,8 @@ pub fn router() -> Router<AppState> {
.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)]
Expand Down Expand Up @@ -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("<!DOCTYPE html>") || body.contains("<html"),
"返回内容应为 HTML: {}",
&body[..body.len().min(80)]
);
let (s, _) = get(st, "/css/style.css", None).await;
assert_eq!(s, StatusCode::OK, "静态资源应 200");
}

#[tokio::test]
async fn me_returns_user_info_and_role() {
let st = test_state("me");
let demo_bearer = login_bearer(&st, "demo@aitokenpool.local", "demo1234").await;
let (s, body) = get(st.clone(), "/api/me", Some(&demo_bearer)).await;
assert_eq!(s, StatusCode::OK, "me 应 200: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["email"], "demo@aitokenpool.local");
assert_eq!(v["role"], "user");
assert!(v["name"].as_str().is_some(), "含 name: {body}");
// admin 登录 → role=admin
let admin_bearer = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await;
let (s, body) = get(st, "/api/me", Some(&admin_bearer)).await;
assert_eq!(s, StatusCode::OK, "admin me 应 200: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["role"], "admin");
}
}
5 changes: 3 additions & 2 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ <h1>AITokenPool</h1>
</label>
<div class="login-remember">
<label class="checkbox-line"><input type="checkbox" id="login-remember"> <span>记住我</span></label>
<span class="demo-hint">演示账号:demo@aitokenpool.local / demo1234</span>
<span class="demo-hint">演示账号:demo@aitokenpool.local / demo1234 · 管理员 admin@aitokenpool.local / admin1234</span>
</div>
<button type="submit" class="btn btn-primary btn-block">进入平台 <span class="en">(Enter)</span></button>
</form>
<div class="login-divider"><span>或</span></div>
<button type="button" class="btn btn-ghost btn-block" id="guest-browse-btn">先逛逛市场 <span class="en">(Guest browse)</span></button>
<p class="login-foot">还没有账号?<a href="#">注册 <span class="en">Register</span></a> · <a href="#">找回密码</a></p>
<p class="login-tip">静态演示原型 · 数据为内嵌 mock,无真实后端 · 管理员与普通用户由账号角色决定登录后看到的界面</p> </div>
<p class="login-tip">已对接后端 API · 登录后进入真实数据界面 · 管理员账号登录可见「管理视图」</p> </div>
</div>

<!-- ======================= 应用外壳 ======================= -->
Expand Down Expand Up @@ -528,6 +528,7 @@ <h3 id="chat-title">使用模型</h3>
</div>
</div>

<script src="js/api.js"></script>
<script src="js/data.js"></script>
<script src="js/app.js"></script>
</body>
Expand Down
97 changes: 97 additions & 0 deletions ui/js/api.js
Original file line number Diff line number Diff line change
@@ -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,
};
})();
100 changes: 85 additions & 15 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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("/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("/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;
Expand All @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions ui/js/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading