diff --git a/desktop/build.rs b/desktop/build.rs index 06db1217..8c253940 100644 --- a/desktop/build.rs +++ b/desktop/build.rs @@ -81,6 +81,8 @@ fn main() { "mc_login", "mc_password_login", "mc_logout", + "mc_usage", + "mc_checkin", "mc_models_sync", "mc_models_revoke", "mc_tasks", diff --git a/desktop/src/baizhi/mod.rs b/desktop/src/baizhi/mod.rs index d827fd01..932624f3 100644 --- a/desktop/src/baizhi/mod.rs +++ b/desktop/src/baizhi/mod.rs @@ -615,6 +615,19 @@ pub async fn mc_logout(bz: State<'_, BaizhiState>) -> Result { Ok(json!({ "ok": true })) } +/// 账号权益(额度/会员/签到态/邀请一次取回;单路缺席按 null 降级,见 mc_usage)。 +#[tauri::command] +pub async fn mc_usage(bz: State<'_, BaizhiState>) -> Result { + monkeycode::mc_usage(&bz.0).await.map_err(BzErr::msg) +} + +/// 每日签到(壳内自动完成 PoW 验证码)。成功后 UI 重拉 mc_usage 刷新余额。 +#[tauri::command] +pub async fn mc_checkin(bz: State<'_, BaizhiState>) -> Result { + monkeycode::mc_checkin(&bz.0).await.map_err(BzErr::msg)?; + Ok(json!({ "ok": true })) +} + // ==================== 会员模型本地同步 ==================== pub(crate) const OHMYAGENT_KEY_FILE: &str = "monkeycode-ohmyagent-key.json"; diff --git a/desktop/src/baizhi/monkeycode.rs b/desktop/src/baizhi/monkeycode.rs index 6a593657..e5c3035d 100644 --- a/desktop/src/baizhi/monkeycode.rs +++ b/desktop/src/baizhi/monkeycode.rs @@ -185,6 +185,65 @@ pub fn mc_host(svc: &Service) -> String { .unwrap_or_else(|| svc.ep.monkeycode.clone()) } +/// 钱包(积分余额 + 每日免费模型 token 额度)。官方云才有这个端点, +/// 私有化部署会 404。 +async fn mc_wallet(svc: &Service) -> BzResult { + mc_call(svc, reqwest::Method::GET, "/api/v1/users/wallet", None).await +} + +/// 会员订阅(等级/到期/续费来源)。开源版后端固定返回基础状态。 +async fn mc_subscription(svc: &Service) -> BzResult { + mc_call(svc, reqwest::Method::GET, "/api/v1/users/subscription", None).await +} + +/// 当天是否已签到。 +async fn mc_checkin_status(svc: &Service) -> BzResult { + mc_call(svc, reqwest::Method::GET, "/api/v1/users/wallet/checkin", None).await +} + +/// 邀请记录({count, items})。头像地址可能是相对路径,由 UI 按 base_url 补全。 +async fn mc_invitations(svc: &Service) -> BzResult { + mc_call(svc, reqwest::Method::GET, "/api/v1/users/invitations?page=1&size=50", None).await +} + +/// 账号权益总览:额度、会员、签到态、邀请记录并发取回。单路失败按缺省 +/// (null)降级——私有化部署只有订阅端点,其余都 404,此时仍要能看到会员 +/// 等级。全部失败才报错(会话失效/网络不通这类真故障)。 +/// base_url 一并回传:邀请链接和相对头像地址都要以它为解析基准,UI 自己 +/// 按主机名拼 https:// 会在自建 http/带端口部署上拼错。 +pub async fn mc_usage(svc: &Service) -> BzResult { + let (wallet, subscription, checkin, invitations) = tokio::join!( + mc_wallet(svc), + mc_subscription(svc), + mc_checkin_status(svc), + mc_invitations(svc) + ); + if wallet.is_err() && subscription.is_err() && checkin.is_err() && invitations.is_err() { + return Err(wallet.unwrap_err()); + } + Ok(json!({ + "base_url": svc.ep.monkeycode, + "wallet": wallet.unwrap_or(Value::Null), + "subscription": subscription.unwrap_or(Value::Null), + // 取不到时给 null,与"确定没签到"(false)区分——否则会误催已签到的用户 + "checked_in": checkin.ok().and_then(|v| v.get("checked_in").and_then(Value::as_bool)), + "invitations": invitations.unwrap_or(Value::Null), + })) +} + +/// 每日签到(每天 1 次;与账密登录同一套 MonkeyCode 域 PoW 验证码)。 +/// 重复签到等业务失败由服务端包壳原样透传。 +pub async fn mc_checkin(svc: &Service) -> BzResult { + let captcha = svc.captcha_token_at(&svc.ep.monkeycode, &svc.mc, "MonkeyCode ").await?; + mc_call( + svc, + reqwest::Method::POST, + "/api/v1/users/wallet/checkin", + Some(&json!({ "captcha_token": captcha })), + ) + .await +} + /// 云端任务列表({tasks, page_info} 原样透传 UI)。project_id / quick_start /// 与 Web 侧栏筛选一致:项目内任务、未关联项目的快速任务分别查询。 pub async fn mc_tasks( diff --git a/desktop/src/baizhi/tests.rs b/desktop/src/baizhi/tests.rs index 99cead06..8294c88c 100644 --- a/desktop/src/baizhi/tests.rs +++ b/desktop/src/baizhi/tests.rs @@ -780,6 +780,104 @@ async fn cloud_sidebar_and_task_actions_contract() { assert!(requests.iter().any(|(method, path, _)| method == "DELETE" && path == "/api/v1/users/tasks/t1")); } +/// 账号权益契约:钱包/订阅/签到态/邀请四路并发取回,单路缺席(私有化部署 +/// 只有订阅端点,其余 404)按 null 降级而不牵连其余;全部失败才整体报错。 +/// 签到态取不到时是 null 而非 false——否则会误催已签到的用户。 +#[tokio::test(flavor = "multi_thread")] +async fn mc_usage_contract() { + let saas = Arc::new(AtomicBool::new(true)); // false = 只留订阅端点的自建部署 + let sub_ok = Arc::new(AtomicBool::new(true)); + let (o, s) = (saas.clone(), sub_ok.clone()); + let (url, _stop) = serve(Arc::new(move |req: Req| match req.path.split('?').next().unwrap() { + "/api/v1/users/wallet" if o.load(Ordering::Relaxed) => Resp::json( + 200, + json!({ "code": 0, "data": { "balance": 12_345_678, "daily_token_balance": 400_000, "daily_token_limit": 1_000_000 } }), + ), + "/api/v1/users/wallet/checkin" if o.load(Ordering::Relaxed) => { + Resp::json(200, json!({ "code": 0, "data": { "checked_in": true } })) + } + "/api/v1/users/invitations" if o.load(Ordering::Relaxed) => Resp::json( + 200, + json!({ "code": 0, "data": { "count": 2, "items": [{ "id": "u2", "name": "阿茂" }] } }), + ), + "/api/v1/users/subscription" if s.load(Ordering::Relaxed) => { + Resp::json(200, json!({ "code": 0, "data": { "plan": "pro", "auto_renew": false } })) + } + _ => Resp::json(404, json!({ "code": 404, "message": "not found" })), + })); + let svc = Service::test_service(Endpoints { + account: url.clone(), + model_gateway: url.clone(), + mcp_gateway: url.clone(), + monkeycode: url.clone(), + }); + + let usage = super::monkeycode::mc_usage(&svc).await.map_err(|e| e.msg()).unwrap(); + assert_eq!(usage.pointer("/wallet/balance").and_then(Value::as_i64), Some(12_345_678)); + assert_eq!(usage.pointer("/subscription/plan").and_then(Value::as_str), Some("pro")); + assert_eq!(usage.get("checked_in").and_then(Value::as_bool), Some(true)); + assert_eq!(usage.pointer("/invitations/count").and_then(Value::as_i64), Some(2)); + // 邀请链接与相对头像地址的解析基准,必须是完整基址(含协议/端口) + assert_eq!(usage.get("base_url").and_then(Value::as_str), Some(url.as_str())); + + // 私有化部署:只剩订阅端点,会员等级照常可见,其余按 null 降级 + saas.store(false, Ordering::Relaxed); + let usage = super::monkeycode::mc_usage(&svc).await.map_err(|e| e.msg()).unwrap(); + assert!(usage.get("wallet").unwrap().is_null()); + assert!(usage.get("checked_in").unwrap().is_null(), "签到态取不到时不能退化成 false"); + assert!(usage.get("invitations").unwrap().is_null()); + assert_eq!(usage.pointer("/subscription/plan").and_then(Value::as_str), Some("pro")); + + // 四路都不可用 = 真故障,如实报错 + sub_ok.store(false, Ordering::Relaxed); + assert!(super::monkeycode::mc_usage(&svc).await.is_err()); +} + +/// 签到契约:壳内先取 MonkeyCode 域 PoW 验证码,再带 captcha_token POST +/// 签到端点(与账密登录同一套验证码,全程不碰百智罐)。PoW 求解本身由 +/// monkeycode_password_login_contract 按协议校验,这里只盯签到这一跳。 +#[tokio::test(flavor = "multi_thread")] +async fn mc_checkin_contract() { + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap = captured.clone(); + let (url, _stop) = serve(Arc::new(move |req: Req| { + let body = body_json(&req.body); + cap.lock().unwrap().push((req.method.clone(), req.path.clone(), body.clone())); + match (req.method.as_str(), req.path.split('?').next().unwrap()) { + // go-cap:201 + 裸结构(不套 {code,data} 包壳) + ("POST", "/api/v1/public/captcha/challenge") => { + Resp::json(201, json!({ "challenge": { "c": 1, "s": 32, "d": 1 }, "token": "mc-chtok" })) + } + ("POST", "/api/v1/public/captcha/redeem") => { + Resp::json(201, json!({ "success": true, "token": "mc-captoken" })) + } + ("POST", "/api/v1/users/wallet/checkin") => { + if body.get("captcha_token").and_then(Value::as_str) != Some("mc-captoken") { + return Resp::json(403, json!({ "code": 403, "message": "禁止访问" })); + } + Resp::json(200, json!({ "code": 0, "data": { "credits": 100 } })) + } + _ => Resp::json(404, json!({ "code": 404, "message": "not found" })), + } + })); + let svc = Service::test_service(Endpoints { + account: url.clone(), + model_gateway: url.clone(), + mcp_gateway: url.clone(), + monkeycode: url, + }); + + super::monkeycode::mc_checkin(&svc).await.map_err(|e| e.msg()).unwrap(); + + let requests = captured.lock().unwrap(); + assert!(requests.iter().any(|(m, p, _)| m == "POST" && p == "/api/v1/public/captcha/challenge")); + assert!(requests + .iter() + .any(|(m, p, b)| m == "POST" && p == "/api/v1/users/wallet/checkin" && b.get("captcha_token").is_some())); + // 百智罐不该被这条链路碰到(双罐隔离) + assert!(svc.store.is_empty()); +} + // ==================== 会员模型本地同步 ==================== /// 会员模型同步/删除契约(对齐服务端 swagger【用户】OhMyAgent 分组): diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 78ef38bd..c19118ce 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -1207,6 +1207,8 @@ fn main() { baizhi::mc_login, baizhi::mc_password_login, baizhi::mc_logout, + baizhi::mc_usage, + baizhi::mc_checkin, baizhi::mc_models_sync, baizhi::mc_models_revoke, baizhi::mc_tasks, diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index e1c65260..ddcd102d 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -85,6 +85,8 @@ "allow-mc-login", "allow-mc-password-login", "allow-mc-logout", + "allow-mc-usage", + "allow-mc-checkin", "allow-mc-models-sync", "allow-mc-models-revoke", "allow-mc-tasks", diff --git a/desktop/ui/src/cloudapi.ts b/desktop/ui/src/cloudapi.ts index 0042c42a..1421faa2 100644 --- a/desktop/ui/src/cloudapi.ts +++ b/desktop/ui/src/cloudapi.ts @@ -4,7 +4,7 @@ import { b64encode, frameData } from "./codec"; import type { McTaskOptions } from "./cloud"; import { invoke, listenAsync } from "./ipc"; -import type { CloudAttachment, CloudProjectsResp, CloudTaskDetail, CloudTasksResp, Frame, McModelsSyncResult, McStatus, McUser, WsCloseInfo } from "./types"; +import type { CloudAttachment, CloudProjectsResp, CloudTaskDetail, CloudTasksResp, Frame, McModelsSyncResult, McStatus, McUsage, McUser, WsCloseInfo } from "./types"; // ==================== 云端 REST(壳命令代理) ==================== @@ -21,6 +21,12 @@ export const mcPasswordLogin = (email: string, password: string) => export const mcLogout = () => invoke<{ ok: boolean }>("mc_logout"); +/** 账号权益(额度/会员/签到态/邀请)。壳侧各路并发且各自容错,全失败才 reject。 */ +export const mcUsage = () => invoke("mc_usage"); + +/** 每日签到(壳内自动完成 PoW 验证码)。成功后调用方重拉 mcUsage 刷新余额。 */ +export const mcCheckin = () => invoke<{ ok: boolean }>("mc_checkin"); + /** 同步会员内置模型为本地条目(不碰配置,返回值经设置表单落盘)。 */ export const mcModelsSync = () => invoke("mc_models_sync"); diff --git a/desktop/ui/src/icons.tsx b/desktop/ui/src/icons.tsx index 1077ad1e..d9e74bd3 100644 --- a/desktop/ui/src/icons.tsx +++ b/desktop/ui/src/icons.tsx @@ -395,3 +395,40 @@ export function IconBack({ size = 10, color = "var(--t1)", style }: IconProps) { ); } + +// 以下三个与移动端 Icons(24 视框、1.7 描边)同路径,保证两端同一形状。 + +/** 皇冠(会员等级) */ +export function IconCrown({ size = 13, color = "var(--accTx)", style }: IconProps) { + return ( + + + + ); +} + +/** 日历(每日签到) */ +export function IconCalendar({ size = 13, color = "var(--t3)", style }: IconProps) { + return ( + + + + + ); +} + +/** 复制(叠放的两个方框) */ +export function IconCopy({ size = 12, color = "var(--t3)", style }: IconProps) { + return ( + + + + + ); +} diff --git a/desktop/ui/src/mcUsagePanel.test.tsx b/desktop/ui/src/mcUsagePanel.test.tsx new file mode 100644 index 00000000..17317933 --- /dev/null +++ b/desktop/ui/src/mcUsagePanel.test.tsx @@ -0,0 +1,85 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { McUsagePanel } from "./settings"; +import type { McUsage } from "./types"; + +const usage = (u: Partial = {}): McUsage => ({ + base_url: "https://monkeycode-ai.com", + wallet: null, + subscription: null, + checked_in: null, + invitations: null, + ...u, +}); +const noCheckin = async () => null; + +/** 账号权益块:桌面端此前完全看不到自己的额度(只有移动端「我的」页有), + * 已关联的用户要能看到会员等级、今日 token 余量、积分、签到与邀请。 */ +describe("McUsagePanel", () => { + it("渲染会员等级、今日额度与积分余额", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("专业会员"); + expect(html).toContain("有效期至 2026-08-31"); + expect(html).toContain("剩余 400,000 / 1.0M"); + expect(html).toContain("12,345"); + }); + + it("还没拉到数据时整块不占位(空进度条会被读成额度为 0)", () => { + expect(renderToStaticMarkup()).toBe(""); + expect(renderToStaticMarkup()).toBe(""); + }); + + it("私有化部署没有钱包端点时只显示会员等级,不出现额度/签到/邀请", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("基础会员"); + expect(html).not.toContain("今日额度"); + expect(html).not.toContain("积分余额"); + expect(html).not.toContain("签到"); + expect(html).not.toContain("已邀请"); + }); + + it("未签到给可点的签到入口,已签到转低调态", () => { + const todo = renderToStaticMarkup(); + expect(todo).toContain("签到 +100"); + expect(todo).not.toContain("今日已签到"); + + const done = renderToStaticMarkup(); + expect(done).toContain("今日已签到"); + expect(done).not.toContain("签到 +100"); + }); + + it("邀请行给出人数、奖励与复制入口", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("已邀请 3 人"); + expect(html).toContain("每邀请一位 +5,000 积分"); + expect(html).toContain("复制邀请链接"); + expect(html).toContain("https://monkeycode-ai.com/?ic=me-1"); + }); + + it("拿不到账号 id 时不给复制入口(链接拼不出来)", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("已邀请 3 人"); + expect(html).not.toContain("复制邀请链接"); + }); +}); diff --git a/desktop/ui/src/mcaccount.ts b/desktop/ui/src/mcaccount.ts index 775e0a6b..f0b817a8 100644 --- a/desktop/ui/src/mcaccount.ts +++ b/desktop/ui/src/mcaccount.ts @@ -1,5 +1,7 @@ // MonkeyCode 账号探测只读取既有会话和任务,刻意不接收登录函数。 // 因此启动、聚焦和定时刷新都不可能隐式用百智云账号创建 MonkeyCode 会话。 +// 账号权益(额度/签到/邀请)不在这里:它只在设置页可见,挂进这条 30 秒 +// 轮询等于为一块看不见的面板长期空跑请求,改由设置页挂载时自取。 import { mcProjects, mcStatus, mcTasks } from "./cloudapi"; import type { CloudProject, CloudProjectsResp, CloudTask, CloudTasksResp, McStatus } from "./types"; diff --git a/desktop/ui/src/mcusage.test.ts b/desktop/ui/src/mcusage.test.ts new file mode 100644 index 00000000..910ec94c --- /dev/null +++ b/desktop/ui/src/mcusage.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { fmtTokens, planLabel, resolveAssetUrl, usageView } from "./mcusage"; +import type { McUsage } from "./types"; + +/** 各路默认缺席,按用例补齐——壳侧本就允许单路缺席。 */ +const usage = (u: Partial = {}): McUsage => ({ + base_url: "https://monkeycode-ai.com", + wallet: null, + subscription: null, + checked_in: null, + invitations: null, + ...u, +}); + +describe("planLabel", () => { + it("flagship 是 ultra 的服务端别名", () => { + expect(planLabel("ultra")).toBe("旗舰会员"); + expect(planLabel("flagship")).toBe("旗舰会员"); + expect(planLabel("pro")).toBe("专业会员"); + expect(planLabel(undefined)).toBe("基础会员"); + }); +}); + +describe("fmtTokens", () => { + it("百万以上缩写为 M,其余千分位", () => { + expect(fmtTokens(2_500_000)).toBe("2.5M"); + expect(fmtTokens(999_999)).toBe("999,999"); + expect(fmtTokens(0)).toBe("0"); + }); +}); + +describe("resolveAssetUrl", () => { + it("相对地址按云端基址补全,绝对地址原样", () => { + expect(resolveAssetUrl("https://mc.io", "/static/a.png")).toBe("https://mc.io/static/a.png"); + expect(resolveAssetUrl("https://mc.io", "static/a.png")).toBe("https://mc.io/static/a.png"); + expect(resolveAssetUrl("https://mc.io", "https://cdn.io/a.png")).toBe("https://cdn.io/a.png"); + expect(resolveAssetUrl("https://mc.io", "//cdn.io/a.png")).toBe("//cdn.io/a.png"); + expect(resolveAssetUrl("", "/static/a.png")).toBe(""); + expect(resolveAssetUrl("https://mc.io", undefined)).toBe(""); + }); +}); + +describe("usageView", () => { + it("一路都没有时不渲染(空进度条会被读成额度为 0)", () => { + expect(usageView(null)).toBeNull(); + expect(usageView(usage())).toBeNull(); + }); + + it("积分按 /1000 折算,今日额度给出剩余比例", () => { + const v = usageView( + usage({ + wallet: { balance: 12_345_678, daily_token_balance: 400_000, daily_token_limit: 1_000_000 }, + subscription: { plan: "pro", expires_at: "2026-08-31T00:00:00Z" }, + }), + ); + expect(v?.planText).toBe("专业会员"); + expect(v?.expiryText).toBe("有效期至 2026-08-31"); + expect(v?.credits).toBe("12,345"); + expect(v?.quota).toMatchObject({ remaining: 400_000, total: 1_000_000, text: "剩余 400,000 / 1.0M", ratio: 0.4 }); + }); + + it("基础档不显示到期日(服务端不给,给了也不代表会降级)", () => { + const v = usageView(usage({ subscription: { plan: "basic", expires_at: "2026-08-31T00:00:00Z" } })); + expect(v?.expiryText).toBe("长期有效"); + expect(v?.credits).toBeNull(); + expect(v?.quota).toBeNull(); + }); + + it("没有免费额度档位时标注无额度,不做除零", () => { + const v = usageView(usage({ wallet: { balance: 0, daily_token_balance: 0, daily_token_limit: 0 } })); + expect(v?.quota).toMatchObject({ text: "无额度", ratio: 0 }); + expect(v?.expiryText).toBe(""); + }); + + it("剩余超过上限时按上限收口(服务端口径漂移不该撑爆进度条)", () => { + const v = usageView(usage({ wallet: { balance: 0, daily_token_balance: 5_000, daily_token_limit: 1_000 } })); + expect(v?.quota).toMatchObject({ remaining: 1_000, ratio: 1 }); + }); + + it("私有化部署只有订阅端点时仍展示会员等级", () => { + const v = usageView(usage({ subscription: { plan: "pro" } })); + expect(v?.planText).toBe("专业会员"); + expect(v?.credits).toBeNull(); + expect(v?.checkedIn).toBeNull(); + expect(v?.invite).toBeNull(); + }); + + it("签到态原样透传三态,取不到时保持 null(不退化成未签到)", () => { + expect(usageView(usage({ checked_in: true }))?.checkedIn).toBe(true); + expect(usageView(usage({ checked_in: false }))?.checkedIn).toBe(false); + expect(usageView(usage({ subscription: { plan: "pro" } }))?.checkedIn).toBeNull(); + }); + + it("邀请:头像最多 4 个、相对地址补全、缺图退首字母,链接带账号 id", () => { + const v = usageView( + usage({ + invitations: { + count: 7, + items: [ + { id: "u1", name: "阿茂", avatar_url: "/avatars/1.png" }, + { id: "u2", name: "bob", avatar_url: "https://cdn.io/2.png" }, + { id: "u3" }, + { id: "u4", name: "dan" }, + { id: "u5", name: "eve" }, + ], + }, + }), + "me-1", + ); + expect(v?.invite?.count).toBe(7); + expect(v?.invite?.avatars).toHaveLength(4); + expect(v?.invite?.avatars[0]).toMatchObject({ url: "https://monkeycode-ai.com/avatars/1.png", initial: "阿" }); + expect(v?.invite?.avatars[1]).toMatchObject({ url: "https://cdn.io/2.png", initial: "B" }); + expect(v?.invite?.avatars[2]).toMatchObject({ url: "", initial: "?" }); + expect(v?.invite?.link).toBe("https://monkeycode-ai.com/?ic=me-1"); + }); + + it("count 缺省时退回条目数;基址或账号 id 缺一就不给邀请链接", () => { + const items = [{ id: "u1", name: "阿茂" }]; + expect(usageView(usage({ invitations: { items } }), "me-1")?.invite?.count).toBe(1); + expect(usageView(usage({ invitations: { items } }))?.invite?.link).toBe(""); + expect(usageView(usage({ base_url: "", invitations: { items } }), "me-1")?.invite?.link).toBe(""); + }); + + it("基址尾部斜杠不会拼出双斜杠", () => { + const v = usageView( + usage({ base_url: "https://mc.io/", invitations: { count: 1, items: [{ id: "u1", avatar_url: "/a.png" }] } }), + "me-1", + ); + expect(v?.invite?.link).toBe("https://mc.io/?ic=me-1"); + expect(v?.invite?.avatars[0].url).toBe("https://mc.io/a.png"); + }); +}); diff --git a/desktop/ui/src/mcusage.ts b/desktop/ui/src/mcusage.ts new file mode 100644 index 00000000..208865c4 --- /dev/null +++ b/desktop/ui/src/mcusage.ts @@ -0,0 +1,110 @@ +// 账号权益的展示口径(与移动端 app/(tabs)/profile.tsx 对齐):等级文案、 +// token 缩写、今日额度、签到与邀请。纯函数,渲染在 settings.tsx 的 +// MonkeyCode 账号卡。 +import type { McUsage } from "./types"; + +/** 奖励数额与移动端文案同源(服务端不下发,两端各自硬编码;改版要一起改)。 */ +export const CHECKIN_REWARD = 100; +export const INVITE_REWARD = 5000; + +/** 头像堆叠最多展示几个(与移动端一致)。 */ +const AVATAR_LIMIT = 4; + +/** 会员等级文案。flagship 是 ultra 的服务端别名(移动端同款归一)。 */ +export function planLabel(plan?: string): string { + if (plan === "ultra" || plan === "flagship") return "旗舰会员"; + if (plan === "pro") return "专业会员"; + return "基础会员"; +} + +/** token 数缩写:百万以上取一位小数的 M,否则千分位。 */ +export function fmtTokens(v: number): string { + if (v >= 1_000_000) return `${(Math.floor(v / 100_000) / 10).toFixed(1)}M`; + return v.toLocaleString("zh-CN"); +} + +/** 相对资源地址按云端基址补全(移动端 resolveAssetUrl 的等价物)。 */ +export function resolveAssetUrl(base: string, url?: string): string { + const u = (url || "").trim(); + if (!u) return ""; + if (/^(https?:)?\/\//i.test(u) || u.startsWith("data:")) return u; + if (!base) return ""; + return u.startsWith("/") ? `${base}${u}` : `${base}/${u}`; +} + +export interface UsageAvatar { + key: string; + /** 补全后的头像地址;空串 = 用首字母兜底 */ + url: string; + /** 首字母(头像缺失或加载失败时展示) */ + initial: string; +} + +/** 卡片要渲染的一切;null = 无可展示内容(整块不占位)。 */ +export interface UsageView { + planText: string; + /** "有效期至 2026-01-01" / "长期有效";订阅缺席时为空串(不猜) */ + expiryText: string; + /** 积分余额(已 /1000 并千分位);钱包缺席时 null */ + credits: string | null; + /** 今日免费模型额度;钱包缺席时 null */ + quota: { total: number; remaining: number; text: string; ratio: number } | null; + /** 当天是否已签到;null = 没取到,签到入口整个不出现(不催、也不误报已签) */ + checkedIn: boolean | null; + /** 邀请概况;邀请端点缺席时 null */ + invite: { count: number; avatars: UsageAvatar[]; link: string } | null; +} + +const clamp = (v: number, total: number) => Math.min(Math.max(v, 0), total); + +/** userId 来自 mc_status 的云端账号,用于拼邀请链接;缺失时 link 为空串。 */ +export function usageView(usage: McUsage | null | undefined, userId?: string): UsageView | null { + if (!usage) return null; + const { wallet, subscription, invitations } = usage; + const checkedIn = usage.checked_in ?? null; + if (!wallet && !subscription && !invitations && checkedIn === null) return null; + + const plan = subscription?.plan; + // 到期日只对付费档有意义:基础档服务端不给 expires_at,给了也不代表会降级 + const paid = plan === "pro" || plan === "ultra" || plan === "flagship"; + const expiry = paid && subscription?.expires_at ? subscription.expires_at.slice(0, 10) : ""; + + let credits: string | null = null; + let quota: UsageView["quota"] = null; + if (wallet) { + credits = Math.floor((wallet.balance ?? 0) / 1000).toLocaleString("zh-CN"); + const total = Math.max(wallet.daily_token_limit ?? 0, 0); + // 上限为 0 = 该账号没有免费额度档位,此时余额字段不具备"剩余/总量"语义 + const remaining = total > 0 ? clamp(wallet.daily_token_balance ?? 0, total) : Math.max(wallet.daily_token_balance ?? 0, 0); + quota = { + total, + remaining, + text: total > 0 ? `剩余 ${fmtTokens(remaining)} / ${fmtTokens(total)}` : "无额度", + ratio: total > 0 ? remaining / total : 0, + }; + } + + const base = (usage.base_url || "").replace(/\/+$/, ""); + const items = invitations?.items ?? []; + const invite = invitations + ? { + count: invitations.count ?? items.length, + avatars: items.slice(0, AVATAR_LIMIT).map((it, i) => ({ + key: it.id || `invitee-${i}`, + url: resolveAssetUrl(base, it.avatar_url), + initial: (it.name || "?").trim().charAt(0).toUpperCase() || "?", + })), + // 与移动端同款邀请链接;基址或账号 id 缺一不可,拼不出就不给入口 + link: base && userId ? `${base}/?ic=${userId}` : "", + } + : null; + + return { + planText: planLabel(plan), + expiryText: subscription ? (expiry ? `有效期至 ${expiry}` : "长期有效") : "", + credits, + quota, + checkedIn, + invite, + }; +} diff --git a/desktop/ui/src/settings.test.ts b/desktop/ui/src/settings.test.ts index 137cdf67..5f1a270b 100644 --- a/desktop/ui/src/settings.test.ts +++ b/desktop/ui/src/settings.test.ts @@ -7,6 +7,7 @@ import { replaceSourceGroup, sortModelsBySource, syncedName, + syncResultTail, validateMcpNames, } from "./settingsConfig"; import { SOURCE_BAIZHI, SOURCE_MONKEYCODE, type HostModel } from "./types"; @@ -201,3 +202,31 @@ describe("MCP name validation", () => { ]); }); }); + +/** 回归:同步成功会当场把分区切到模型页,账号卡随之退出视野。此前消息里 + * 写着「正在保存并重启内核…」,那句话在卡里根本来不及被看到,却会在用户 + * 下次点回账号时以早已过期的状态出现(保存那会儿就结束了)。 */ +describe("syncResultTail", () => { + it("自动保存不写进行时,纯成功消息离开分区即作废", () => { + expect(syncResultTail({ autoSaved: true, hasNotes: false })).toEqual({ tail: "", transient: true }); + }); + + it("带附加说明(跳过名单/内核诊断)的留着:切回账号时那些话依然成立", () => { + expect(syncResultTail({ autoSaved: true, hasNotes: true })).toEqual({ tail: "", transient: false }); + }); + + it("需要用户动手的收尾语原样保留,且不作废", () => { + expect(syncResultTail({ autoSaved: false, blocked: "busy", hasNotes: false })).toEqual({ + tail: "有任务正在执行,空闲后请手动保存(保存会重启内核)", + transient: false, + }); + expect(syncResultTail({ autoSaved: false, blocked: "dirty", hasNotes: true })).toEqual({ + tail: "表单有未保存的修改,请核对后手动保存", + transient: false, + }); + expect(syncResultTail({ autoSaved: false, hasNotes: false })).toEqual({ + tail: "已切到模型页,核对后保存", + transient: false, + }); + }); +}); diff --git a/desktop/ui/src/settings.tsx b/desktop/ui/src/settings.tsx index cf2adca5..8a727b86 100644 --- a/desktop/ui/src/settings.tsx +++ b/desktop/ui/src/settings.tsx @@ -4,7 +4,7 @@ // 配置所有权在壳(写盘 0600/env 注入/重启内核),本视图只负责渲染与编辑, // 经 Tauri IPC get_config/save_config 读写;保存成功后壳会重启内核并把 // 整个页面导航到新内核 URL(本组件随之卸载)。 -import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { BaizhiCard } from "./baizhi"; import { baizhiStatus, baizhiSync } from "./baizhiapi"; import { @@ -26,11 +26,12 @@ import { } from "./host"; import { engineCaps } from "./session"; import { MONO } from "./components"; -import { IconBack, IconGear, IconGlobe, IconMonitor, IconPlus, IconSpark } from "./icons"; +import { IconBack, IconCalendar, IconCheck, IconCopy, IconCrown, IconGear, IconGlobe, IconMonitor, IconPlus, IconSpark } from "./icons"; +import { copyText } from "./markdown"; import { BaizhiLogo } from "./baizhi"; import logoUrl from "./logo.png"; import { Field, Section, input, select, whiteBtn } from "./settings-ui"; -import { mcModelsRevoke, mcModelsSync } from "./cloudapi"; +import { mcCheckin, mcModelsRevoke, mcModelsSync, mcUsage as fetchMcUsage } from "./cloudapi"; import { memberCategory, sameModelName, stripSourceSuffix, stripTierPrefix } from "./modelMenu"; import { dedupeModelsByName, @@ -42,8 +43,10 @@ import { serversToMcps, sortModelsBySource, syncedName, + syncResultTail, validateMcpNames, type McpEntry, + type SyncMsg, } from "./settingsConfig"; import { readAccent, readTheme, setAccent, setTheme, type AccentKey, type Theme } from "./theme"; import { ACCENTS } from "./gen/accents"; @@ -60,9 +63,11 @@ import { type EngineCaps, type HostModel, type McConnectionState, + type McUsage, type SyncApplyResult, type UpdateStatus, } from "./types"; +import { CHECKIN_REWARD, INVITE_REWARD, usageView, type UsageAvatar } from "./mcusage"; // ---- 关于卡(版本 + 检查更新;仅桌面壳) ---- @@ -368,11 +373,209 @@ function mcIdentity(s: McConnectionState): string { return u?.name || u?.username || u?.email || u?.id || "MonkeyCode 用户"; } +/** 邀请人头像堆叠(缺图/加载失败退回首字母)。 */ +function InviteeStack({ avatars }: { avatars: UsageAvatar[] }) { + const [broken, setBroken] = useState>({}); + if (avatars.length === 0) return null; + return ( + + {avatars.map((a, i) => ( + + {a.url && !broken[a.key] ? ( + setBroken((cur) => ({ ...cur, [a.key]: true }))} + style={{ width: "100%", height: "100%", objectFit: "cover" }} + /> + ) : ( + a.initial + )} + + ))} + + ); +} + +/** 签到按钮三态:已签到=低调 chip,可签到=主按钮,进行中=禁用。 + * checkedIn 为 null(没取到签到态)时调用方整个不渲染本组件。 */ +function CheckinButton({ checkedIn, busy, onClick }: { checkedIn: boolean; busy: boolean; onClick: () => void }) { + if (checkedIn) { + return ( + + + 今日已签到 + + ); + } + return ( + + ); +} + +/** 账号权益块(对齐移动端「我的」页:会员等级 + 今日额度 + 积分 + 签到 + 邀请)。 + * 只在已关联时渲染;一路数据都拿不到就整块不出现——空进度条比不显示 + * 更容易被读成"额度为 0"。 */ +export function McUsagePanel({ + usage, + userId, + onCheckin, +}: { + usage: McUsage | null; + /** 云端账号 id(拼邀请链接);缺失时不给邀请入口 */ + userId?: string; + /** 签到:null=成功,string=错误文案。成功后宿主会重拉权益刷新余额 */ + onCheckin: () => Promise; +}) { + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + const [copied, setCopied] = useState(false); + const copiedTimer = useRef(null); + useEffect(() => () => { + if (copiedTimer.current) window.clearTimeout(copiedTimer.current); + }, []); + + const view = usageView(usage, userId); + if (!view) return null; + const q = view.quota; + // 余额见底才转警示色:额度是每日重置的,平时用掉大半属正常 + const low = !!q && q.total > 0 && q.ratio <= 0.1; + const invite = view.invite; + + const doCheckin = async () => { + if (busy) return; + setBusy(true); + setErr(""); + try { + // 重复签到/验证码失败等由服务端文案外显,不吞 + setErr((await onCheckin().catch((e) => (e instanceof Error ? e.message : String(e)))) ?? ""); + } finally { + setBusy(false); + } + }; + + const copyInvite = () => { + if (!invite?.link) return; + copyText(invite.link); + setCopied(true); + if (copiedTimer.current) window.clearTimeout(copiedTimer.current); + copiedTimer.current = window.setTimeout(() => setCopied(false), 1800); + }; + + return ( +
+ {/* 会员等级 + 有效期 · 签到(主行动) */} +
+ + + {view.planText} + + {view.expiryText && {view.expiryText}} + + {view.checkedIn !== null && void doCheckin()} />} +
+ + {/* 今日额度 */} + {q && ( +
+
+ 今日额度 · 免费模型 + + {q.text} +
+
+
0 ? Math.max(2, q.ratio * 100) : 0}%`, + borderRadius: 99, + background: low ? "var(--warn)" : "var(--acc)", + }} + /> +
+
+ )} + + {/* 积分余额 · 邀请 */} + {(view.credits !== null || invite) && ( +
+ {view.credits !== null && ( + + 积分余额 + {view.credits} + + )} + + {invite && ( + + + + 已邀请 {invite.count} 人 + 每邀请一位 +{INVITE_REWARD.toLocaleString("zh-CN")} 积分 + + {/* 复制按钮定宽:文案在「复制邀请链接 / 已复制」之间切换, + 不定宽会让整个右对齐的邀请簇跟着抽动一下 */} + {invite.link && ( + + )} + + )} +
+ )} + + {err && {err}} +
+ ); +} + /** MonkeyCode 云端任务关联卡。百智云只是显式连接时的授权前提, * 两者状态和退出操作互不代替。已关联时可把会员内置模型同步为本地任务 * 可用的条目。 */ function MonkeyCodeAccountCard({ connection, + usage, + onCheckin, baizhiLoggedIn, onConnect, onPasswordLogin, @@ -384,6 +587,10 @@ function MonkeyCodeAccountCard({ onLogoClick, }: { connection: McConnectionState; + /** 账号权益(额度/签到/邀请);null=尚未拉到,已关联但缺数据时整块隐藏 */ + usage: McUsage | null; + /** 每日签到:null=成功,string=错误文案 */ + onCheckin: () => Promise; baizhiLoggedIn: boolean; onConnect: () => void; /** 账号密码直连登录:null=成功,string=错误文案(表单本地展示, @@ -521,6 +728,7 @@ function MonkeyCodeAccountCard({
{message} {syncMsg && {syncMsg.text}} + {connected && } {/* 第二条登录路径:MonkeyCode 账号密码(不经百智云,私有化/未绑百智账号可用) */} {(connection.phase === "disconnected" || connection.phase === "error") && ( <> @@ -950,7 +1158,7 @@ export function SettingsView({ // 时,晚到的这一路回来只看到自己已被卸载,整份 {models, mcp_servers} 连同 // 报错一起被丢掉(表现为"登录后只同步到会员模型")。 const [bzSyncing, setBzSyncing] = useState(false); - const [bzSyncMsg, setBzSyncMsg] = useState<{ text: string; color: string } | null>(null); + const [bzSyncMsg, setBzSyncMsg] = useState(null); // 同步即全量导入(用户拍板,不再逐条挑选):结果整组并入设置表单, // 交保存条落盘重启;不想要的条目可在模型页删除(重同步会恢复) const syncBaizhi = async () => { @@ -978,19 +1186,14 @@ export function SettingsView({ // "模型和 MCP 都为空"时才展示 —— 模型拉到了而 MCP 没有时静默无声, // 用户只看到"MCP 没同步过来"却查无对证 if (r.notes?.length) parts.push(...r.notes); - parts.push( - applied.autoSaved - ? "正在保存并重启内核…" - : applied.blocked === "busy" - ? "有任务正在执行,空闲后请手动保存(保存会重启内核)" - : applied.blocked === "dirty" - ? "表单有未保存的修改,请核对后手动保存" - : "已切到模型页,核对后保存", - ); - // 跨组撞名先到先得:跳过必须外显,否则"少了几个模型"查无对证 + // 附加说明 = 内核诊断 + 跨组撞名的跳过名单(必须外显,否则"少了几个 + // 模型"查无对证);它们决定这条消息值不值得在离开分区后留着 + const hasNotes = !!r.notes?.length || applied.skipped.length > 0 || r.key_created; + const { tail, transient } = syncResultTail({ ...applied, hasNotes }); + if (tail) parts.push(tail); if (applied.skipped.length) parts.push(`与现有条目同名已跳过: ${applied.skipped.join("、")}(想改用百智云通道请删除原条目后重新同步)`); - setBzSyncMsg({ text: parts.join("、"), color: "var(--ok)" }); + setBzSyncMsg({ text: parts.join("、"), color: "var(--ok)", transient }); } catch (e) { setBzSyncMsg({ text: e instanceof Error ? e.message : String(e), color: "var(--err)" }); } finally { @@ -1002,7 +1205,18 @@ export function SettingsView({ // 放在 SettingsView 而非账号卡内:百智云同步成功会把分区切到模型页, // 账号卡随之卸载,挂在卡里的"连上就自动同步"会在最关键的一步失效。 const [mcSyncing, setMcSyncing] = useState(false); - const [mcSyncMsg, setMcSyncMsg] = useState<{ text: string; color: string } | null>(null); + const [mcSyncMsg, setMcSyncMsg] = useState(null); + + // 同步消息的保质期:同步成功会当场把分区切到模型页,transient 的那条 + // (纯成功、既无待办也无附加说明)到这里就作废——留着的话,用户下次点回 + // 账号看到的是一句针对早已结束的动作的反馈。失败消息不受影响:出错不切 + // 分区,用户就在账号页看着。判据见 syncResultTail。 + useEffect(() => { + if (active === "account") return; + setBzSyncMsg((m) => (m?.transient ? null : m)); + setMcSyncMsg((m) => (m?.transient ? null : m)); + }, [active]); + const syncMcModels = async () => { if (mcSyncing) return; setMcSyncMsg(null); @@ -1017,14 +1231,11 @@ export function SettingsView({ const applied = applySyncedModels(r.models, SOURCE_MONKEYCODE); if (applied.skipped.length) notes.push(`与现有条目同名已跳过: ${applied.skipped.join("、")}`); const count = r.models.length - applied.skipped.length; - const tail = applied.autoSaved - ? ",正在保存并重启内核…" - : applied.blocked === "busy" - ? ";有任务正在执行,空闲后请手动保存(保存会重启内核)" - : ";表单有未保存的修改,请核对后手动保存"; + const { tail, transient } = syncResultTail({ ...applied, hasNotes: notes.length > 0 }); setMcSyncMsg({ - text: `已同步 ${count} 个会员模型` + (notes.length ? `(${notes.join(";")})` : "") + tail, + text: `已同步 ${count} 个会员模型` + (notes.length ? `(${notes.join(";")})` : "") + (tail ? `;${tail}` : ""), color: "var(--ok)", + transient, }); } catch (e) { setMcSyncMsg({ text: e instanceof Error ? e.message : String(e), color: "var(--err)" }); @@ -1033,6 +1244,37 @@ export function SettingsView({ } }; + // ---- 账号权益(额度/签到/邀请)---- + // 只在设置页可见,所以只在本视图挂载期间拉一次,不挂进 App 的 30 秒云端 + // 轮询——那等于为一块看不见的面板长期空跑四个云端请求。状态放 + // SettingsView 而非账号卡内:同步会把分区切到模型页,卡会随之卸载。 + const [mcUsage, setMcUsage] = useState(null); + const refreshMcUsage = useCallback(async () => { + // 全失败(会话失效/自建部署一个权益端点都没有)就当没有权益可展示, + // 面板整块不出现——这里没有比"不显示"更有用的降级 + setMcUsage(await fetchMcUsage().catch(() => null)); + }, []); + useEffect(() => { + if (mcConnection.phase !== "connected") { + setMcUsage(null); + return; + } + void refreshMcUsage(); + }, [mcConnection.phase, refreshMcUsage]); + + /** 每日签到(壳内完成 PoW 验证码)。成功后重拉权益,+100 积分与 + * 「今日已签到」一次刷出;失败文案交卡片就地展示(重复签到等属业务 + * 提示,写进全局连接态会让侧栏冒出「连接失败」)。 */ + const checkinMc = useCallback(async (): Promise => { + try { + await mcCheckin(); + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + await refreshMcUsage(); + return null; + }, [refreshMcUsage]); + // 连上就自动同步会员模型:只认**本页发起的连接**(点连接、账号密码登录、 // 百智云登录顺带连)的升起沿——启动时恢复的既连状态、侧栏重试都不触发, // 自动同步会打脏表单,不能在用户没动作时凭空发生。 @@ -1820,6 +2062,8 @@ export function SettingsView({
(list: T[]): T[ return [...list].sort((a, b) => modelSourceRank(a.source) - modelSourceRank(b.source)); } +/** 同步结果消息(账号卡就地外显)。 */ +export interface SyncMsg { + text: string; + color: string; + /** 只是这次点击的即时反馈,离开分区即作废;见 syncResultTail */ + transient?: boolean; +} + +/** + * 同步结果消息的收尾语与生命周期(百智云/会员模型两条同步流水线共用)。 + * + * 自动保存这一路**不写进行时**:同步成功会当场把分区切到模型页 + * (mergeSyncedModels),消息所在的账号卡随之退出视野——"正在保存并重启 + * 内核…"根本来不及被看到,却会在用户下次点回账号时以早已过期的状态出现 + * (保存那会儿就结束了)。保存与重启另有保存条/引擎横幅各自外显。 + * + * transient:消息只剩这次点击的即时反馈(既没有待办、也没有附加说明), + * 离开分区即作废;带待办(需手动保存)或附加说明(跳过名单、内核诊断)的 + * 留着——那些话在用户切回来时依然成立。 + * + * autoSaved 的语义是"会被写下去"(本次直接存,或搭上在途保存的补存循环), + * 见 SyncApplyResult。 + */ +export function syncResultTail(p: { + autoSaved: boolean; + blocked?: "dirty" | "busy"; + /** 消息里是否还带着附加说明(跳过名单、内核 notes 等) */ + hasNotes: boolean; +}): { tail: string; transient: boolean } { + if (p.autoSaved) return { tail: "", transient: !p.hasNotes }; + if (p.blocked === "busy") return { tail: "有任务正在执行,空闲后请手动保存(保存会重启内核)", transient: false }; + if (p.blocked === "dirty") return { tail: "表单有未保存的修改,请核对后手动保存", transient: false }; + return { tail: "已切到模型页,核对后保存", transient: false }; +} + // 归一化保存载荷:save() 与 dirty 比较共用同一形态(名称 trim、default 重算、MCP 序列化) export const payloadOf = (ms: HostModel[], di: number, mc: McpEntry[], ke: string, mcUrl: string, mcBasic: string, mcLlm: string): HostConfig => ({ // 显式列出内核支持的字段,避免旧版/实验 UI 字段只写进 config.json、 diff --git a/desktop/ui/src/types.ts b/desktop/ui/src/types.ts index 22dd1120..0801ea89 100644 --- a/desktop/ui/src/types.ts +++ b/desktop/ui/src/types.ts @@ -428,6 +428,49 @@ export interface McStatus { user?: McUser; } +/** 钱包(字段与移动端 /api/v1/users/wallet 一致)。 */ +export interface McWallet { + /** 积分余额(展示需 /1000,与移动端「积分」口径一致) */ + balance?: number; + /** 每日免费模型剩余 tokens */ + daily_token_balance?: number; + /** 每日免费模型 tokens 上限 */ + daily_token_limit?: number; +} + +export interface McSubscription { + /** "basic" | "pro" | "ultra" | "flagship" */ + plan?: string; + expires_at?: string; + auto_renew?: boolean; + source?: string; +} + +export interface McInvitation { + id?: string; + name?: string; + /** 可能是相对路径,按 McUsage.base_url 补全 */ + avatar_url?: string; + credits?: number; + invited_at?: number; +} + +export interface McInvitations { + count?: number; + items?: McInvitation[]; +} + +/** mc_usage 返回:各路各自可能为 null(私有化部署只有订阅端点)。 */ +export interface McUsage { + /** 云端服务完整基址(含协议/端口):邀请链接与相对头像地址的解析基准 */ + base_url?: string; + wallet: McWallet | null; + subscription: McSubscription | null; + /** 当天是否已签到;null = 本次没取到(不等于"未签到") */ + checked_in: boolean | null; + invitations: McInvitations | null; +} + /** MonkeyCode 云端账号在 UI 中的独立关联状态。 * 百智云登录只提供桥接授权,不会再隐式把本状态推进到 connected。 */ export interface McConnectionState {