Compare commits

...

179 Commits
v1.0.1 ... dev

Author SHA1 Message Date
618eecf676 Merge pull request '_tmp_update' (#11) from _tmp_update into dev 2026-10-10 02:39:06 +08:00
8207285b23 update 1.7.22 final2
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 02:39:01 +08:00
82bd04597d Merge pull request '_tmp_update' (#3) from _tmp_update into dev 2026-10-10 02:00:16 +08:00
5850fc452c update 1.7.22 final
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 02:00:15 +08:00
c6a75f8fce Merge pull request '_tmp_update' (#2) from _tmp_update into dev 2026-10-10 01:47:32 +08:00
a384309689 update 1.7.22
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 01:47:31 +08:00
8692014340 Merge pull request 'chore(update): publish 1.7.21 manifest' (#1) from _tmp_update into dev
Some checks are pending
Release / build (push) Waiting to run
2026-10-10 00:25:54 +08:00
15c6bc24c4 chore(update): 发布 1.7.21 更新清单
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 00:25:12 +08:00
460bed7279 chore(update): 发布 1.7.20 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 23:16:10 +08:00
ecbc86fdf2 chore(update): 发布 1.7.19 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:43:53 +08:00
05b5107f46 chore(update): 发布 1.7.18 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:32:24 +08:00
bccd7bad83 chore(update): 发布 1.7.17 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:14:31 +08:00
c5120c5952 chore(update): 发布 1.7.16 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 18:39:17 +08:00
2c5a3b2055 chore(update): 发布 1.7.15 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 18:02:12 +08:00
c76193e892 chore(update): 发布 1.7.14 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 17:22:54 +08:00
6edd68f80e chore(update): 发布 1.7.13 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 17:12:43 +08:00
750b9d9f90 chore(update): 发布 1.7.12 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 16:42:53 +08:00
0f4d68033e chore(update): 发布 1.7.11 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 16:33:50 +08:00
7c7a23f097 chore(update): 发布 1.7.10 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 15:36:25 +08:00
7c3809e879 chore(update): 发布 1.7.9 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 15:29:08 +08:00
7ad49e994d chore(update): 发布 1.7.8 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 12:27:18 +08:00
8f40360b9f chore(update): 发布 1.7.7 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 11:51:52 +08:00
08a3b42a82 chore(update): 发布 1.7.6 更新清单 2026-10-09 11:23:01 +08:00
aab50bbe78 chore(update): 发布 1.7.5 更新清单 2026-10-09 11:04:35 +08:00
faf7f40524 chore(update): 发布 1.7.4 更新清单 2026-10-09 10:47:27 +08:00
342788056b chore(update): 发布 1.7.3 更新清单 2026-10-09 10:29:48 +08:00
b479cb2a7a chore(update): 发布 1.7.2 更新清单 2026-10-09 10:22:57 +08:00
6e0d67afb7 chore(update): 发布 1.7.1 更新清单 2026-10-09 10:13:03 +08:00
8d68c3b280 chore(update): 发布 1.7.0 更新清单 2026-10-09 10:03:32 +08:00
71f3f8d554 chore(update): 发布 1.6.9 更新清单 2026-10-09 09:47:13 +08:00
dfd91a5355 chore(update): 发布 1.6.8 更新清单 2026-10-09 09:40:01 +08:00
149fc5f125 chore(update): 发布 1.6.7 更新清单 2026-10-09 09:31:01 +08:00
95cde916ca chore(update): 发布 1.6.6 更新清单 2026-10-09 09:18:15 +08:00
79f99f5dee fix(credential): 多账号会话凭据按请求账号精确匹配;UI微调
- CredentialRepository 快照新增 requestedAccountId,暂存凭据严格按请求账号匹配,
  避免 A 账号刷新期间把暂存凭据误交给默认账号/其他账号
- Dashboard 首页提示与自动签到开关改为卡片样式,开关配色对齐雨晴粉主题
- 默认账号徽标加 maxLines/softWrap 防窄屏换行
2026-10-08 22:16:06 +08:00
2509ab8a85 feat(update): 设置页新增检测更新/下载APK/自动安装;接入Gitea update.json清单 2026-10-08 21:42:45 +08:00
ceb5e6a268 feat(checkin): 签到记录含日期/状态/id/数量,失败可当天重试;同步本地主题与组件改动 2026-10-08 21:08:22 +08:00
ffc06a38ca feat(account): 多账号体系、Sub2API token 自动刷新与面板按账号隔离
- Trae/WorkBuddy/Sub2API 支持多账号:每账号一张首页卡片、可设默认账号
  (小组件/每日自动签到仅作用于默认账号),新增账号管理页
- 每日自动签到按账号执行,签到结果弹窗汇报(含积分变动)
- 移除独立的 UsageStat 页,用量并入服务详情
- Sub2API 面板 token 过期自动用邮箱+密码重登并回写该账号,
  自动重登失败才引导手动网页登录;并发 401 按账号互斥去重
- Sub2API 面板管理(Key/分组/兑换码)按当前账号隔离
- 首页恢复每账号一张普通卡片(回退叠放卡组样式)并保留历史排序兼容
- WebView 登录自动识别成功、去掉手动确认弹窗;登录后立即刷新余额
- 修复积分占位符泄漏(%1$s /%2$s 积分)
2026-09-15 23:32:51 +08:00
1791fa6436 fix: 登录后余额不刷新 + 明细中英文本地化
- WebViewLogin 登录成功后 popBackStack 到 SERVICE_DETAIL,让详情页
  ON_RESUME 自动刷新余额;back stack 无 SERVICE_DETAIL 时 fallback
  到 guardedPop() 避免卡住
- GenericExtrasCard 重写:11 个已知技术 key 映射为三语本地化标签,
  布尔值格式化为是/否,consumption_ratio 格式化为百分比
- 三语 strings.xml 新增 13 个 extra_label_* / extra_value_* 字符串
- 移除 WebViewLoginViewModel/Screen 中的调试 Log
2026-09-13 16:37:22 +08:00
0bed6d626a feat(login): Trae+WorkBuddy 软件内登录
- WebViewLoginViewModel: 新增 Trae OAuth 回调解析(onTraeCallback)
  和 WorkBuddy 设备授权流(bindWorkBuddy+pollWorkBuddyToken)
- WebViewLoginScreen: 添加 shouldOverrideUrlLoading 拦截 Trae 回调
  (http://127.0.0.1:18080/authorize), loginUrl 为空时显示加载指示器
- CredentialEditScreen: TraeCredentialForm 和 WorkBuddyCredentialForm
  顶部添加'软件内登录'按钮(OutlinedButton)
- strings.xml(三语): 新增 action_login_in_app 和 error_login_timeout
- 修复: WorkBuddy 网络请求包裹 withContext(Dispatchers.IO) 防止
  NetworkOnMainThreadException
2026-09-13 14:57:33 +08:00
085a4d722f feat(checkin): 签到语义化结果 + 多语言 + 轮换/并发安全
- Repository 改为返回语义化 CheckinOutcome(不再直出硬编码中文),
  UI 经 UiText 按 Locale 解析;补齐三语言文案(含连签/需配置凭据/已签禁用态)
- WorkBuddy authedPost 改 sealed 结果:失败也回传轮换出的新 token,
  多候选路径共享轮换状态,避免旧 refreshToken 单次消费后卡死(永久 401)
- 签到的凭据写入改走 RefreshBalanceUseCase.withCredentialSession,
  按快照校验提交,避免并发改凭据被旧快照覆盖(lost update)
- Trae 仅在实际被服务端接受时才标记 lastVerifiedAt(400 业务失败不标记)
- DEVICE_BLOCKED 改用 Notice 态:不置灰按钮,与「今日已签到」语义区分
- 新增 TraeRepositoryTest(10) / WorkBuddyRepositoryTest(5),纯 JVM 解析用例
2026-09-13 14:01:27 +08:00
3b4cd103d0 fix(sub2api): tab改纯文字样式;去掉多余「按模型」标题;今天明细按小时
1. 「按模型 / 按 Key」tab 从 FilterChip 按钮改为纯 Text + clickable,
   去掉按钮背景和圆角,更像文字标签而非按钮。
2. MODEL tab 下方原来有一行「按模型」小标题(sub2_models_header),
   和 tab 本身重复,去掉。
3. 「今天」窗口的每日用量明细从隐藏改为按小时倒序列出,
   每行显示 HH:00 + 请求/tokens/费用。
2026-09-13 13:14:49 +08:00
78f93588f8 fix(sub2api): 修复柱状图不显示——接入按小时趋势
详情页「今天」窗口柱状图只有一根按天柱子看不出分布,
原因是 hourlyTrend / onLoadHourlyTrend 参数从未从 Screen 传入 Card。

修复:
- VM 新增 hourlyTrend StateFlow + loadHourlyTrend(date) loader
- Screen 传入 hourlyTrend + onLoadHourlyTrend 回调
- Card 的 onLoadHourlyTrend 改为 (LocalDate) -> Unit,今天窗口时拉取小时趋势
- detailTab 状态去掉 remember(detail) 依赖,避免后台刷新余额时重置 tab 选中
2026-09-13 12:18:11 +08:00
dc3027417e feat(sub2api): 详情页新增「按 Key」tab;面板管理按钮间距收紧
详情页「按模型」分区改为 tab 切换:按模型 / 按 Key。
按 Key tab 调 GET /api/v1/usage/stats?api_key_id=N 逐个查询,并行拉取;
未使用的 Key(404)显示为 0 而非报错。

面板管理「复制/分组/删除」三个操作从 TextButton 改为可点击 Text,
去掉 58dp 最小宽度带来的过大间隔(用户反馈「间隔有点宽」)。

补齐三语言缺失字符串(sub2_home_spend / sub2_panel_delete_confirm_*
/ sub2_trend_account_scope / sub2_tab_by_* / sub2_no_key_data /
sub2_key_unused)。
2026-09-13 11:44:00 +08:00
100b698f38 feat(sub2api): 面板管理支持删除 Key;用量明细修柱状图与按小时趋势 2026-09-13 00:46:04 +08:00
bc862df226 fix(sub2api): 余额标签与数字同行;请求次数改为「消耗 N 次」 2026-09-13 00:02:16 +08:00
62ddf50638 fix(sub2api): 面板管理按钮贴右边缘;补齐审查发现的登录安全与死代码问题
面板管理按钮(用户反馈「没有放到最右边」):
- 根因:底部 Row 里同时给左侧文案 weight(1f, fill=false) 和中间 Spacer weight(1f),
  两个 weight 平分剩余空间,按钮只被推到一半。改为只让文案 weight(1f) 吃掉全部剩余宽度。
- 同时清零 TextButton 的 contentPadding,并去掉无效的 defaultMinSize(0)
  (Material3 的最小尺寸挂在按钮内层节点,外层 modifier 压不下去,实测为 no-op)。
- 去掉箭头 Icon 的 offset 微调:offset 只改放置不改测量,右移的像素会落在按钮命中区之外造成误触。

独立复审发现并修复:
- 【安全】线上真正使用的面板登录弹窗 PanelLoginDialog 轮询 localStorage.auth_token 时
  完全没有来源校验——登录容器会跳转到第三方页(OAuth/CDN),它们可伪造 token 覆盖用户凭据。
  现要求 token 必须来自配置的面板域名(比较 hostname),并回传 host 一并校验。
- 【阻塞】saveFromCookieManager 仍在主线程 runBlocking 做 Keystore 加解密 + DataStore 写盘,
  改 suspend 并在 viewModelScope 中调用。
- 【死代码】移除已无调用点的 balanceTrailing 插槽(余额行入口已改到底部)。

说明:WebViewLoginScreen 那套 JS 桥(LoginProbeBridge/nonce/trustedHosts)经查其路由
Routes.WEBVIEW_LOGIN 没有任何 UI 入口(onStartWebViewLogin 只声明未调用),实际不可达,
故本次把安全校验补到了真正在跑的 PanelLoginDialog 上,避免两套并行实现语义分叉。

验证:compileDebugKotlin 与 lintDebug 通过;真机实测按钮与「刚刚更新」同行(y1492-1555)
且可点击区域右边缘到达卡片内容区右缘,点按正常打开弹窗。
2026-09-12 11:31:15 +08:00
8592f15663 feat(sub2api): 未配置服务不再占位;面板管理入口改到卡片右下角;网页登录自动抓取凭据
1) 未配置的用量卡不再显示
   OpenCode/CommandCode 用量卡与 Token 热力图卡此前是无条件加入首页列表的,
   即使对应服务未配置凭据也会占位显示。现改为按 cards 里是否包含该服务来判断,
   热力图卡跟随 OCGO。

2) 面板管理入口位置:卡片底部右下角,与「刚刚更新」同一行右对齐
   (此前放在余额右侧,用户反馈位置不对)
   底部 Row:左 footerText(weight(1f, fill=false)) + 中间弹性 Spacer + 右按钮。

3) 网页登录后自动获取凭据
   - WebViewLoginScreen 注入 JS 桥(@JavascriptInterface LoginProbeBridge),
     页面加载完成后读取 localStorage.auth_token / auth_user / favicon / origin 回传。
   - WebViewSessionSaver.saveSub2ApiPanelSession 落库面板凭据,保留已有 email/password/apiKey。
   - 安全:每次会话随机 nonce(回传必须匹配)+ origin host 白名单(trustedHosts
     来自用户配置的面板基址),防止登录过程中跳转的第三方页面伪造 payload 投毒凭据。
   - 自托管实例的 baseUrl 由 ViewModel 自行从凭据仓库读取,避免每个导航调用点漏传。

4) 修复(独立审查发现)
   - saveSub2ApiPanelSession 去 runBlocking 改 suspend,在 viewModelScope 中调用,
     避免 JS 桥线程同步等待 Keystore 加解密 + DataStore 写盘。
   - 删除「用 origin 查 Cookie」的旁路:origin 无 path,CookieManager 查不到会误报
     「登录完成了吗」,Cookie 抓取统一交回 onPageFinished 的真实 URL。
   - normalizeBaseUrl 改用 okhttp3 HttpUrl 解析,垃圾串不再通过校验后落库。
   - 撤销给 DeepSeek 加 loginUrl 的改动:其凭据类型是 ApiKeyCredential,
     走 Cookie 路径会写成 SessionCredential 并导致 API Key 静默丢失。

真机验证(V2172A / Android 15):
首页只剩 Sub2API 一张卡;面板管理与「刚刚更新」同一行(均 y1492-1555,按钮右对齐 x783-980);
点按钮正常打开弹窗;网页登录容器正常加载面板首页(证明 baseUrl 兜底生效)。
compileDebugKotlin 与 lintDebug 均通过。
2026-09-12 10:57:58 +08:00
edc2a40dc1 fix(sub2api): 面板管理入口与余额齐平;用量磁贴压缩为两行
- 「面板管理」从卡片底部移到余额同一行右侧:给 BalanceMainArea 增加 balanceTrailing 槽,
  Sub2ApiBalanceWithUsage 用 Row(verticalAlignment=CenterVertically) 让入口与余额数字齐平,
  不再单独占一行并额外撑高卡片。
- 今日/近7天 磁贴由三行(周期名/Token/次数)改为两行:第一行「周期名 … 请求次数」左右分布,
  第二行 Token 数。磁贴高度 56dp → 44dp,卡片整体更紧凑。

真机验证(V2172A / Android 15):余额 359.01 位于 y958-1105,面板管理 y956-1037 与之同高;
磁贴两行 y1185-1243 / y1255-1339;点「面板管理」正常打开弹窗(弹窗内标题仍只有一个)。
compileDebugKotlin 与 lintDebug 均通过。
2026-09-12 10:18:19 +08:00
56f85d296c feat(sub2api): 重做首页卡片布局——余额主视觉 + 今日/近7天等宽磁贴
原布局把余额与周期数据塞在同一行,窄屏(360dp)会换行、数字长短不一还容易错位,
整体观感松散。改为上下分区:

- 余额区:「余额」小标签 + 大号数字 + 单位,单行左对齐,作为卡片主视觉。
- 用量区:「今日 / 近7天」两块等宽磁贴(14dp 圆角 + 半透明底色),
  每块固定三行(周期名 / Token 数 / 请求次数),内容垂直居中,
  两块结构与内边距一致,数值长短不同也不会错位。
- 卡片正面只保留 余额、Token 用量、请求次数三项;不再显示花费与「本月已用」
  (花费与更长周期明细仍可在详情页时间窗口查看)。

副标题改为登录方式:
- Sub2API 卡片副标题由「自托管面板 · 用户级 sk- Key 余额」改为「邮箱登录 / API 登录」,
  依据 Repository 新写入的 extras["auth_mode"](panel / apikey)判定,无数据时回退原描述。

真机验证(V2172A / Android 15,360dp 宽):
余额 y887-1105 与两块磁贴 y1185-1405 分区清晰、不换行;磁贴在 x176 与 x788 等宽对齐;
副标题显示「邮箱登录」。compileDebugKotlin 与 lintDebug 均通过。
2026-09-12 10:14:15 +08:00
ce8a656448 fix(sub2api): 刷新按钮与「面板管理」标题同排
- 弹窗标题槽改为 Row:左侧标题 + 右侧刷新图标按钮,刷新与标题处于同一水平线,
  且随标题固定不随内容滚动(内容区仍可滚动)。
- showHeader=false 时组件不再渲染内部标题/刷新行,避免多出一条空行把刷新挤到标题下面。

真机验证(V2172A / Android 15):标题 y284..413 与刷新 y300..396 同行居中;
点刷新弹窗保持打开且列表重载;滚动后标题+刷新固定于顶部。
compileDebugKotlin 与 lintDebug 均通过。
2026-09-12 09:47:42 +08:00
eebe9f6987 fix(sub2api): 面板管理只保留首页入口,弹窗支持滚动、操作前置
按使用反馈调整(真机验证):
- 详情页不再重复展示面板管理卡:Key 列表/新建/分组/兑换统一收到首页 Sub2API 卡片的
  「面板管理」入口,详情页只保留用量明细(周期切换 + 柱状图 + 按模型)。
  随之移除详情页已不再使用的 panelKeysState / panelGroups / panelActionMessage 状态收集。
- 修复「面板管理弹窗打开后不能滚动」:AlertDialog 的 text 槽是固定高度且不自带滚动容器,
  内容超过上限会被直接裁剪。改为给 text 槽加 verticalScroll + 高度上限(屏幕高度 72%),
  现在可以正常上下滚动浏览全部 Key。
- 主要操作前置:新建 Key / 兑换卡密 从列表底部移到顶部(原位置在 20+ 条 Key 之后,
  需要滚到底才能看到);并新增「API Key 列表」小标题区分列表区。
- 弹窗内不再重复标题:组件新增 showHeader 参数,首页弹窗由 AlertDialog title 槽显示标题,
  组件内部标题隐藏(刷新按钮保留)。

验证:compileDebugKotlin、lintDebug 通过;真机(V2172A / Android 15)实测
首页仍只显示今日与近7天、弹窗可滚动、新建/兑换在首屏、详情页无面板管理卡。
注:gradle.properties 的本地路径开关仍未纳入提交。
2026-09-12 09:40:59 +08:00
58d8b0e077 fix(sub2api): 按真实面板实测校准兑换/金额/模型窗口口径
实测环境:xxcsn.site(无忧ai)v0.2.1,浏览器登录态直连接口逐条比对响应结构。

修复(均为实测发现,非推测):
- 兑换失败文案被吞:失败走 HTTP 404/409 + {code,message,reason},原先先判状态再解析,
  会把「兑换码不存在 / 已被使用」降级成无文案的通用错误。改为先解析 body,
  并把 REDEEM_CODE_NOT_FOUND/USED/EXPIRED/DISABLED 映射为中文,未知 reason 透传面板原文。
- 兑换成功不显示金额:成功响应 data.value 是数字(实测 value:50),原用 scalar("value") 取不到,
  「兑换成功」后面没有金额。改用 numberOr + trimNum,并附带类型后缀(余额/订阅)。
- 金额少一位(严重):trimNum 原实现 v.toString().trimEnd('0','.'),Kotlin 的 trimEnd 会连续
  剥离集合内任意字符——"50.0"→"5"、"100.0"→"1"、"10.0"→"1"(已在 JDK 上复现)。
  即兑换 $50 会显示成 +$5。改为 BigDecimal.valueOf(v).stripTrailingZeros().toPlainString()。
  同一份错误实现还拷贝在 TraeRepository / WorkBuddyRepository,一并修复。
- 按模型无法按周期筛选(Key 模式根因):实测下游 /v1/usage 的 model_stats 恒为历史累计口径,
  days/start_date/end_date 只影响 daily_usage。新增 fetchKeyScopedWindowModelStats():
  有面板登录态时先用 /api/v1/keys 反查当前 sk-key 的 id,再走支持日期窗口与 api_key_id 的
  /api/v1/usage/dashboard/models(实测同一 key:today=0 / 近7天=8 / 近90天=8 条)。
- 累计口径如实标注:新增 WindowModelStats(stats, allTime);纯 Key 无面板态时回退历史累计,
  UI 在「按模型统计」标题追加「(累计口径,未按所选周期过滤)」,避免冒充周期数据(三语)。
- API Key 掩码对齐面板网页观感(sk-06b...2dc0);列表接口虽返回完整 key,页面仍只显示掩码,
  复制按钮复制的始终是完整值。

实测核对结果:首页 今日 $1.58/936req 与 近7天 $132.49/9893req 周期区分有效;
90 天趋势网关只返回 89 天,补零后 90 天(验证 slice() 补零逻辑必要且正确);
按模型 today=1 / 近7天=11 / 近90天=34 条,周期筛选生效;
兑换成功路径真实执行一次(id=57,$50,余额 309.29→359.29),失败路径 404/409 文案正确。

验证:compileDebugKotlin、lintDebug 均通过;独立审计两轮均无阻断与必修体验问题。
注:测试用临时 key 已删除;gradle.properties 的本地路径开关未纳入提交。
2026-09-12 03:41:02 +08:00
2aaa225139 feat(sub2api): 面板管理接入首页卡片;用量明细周期化与按模型筛选修复
首页与用量明细(用户反馈 1-3):
- 首页 Sub2API 卡片汇总只保留「今日 / 近7天」两个周期,去掉近30天/本月
- Sub2API 用量明细卡默认窗口改为「今天」,进入即看当天数据
- 每日用量柱状图:柱间距按天数自适应(>44 天 1dp),避免近90天时 3dp×N 间距溢出屏幕导致柱子挤没;柱高封顶 0.95、零消耗日 0.02 占位
- 「按模型」真正按时间周期筛选:新增 Sub2ApiRepository.fetchWindowModelStats(),按窗口 start_date/end_date 拉取 model_stats(面板模式走 dashboard/models,Key 模式走 /v1/usage),并新增加载中/错误/空态;切换窗口后自动取消悬空模型选中

面板管理(用户反馈 4):
- 面板管理 UI 从 ServiceDetailScreen 抽为公共组件 ui/servicedetail/Sub2ApiPanelManagement.kt
- 首页 Sub2API 卡片新增「面板管理」入口,弹窗内嵌同一组件(DashboardViewModel 新增面板状态与动作)
- API Key 不再展示过长明文:列表只显示掩码(前8+后4),新建成功弹窗一次性展示完整 Key 并可复制
- 复制按钮补 Toast 反馈;新建 Key 响应解析兼容 data 对象/字符串/顶层字段,并透传面板 error/message
- 兑换结果不再静默:Toast + 行内快照双反馈;消息消费后置空,连续相同结果也能再次提示
- 面板接口统一校验 HTTP 状态(401/403 明确提示面板登录已失效),不再把登录失效显示成余额 $0.00/空列表
- 面板模式分模型统计窗口与每日明细对齐到近 90 天

其它:SiteIcon 站点图标网络与 SVG 解码移到 Dispatchers.IO;新增 sub2_panel_* 三语文案,修正 sub2_home_no_usage 默认英文资源误写中文。
2026-09-12 03:11:31 +08:00
80552af204 feat: 新凭据类型补全 RefreshWriteSession lastVerifiedAt 分支 2026-09-11 23:38:03 +08:00
2da780bf09 docs: 上游同步指南与二开计划/凭据文档;scripts: sync-upstream 一键合并 2026-09-11 23:38:03 +08:00
fd3d74cfef feat: API 管理 / 用量统计 / 充值 WebView / 首页仅显示已配置;Sub2API 用量明细卡(模型筛选+时间窗口+柱状图) 2026-09-11 23:38:03 +08:00
e4cd9a36f6 feat: 新增 Trae / WorkBuddy / Sub2API 三家供应商接入与凭据管理 2026-09-11 23:38:03 +08:00
WaterRain
3bef8cc3f1
feat: 时间选择器优化 —— 范围单弹窗选择(DateRangePicker)+ 打开回显 + 天/月选择器精简 2026-09-11 07:51:49 +00:00
WaterRain
777860024d
release: bump to v1.6.4
Some checks failed
Release / build (push) Has been cancelled
2026-09-11 06:55:38 +00:00
WaterRain
2c38c4caf1
docs: taste.md 补充审计不选模型、版本号控制权偏好 2026-09-11 06:52:37 +00:00
WaterRain
1439e8ca43
refactor(ccgo): CommandCode Go 统一更名为 CommandCode 2026-09-11 06:52:37 +00:00
WaterRain
a079a0bfcb
feat(chart): 用量详情页显示服务标识;图例只显示当前时间范围内模型(含 tooltip 残留修复) 2026-09-11 06:52:36 +00:00
WaterRain
eecf54986f
docs: README 补充 Token活动热力图说明与截图;归档历史计划文档至 docs/archive
Some checks are pending
CI / Unit Tests & Lint (push) Waiting to run
CI / Debug APK (push) Blocked by required conditions
CI / Release APK (debug-signed) (push) Blocked by required conditions
2026-09-10 07:58:14 +00:00
WaterRain
95508bab9f
release: bump to v1.6.3
Some checks failed
Release / build (push) Has been cancelled
2026-08-30 06:57:05 +00:00
WaterRain
75c6ce9568
feat(ocgo): 用量窗口新增模型级用量列表(_server 接口 + 窗口配额) 2026-08-30 06:56:49 +00:00
WaterRain
2b2ef941a9
docs: 新增 taste.md 协作偏好,AGENTS.md 要求任务前先读取 2026-08-30 05:50:04 +00:00
WaterRain
24eb3e190c
fix(ccgo): GOAT 计划月度额度映射 + 详情页月度重置倒计时修复
- PLANS 新增 individual-goat→$70(官网 GOAT 计划,5h $14 / weekly $35 / monthly $70),
  修正 individual-pro 30→80,planDisplayName 映射 GOAT
- 抽 internal planMonthlyQuota/planDisplayName(大小写不敏感、未知计划安全降级)
- 详情页月度重置倒计时改用仓库已算好的 monthly.resetInSec(原按 ISO 解析
  epoch 毫秒的 billingPeriodEnd 恒失败,倒计时永不显示)
- 新增 7 个计划映射测试
2026-08-30 05:49:52 +00:00
WaterRain
6cd54a3a8f
feat(ccgo): 适配用量接口新格式 + 修复 nextCursor null 同步死循环
- 解析迁移到 companion(纯函数可单测),cost 改读 meta.totalCost(美元数值),
  旧格式 creditsTotal 兼容回退
- nextCursor 由服务端直接提供,缺失/null 时回退按末条自编码
- 修复 JSON null 被解析成字符串 "null" 导致 fullSync 死循环(JsonNull 拦截)
- fullSync/incrementalSync 增加游标未前进 + 页数上限双防御
- 新增 8 个解析测试(含 null 游标回归用例)
2026-08-30 05:49:45 +00:00
WaterRain
f1ed643b80
release: bump to v1.6.2
Some checks failed
Release / build (push) Has been cancelled
2026-08-27 02:24:17 +00:00
WaterRain
1a1066076a
feat(opencode): 适配用量页新格式(usagePercent 小数 + usage/limit 字段)
- parseWindows 的 usagePercent 改为 Float 解析,避免小数被 toIntOrNull 丢弃\n- 新增 usage/limit 字段解析(exactKey 匹配避免前缀误命中)\n- 首页卡片与桌面小组件读取端改为 toFloatOrNull()?.roundToInt()\n- 新增新格式/旧格式兼容测试
2026-08-27 02:23:54 +00:00
WaterRain
bb309c92f0
release: bump to v1.6.1
Some checks failed
Release / build (push) Has been cancelled
2026-08-24 08:49:58 +00:00
WaterRain
dcc93714be
chore: 统一品牌命名(Ollama 去 Pro 后缀、Codex 去 Plus 后缀) 2026-08-24 08:49:54 +00:00
WaterRain
43d8df90f6
feat(ui): Codex 主模型余额为主视觉,Spark 弱化为次要摘要
- 首页卡片:主模型窗口独占进度行,Spark 收为单行小字摘要\n- 详情页:Spark 收进分隔线 + 小标题次要小节,细条低饱和进度\n- 桌面小组件:跳过 SPARK 窗口,仅保留主模型三行布局\n- 新增 window_spark 多语言文案
2026-08-24 08:49:46 +00:00
WaterRain
7e44e545bf
feat(codex): 用量窗口支持 Spark 独立限额分组解析
- UsageWindow 增加 WindowGroup(CODEX/SPARK)分组字段\n- additional_rate_limits 按 limit_name 是否含 spark 归类\n- selectPrimaryWindow 优先普通 Codex 组,避免 Spark 5h 抢占主余额
2026-08-24 08:49:35 +00:00
WaterRain
4ed6aaa3cc
feat(heatmap): 年份选择器新增最近365天窗口(默认)
- RECENT_YEAR(-1) sentinel:最近365天(今天往前364天~今天),默认加载窗口
- computeAllViews 支持窗口模式:每日/每周/累计/统计/分位按窗口计算
- 下拉置顶最近365天+ 分隔线 + 选中标记,与具体年份区分
- 浮层日期跨年自动带年份前缀(formatWeekRangeText 兼容 RECENT)
- setYear 竞态 guard 改 latestRequestedYear(最后点击胜出);RECENT 缓存跨天自动失效重算
- HeatmapAllViewsTest 4 用例,全量 130 用例通过
2026-08-08 00:27:55 +00:00
WaterRain
a0cf7fd31c
docs: AGENTS.md 补充子代理任务完整路径规则 2026-08-08 00:27:54 +00:00
WaterRain
99050554e2
feat(heatmap): 个人资料与活动洞察 + 当前连续天数逻辑修正
- 个人资料卡片(页面顶部):自定义头像(相册选图→私有目录持久化→降采样解码)+ 昵称/邮箱自由文本编辑,SharedPreferences 持久化
- 活动洞察卡片:总请求次数 + 最多请求时段 Top3(按小时,UTC+8/UTC 口径,全量历史)
- 当前连续天数:结尾 0 token 天跳过,从最后一个非零天往前数(最后一天 0 也计入连续)
- 三语言资源(en/Hans/Hant),HeatmapInsightsTest 7 用例,全量 126 用例通过
2026-08-07 23:01:37 +00:00
WaterRain
266d33d131
release: bump to v1.6.0
Some checks failed
Release / build (push) Has been cancelled
2026-08-07 03:36:46 +00:00
WaterRain
025f971f7a
feat(heatmap): 首页OCGO快捷入口 + 多语言兼容修复
- 首页:OCGO用量卡与服务卡「查看用量详情」右侧新增 Token 活动快捷入口
- 首页入口描述改为「OCGO Token 活动」;统计卡片改 2×2 布局
- 多语言:热力图月份/日期/数字单位 locale-aware(英文 K/M/B + MMM d)
- 浮层/读数条句子走 stringResource;月份名资源数组(三语言)
- 遗留修复:Codex 窗口标签中性化(weekly/usage)、Ollama plan 空串兜底、
  isFiveHourLabel 语言无关判断、字段标签资源化、Widget usageLabel
- 单测同步:Codex/Ollama/FormatUtils/FormatCodexPrimaryLabel 全绿(119 用例)
2026-08-07 03:28:23 +00:00
WaterRain
03424e6184
feat(heatmap): 年度统计卡片(累计/峰值/当前连续/最长连续)
- computeStats 单次循环计算四项指标,存于 HeatmapUiState.stats
- 视图切换器下方一行 4 卡片,按所选年份跟随变化
- i18n 三语言(含 plurals 单复数);loading 占位显示 -
- 对抗审查修复:surfaceVariant 品牌色、en plurals、loading 占位
- 新增 HeatmapStatsTest 单测(4 用例全绿)
2026-08-07 01:53:24 +00:00
WaterRain
bb1dd97874
docs: 安全修复计划(security-fix-plan) 2026-08-07 01:32:43 +00:00
WaterRain
83fc18317f
feat(heatmap): 滑动查看手势扩展到每周与累计视图
- 手势仲裁(长按/慢速拖动=查看锁定、快速=滚动)从每日扩展到三种视图
- 长按(按住不动≥系统长按时长)进入查看:所见即所得格子高亮+读数条
- 每周视图读数条显示周范围(formatWeekRangeText 与浮层同口径)
- 累计视图读数条显示累计 token;tap 点击清除残留预览
- 对抗审查修复:up 消费防误弹浮层、判决事件即时消费、溢出省略、防御性校验
2026-08-07 01:30:21 +00:00
WaterRain
962d8f1215
feat(heatmap): 年份选择器 + 每周视图柱状图 + 对抗审查修复
- 修复 4 个 heatmap 提交的编译错误与对抗审查问题:浮层锚定与跨年日期、
  Crossfade 快照、时区口径统一(useUtc8)、万级格式化溢出、周视图周日对齐
- 新增年份选择器:自然年语义、默认今年、三种视图跟随、下拉菜单、
  图例固定不随热力图滚动、打开默认滚到最右、0 token 天可点击查看
- 每周视图改为离散柱状图:列=自然周,高度按 token 排名比例映射 2~7 格
  (不同量级周高度可区分),0 用量周显示 Level 0 空白格(整列 7 格全绘制)
- 选中周整列主题色描边强调,浮层日期跨年带年份前缀
- 文档 docs/heatmap-plan.md 补充年份选择器/图例与滚动/柱状图章节
2026-08-06 22:33:25 +00:00
WaterRain
77cb664590
feat(heatmap): add popup tooltip, crossfade animation, accessibility, i18n
- Click cell shows popup with date and token count in Chinese format
- Weekly popup shows date range (start-end)
- Crossfade 150ms between view mode switches
- Canvas i18n: noDataText/lessText/moreText parameters
- Accessibility: semantics contentDescription on Canvas
- formatTokenChinese: 亿/万 Chinese units with decimal precision
- Popup: focusable, auto-dismiss, toggle on re-click
- heatmap_accessibility_desc string in EN/zh-Hans/zh-Hant
2026-08-06 07:45:15 +00:00
WaterRain
dd91e94805
feat(heatmap): add HeatmapScreen, navigation route, dashboard entry, i18n strings
- HeatmapScreen with TopAppBar, view switcher (Daily/Weekly/Cumulative), Canvas
- DashboardScreen: new HeatmapEntryCard with CalendarMonth icon
- RainyTokenNavHost: HEATMAP route for both Compact and Expanded layouts
- 9 new string resources across EN/zh-Hans/zh-Hant
- ArrowBack -> AutoMirrored.Filled.ArrowBack (deprecation fix)
2026-08-06 07:30:28 +00:00
WaterRain
6e4a5ff9cb
feat(heatmap): add HeatmapCanvas composable with three view layouts
- Daily: 53×7 GitHub-style grid with week grouping and left-padding
- Weekly: 52×1 single-row grid
- Cumulative: 53×7 grid using cumulative token data
- 6-level pink color gradient (light/dark mode)
- Canvas drawRoundRect with 11dp blocks, 3dp gap, 3dp radius
- Bottom month labels (Chinese), color legend, horizontal scroll
- Tap interaction with coordinate-to-cell mapping
2026-08-06 07:21:07 +00:00
WaterRain
b8479eebf3
feat(heatmap): add HeatmapViewModel with quantile-based color levels and three view modes
- Daily/Weekly/Cumulative data aggregation from OCGO records
- Quantile (P25/P50/P75/P95) color level mapping with nearest-rank
- Token = inputTokens + cacheReadTokens + reasoningTokens + outputTokens
- 365-day rolling window, 52-week weekly view, cumulative running sum
- Hilt injected, Dispatchers.Default for heavy ops, try-catch for safety
2026-08-06 07:06:34 +00:00
WaterRain
7f2ca18d9d
feat: 图表新增近30天时间尺度 (LAST_30D_DAILY) 2026-08-06 05:47:16 +00:00
WaterRain
f203149f4f
security: 修复 CI/Release workflow 签名凭据风险
- 所有 action 引用改为 commit SHA pin(checkout/setup-java/setup-android/action-gh-release/upload-artifact)
- build.gradle.kts 移除硬编码签名密码 fallback,改用 takeIf 防御 null 和空字符串
- release.yml 构建完成后清理 release.jks(if: always())
- ci.yml 补充最小权限声明 permissions: contents: read
2026-08-04 22:58:00 +00:00
WaterRain
172d6b61aa
release: bump to v1.5.4
Some checks failed
Release / build (push) Has been cancelled
2026-08-04 06:52:11 +00:00
WaterRain
488f08d083
fix: 主页底部版本号改为实时读取 BuildConfig.VERSION_NAME 2026-08-04 06:48:52 +00:00
WaterRain
8340ded070
fix: 详情页新鲜缓存不再标记为缓存,用量卡片显示刷新时间 2026-08-04 06:43:01 +00:00
WaterRain
acddaae068
chore: 精简 AGENTS.md,仅保留提交推送许可规则 2026-08-04 06:27:08 +00:00
WaterRain
e4ea61c0f2
release: bump to v1.5.3
Some checks failed
Release / build (push) Has been cancelled
2026-08-02 03:33:34 +00:00
WaterRain
6498e4cf71
Merge pull request #4 from CATMIAOZHI/i18n-localization
i18n: 多语言支持(全量资源化 + 英文翻译)
2026-08-02 11:32:12 +08:00
WaterRain
041df0c240
docs: README 措辞正式化 + 新增英文版 README.en.md 2026-08-02 03:11:48 +00:00
WaterRain
7795b553d0
docs: README 补充多语言功能说明 2026-08-02 03:05:30 +00:00
WaterRain
f788d57798
fix: 同步/触发错误全链路本地化(count_times 单位 + Unknown/PartialSync/TriggerError 结构化映射) 2026-08-02 03:02:58 +00:00
WaterRain
039f27da18
fix: ParseError 原因结构化本地化,消除英文/繁体界面的中文错误透传(Codex review + 双审计) 2026-08-02 01:16:52 +00:00
WaterRain
2e4e5ce449
fix: 与系统应用语言页同步(framework 唯一权威源)+ widget 文本跟随最新偏好(Codex review + 审计) 2026-08-02 00:12:35 +00:00
WaterRain
f463751fb1
fix: 日期格式与应用内语言同步 + DeepSeek 标签动态化(Codex review) 2026-08-01 20:32:27 +00:00
WaterRain
5e7942c5cc
chore: 移除已迁移的 values-zh(简体资源已迁至 values-b+zh+Hans) 2026-08-01 13:43:37 +00:00
WaterRain
b51644048d
fix: widget 用量条左对齐(label 统一 44dp)+ 署名动态化跟随应用内语言
- 三套布局三行 label 统一 44dp:此前 row2/3 为 60dp、row1 44dp,
  进度条起点差 16dp 导致不对齐;英文标签简化为 Week/Month 后 44dp 足够
- 署名 TextView 补 @+id/widget_brand,Provider 动态 setTextViewText:
  与行标签同源(跟随应用内语言),避免宿主进程按系统语言渲染混搭
2026-08-01 13:43:05 +00:00
WaterRain
4107954080
feat: 应用内语言切换(跟随系统/简/繁/英)+ 繁体中文支持 + 应用名/署名分语言
- 新增 LocaleManager:语言偏好存 SharedPreferences,createConfigurationContext
  + LocaleList 包装 Application/MainActivity 的 attachBaseContext(零新依赖)
- 设置页新增语言卡片与选择对话框:跟随系统 / 简体中文 / 繁體中文 / English
- 新增繁体中文 values-b+zh+Hant(434 条,OpenCC s2twp 台湾标准转换+人工修正)
- 简体目录改为 values-b+zh+Hans(BCP-47,与繁体对称;AGP9 不接受 values-zh-Hans)
- widget_brand 可翻译:英=Rainy / 简繁=雨晴喵;app_name 可翻译:英=RainyToken / 简繁=雨晴Token
- 资源三语言 434/434/434 对齐,占位符零差异
2026-08-01 13:43:05 +00:00
WaterRain
13acca37ed
fix: 默认资源改为英文,非中/英系统语言回退英文(原回退中文)
原结构:values/(默认)=中文,values-en=英文。系统语言既非中文也非英文时
(如日语/法语)会回退到默认资源显示中文,不符合国际用户预期。

新结构:
- values/(默认)=英文(国际回退语言),unqualifiedResLocale=zh-CN → en
- 中文移入 values-zh/(中文系统精确匹配)
- 品牌资源(app_name/widget_brand/brand_token/dashboard_footer_version,
  translatable=false)保留在默认资源,所有语言共用中文品牌名
- 删除 values-en/(不再需要,默认即英文)

验证:中英 426/426 key 对齐、lint 0 Error(MissingTranslation/ExtraTranslation 0)、
113 测试全绿、assembleDebug 成功
2026-08-01 12:28:00 +00:00
WaterRain
3a724d46fd
fix: 日期格式化跟随系统语言,修复英文界面中文日期符号(Codex review P2)
UsageOverviewScreen.DailyDetailRow 原强制 Locale.CHINA,英文环境下 datePattern
为 MMM d, EEEE 但月份/星期仍渲染中文(如 8月1日, 星期五)。改为
Locale.getDefault() 并加入 remember key,与资源语言保持一致。
2026-08-01 12:22:07 +00:00
WaterRain
6ac4d06e9f
i18n 修复:消除英文环境下 5 处中文泄漏(最终审查 P1)
最终独立审查发现英文环境用户可见中文,全部修复:

- 激活成功弹窗:三个 parse 函数(OCGO/Codex/Ollama)改为返回结构化 TriggerSummary,
  中文摘要拼接移出 Repository;UI 层 buildTriggerSummaryText() 用资源组装本地化文本
- Codex 窗口标签:formatCodexPrimaryLabel / normalizeWindowLabel 增加「每周/每月」字面量分支,
  Dashboard 与 Widget 英文环境下正确显示 Weekly/Monthly
- Dashboard footer:DashboardCardUi.lastFetchError 改 UiText?,errorToUiText() 按类型映射资源
- 凭据编辑页:ParseError→error_parse_failed(RepositoryError.ParseError 新增公开 detail),
  「未知」「JSON格式错误」中文 fallback 改资源
- 服务详情页:triggerUsage else 分支复用 errorMessage() 全类型映射;
  TriggerError「token刷新失败」前缀资源化;「无响应体」不再展示
- 新增 TriggerSummary 数据类与 12 组中英资源(trigger_*/error_token_refresh_failed/等)
- 测试:CodexRepositoryTest 6 用例改为结构化断言,FormatUtilsTest 更新 monthly 映射;
  113 个用例全绿,lint 0 Error,资源 426/426 对齐
2026-08-01 08:19:51 +00:00
WaterRain
52b86e95e5
i18n 阶段二:新增英文翻译 values-en + widget 布局英文适配
- 新增 values-en/strings.xml:414 条英文资源与中文完全对齐(占位符/转义/格式逐一校验,无缺失无多余,无中文残留)
- widget 三套布局(亮色/预览/暗色 night)完成资源化 + 英文布局适配:
  - row2/row3 行标签宽度 44dp→60dp,避免 "This week"/"This month" 被截断
  - widget_updated 增加 maxLines=1 + ellipsize=end,防最小宽度下挤占刷新按钮
- widget_name/widget_description 由 translatable=false 改为正式可翻译资源,补充英文(AI Usage Panel)
- 统一英文术语:raw_record_title 改为 Raw data 与 title_raw_data 一致
- 验证:testDebugUnitTest 78 用例通过、lintDebug 0 Error 且 MissingTranslation/ExtraTranslation/Untranslatable 全为 0、assembleDebug 成功
2026-08-01 07:53:59 +00:00
WaterRain
a26629027e
i18n 阶段一:全量字符串资源化(行为不变,仍为中文)
- strings.xml 扩充至 ~470 条(原 3 条),全部 UI 硬编码中文迁移为资源引用
- 新增 UiText 抽象(Resource/Dynamic + asString()/resolve()),6 个 ViewModel 错误消息 UiText 化
- AppTips 13 条改为 @StringRes 资源 ID;TimeFilter/ChartGranularity/StatusStyle/DashboardViewModel 徽章改资源 ID
- FormatUtils 保持纯函数,DurationText/标签参数注入(78 个纯 JVM 测试不受影响)
- Widget XML(2) + WidgetProvider 文字资源化;品牌名 translatable=false
- 开启 generateLocaleConfig + resources.properties(unqualifiedResLocale=zh-CN)
- 修复审查发现的每周/每月行为回归(dashboard/widget 保持'每周/每月')
- 删除死资源/死代码;ChartGranularity 粒度标签补 time_last_12h
- 验证:compileDebugKotlin / testDebugUnitTest(78) / lintDebug / assembleDebug 全绿
2026-08-01 07:32:23 +00:00
WaterRain
f70b77f5cc
release: v1.5.2
Some checks failed
Release / build (push) Has been cancelled
2026-07-28 01:29:38 +00:00
WaterRain
728b3ad89e
fix: OCGO 激活用量端点从 zen/v1 改为 zen/go/v1
- CHAT_API: opencode.ai/zen/v1/chat/completions → opencode.ai/zen/go/v1/chat/completions
- fetchModels provider key: "opencode" → "opencode-go"
  (opencode = Zen 模型列表, opencode-go = Go 模型列表)
2026-07-28 01:22:28 +00:00
WaterRain
6a73155ed0
release: v1.5.1
Some checks failed
Release / build (push) Has been cancelled
2026-07-27 05:24:15 +00:00
WaterRain
2732b138ae
fix: 凭据变更后刷新状态并防止旧请求回写 (#2)
## 修复内容

### Issue #2: 凭据保存/删除后返回设置页配置状态不刷新

**核心修复:**
- Dashboard 并发竞态:reloadLocalState() 改为只更新 credentialState,不再通过 loadFromCache() 重建卡片,避免覆盖 refresh() 写入的最新余额/错误状态
- ServiceDetail 凭据刷新:reloadCredentialState() 增加凭据指纹检测(credentialFingerprint),区分凭据替换/新增/删除三种场景,正确清除旧缓存
- refresh() 加 generation 标记,防止旧请求响应覆盖新数据
- 宽屏布局刷新:Expanded 模式下 ServiceDetail 面板切换时正确触发凭据状态刷新

**安全修复:**
- 移除公开提交的 debug.keystore,恢复 AGP 默认签名行为
- 凭据指纹架构:SHA-256 不可逆指纹 + cacheIdentityFingerprint + sameRefreshLineage,支持 Codex token 轮换不误清缓存
- RefreshWriteSession + CredentialSnapshot 原子提交架构

**测试:**
- 新增 16+ 个单元测试覆盖凭据指纹、分类逻辑、四个场景
- 总计 78+ 个用例全部通过

Refs #2
2026-07-27 11:40:47 +08:00
WaterRain
74a4e3d494
fix: 签名 fallback 限制到 CI 环境,本地构建缺 release.jks 时报错
修复 chatgpt-codex-connector review 指出的问题:
原逻辑在任何机器缺 release.jks 时都静默 fallback 到 debug keystore,
可能生成不安全的 debug 签名 Release APK 而无任何错误提示。

改为仅 CI=true 时才 fallback,本地构建直接 GradleException 报错。
2026-07-20 13:03:22 +00:00
WaterRain
29877c00ce
feat: OCGO & Ollama 一键激活用量 + 设置页 API Key 输入
- OpenCodeGoRepository: 新增 fetchModels() + triggerUsage(),调 opencode.ai/zen/v1/chat/completions
- OllamaRepository: 新增 fetchModels() + triggerUsage(),调 ollama.com/v1/chat/completions
- SessionCredential: 新增 apiKey 字段,用于一键激活用量
- RefreshBalanceUseCase: 新增 fetchOpenCodeGoModels/triggerOpenCodeGoUsage/fetchOllamaModels/triggerOllamaUsage
- ServiceDetailViewModel: 通用化 triggerUsage(),支持 Codex/OCGO/Ollama 三服务,各服务独立 SharedPreferences 持久化
- ServiceDetailScreen: 三服务均显示模型选择器 + 激活按钮 + 响应弹窗
- CredentialEditScreen/ViewModel: OCGO & Ollama 凭据表单新增 API Key 输入区域
- AGENTS.md & README.md: 同步新功能描述
2026-07-20 09:46:39 +00:00
WaterRain
79edb053cc
Merge pull request #1 from CATMIAOZHI/feat/unit-tests
test: 新增 52 个单元测试 + 删除模板残留 + 整理构建修复
2026-07-20 17:27:01 +08:00
WaterRain
433770d285
docs: AGENTS.md + README.md 同步测试体系、CI workflow、PR 审计红线
AGENTS.md:
- 新增测试体系段落(5 文件 78 用例)
- 新增 CI workflow 段落(3 job 串行、签名 fallback、artifact 上传)
- 新增重大修改 PR 审计红线(构建/签名/CI/架构变更必须 PR 提交)
- 关键命令段补 testDebugUnitTest

README.md:
- 顶部加 CI badge
- 构建说明补 testDebugUnitTest 命令
2026-07-20 09:24:23 +00:00
WaterRain
360dde342f
fix: CI Release 构建签名 fallback + debug keystore 保障
- build.gradle.kts: release.jks 不存在时自动 fallback 到 ~/.android/debug.keystore
- ci.yml: build-release job 增加 Ensure debug keystore 步骤
- ci.yml: Release APK 完整性验证(manifest/arsc/res/)

上一轮 CI 失败原因: validateSigningRelease 找不到 release.jks
2026-07-20 09:21:05 +00:00
WaterRain
246f5dece3
ci: 增强 CI workflow——测试报告上传 + lint + Release APK 完整性验证
- test job: testDebugUnitTest + lintDebug,上传 XML 测试报告和 lint 报告
- build-debug job: assembleDebug,上传 Debug APK artifact
- build-release job: assembleRelease + APK 完整性验证(manifest/arsc/res/),上传 Release APK artifact
- test 通过后才构建,三个 job 串行依赖
- 所有 artifact 保留 14 天
2026-07-20 09:10:35 +00:00
WaterRain
b7639a733e
test: 新增 52 个单元测试,删除模板残留,整理构建修复
## 变更内容

### 新增测试(3 文件,52 用例)
- FormatUtilsTest (18): formatAmount/formatResetInSec/formatResetForWidget/normalizeWindowLabel
- FormatCodexPrimaryLabelTest (9): Codex 窗口标签翻译,含 null fallback/大小写/未知值
- CodexRepositoryTest (25): durationLabel 阈值/parseUsageWindows JsonNull 安全/parseSseResponse SSE 解析

### 删除模板残留
- ExampleUnitTest.kt (包名 com.java.myapplication)
- ExampleInstrumentedTest.kt (包名 com.java.myapplication)

### 生产代码可见性调整 (CodexRepository.kt)
- UsageWindow/parseUsageWindows/durationLabel: private to internal, 移入 companion object
- parseSseResponse: private to internal 顶层函数

### 构建修复 (上一轮会话成果)
- build.gradle.kts: 删除旧 fixReleaseManifest workaround,替换为 guardReleaseResources 任务
- AGENTS.md: 新增 ARM64 Proot Release 构建红线段落
- README.md: 补充 Release 构建额外配置说明

## 测试结果
78 tests, 0 failures, 0 errors
2026-07-20 09:07:54 +00:00
WaterRain
5ead63a8a3
fix: AGP 9.0 ARM64 Proot release APK 缺 AndroidManifest.xml
packageRelease 任务在 ARM64 Proot 环境下丢失 manifest,
新增 fixReleaseManifest 后处理任务:从 .ap_ 提取 binary
AndroidManifest.xml 注入 APK 并用 apksigner 重新签名。
仅 aarch64 环境触发,CI x86_64 不受影响。
2026-07-17 23:18:39 +00:00
WaterRain
ea1cae597d
ci: release workflow 注入签名密码 Secret 2026-07-17 22:29:45 +00:00
WaterRain
81cdd00cac
revert: 恢复签名密码默认值,无需额外配置 Secret 2026-07-17 22:27:24 +00:00
WaterRain
5178af91f6
fix: 安全与发布成熟度改进
1. 移除通用 WebView 登录入口(半成品功能,凭据无法正确映射到 Repository)
2. allowBackup=false,移除空模板备份规则引用
3. 版本号修正:versionCode=5, versionName=1.5
4. 签名密码默认值改为空字符串(CI 注入 Secret)
5. release workflow 注入签名密码环境变量
6. 新增 ci.yml:push/PR 触发单测+Debug 编译
7. 新增 LICENSE 文件(MIT)
8. CommandCodeGoRepository 注释修正(顺序执行非并行)
9. README 安全声明更新}
2026-07-17 22:26:16 +00:00
WaterRain
0b140fe73e
fix: UTC+0/UTC+8 切换偏好持久化——OCGO 路径首次加载不读取持久化值导致偏好丢失
将 UTC 偏好同步读取逻辑从 setWorkspace() 下沉到 load() 开头,
确保 OCGO(load())和 CCGO(setWorkspace()→load())两条路径
首次加载时都从 DataStore 读取持久化的 useUtc8 偏好。
useUtc8Initialized 标志位保证只读取一次,后续 toggleUtc8() 不被覆盖。
2026-07-17 21:34:51 +00:00
WaterRain
b4c3402d22
feat: Codex 一键激活用量功能 — 模型列表从 models.dev 动态获取,SSE 响应解析弹窗展示
Some checks failed
Release / build (push) Has been cancelled
2026-07-17 01:50:50 +00:00
WaterRain
f17d37ae39
fix: Codex JsonNull 崩溃 + 5h 窗口优雅降级
- CodexRepository: 所有 ?.jsonObject/?.jsonPrimitive → as? JsonObject/as? JsonPrimitive
  防止 API 返回 JSON null 时 IllegalArgumentException 闪退
- CodexRepository: parseUsageWindows() 外包 try/catch → ParseError 兜底
- CodexRepository: extras 新增 primary.label 供 UI 动态显示主窗口标签
- ServiceBalanceCards: CodexMainBalance 标签动态化 + CodexUsageWindows 保留 5h 空槽位
- ServiceDetailScreen: mainCardLabel 改为 "用量" + CodexUsageCard 保留 5h 空槽位
- Widget: Codex 窗口动态解析 + 5h 始终占第一行
- AGENTS.md: 新增 JsonNull 安全红线 + Codex 5h 降级说明
2026-07-13 02:13:44 +00:00
WaterRain
9d06644225
feat: Codex OAuth 无头登录、调试日志面板、用量详情进度条与文档同步
- 实现 Codex OAuth PKCE 无头模式:生成授权链接、外部浏览器登录、粘贴回调 URL 交换 token

- 新增 DebugLog 内存 ring buffer 与设置页调试日志入口,Repository 关键路径埋点

- 修复 Codex refresh_token 刷新请求体为 form-urlencoded,RefreshResult 密封类保留错误详情

- ServiceDetailScreen 为 Codex 增加用量窗口进度条,Ollama 百分比精确到两位小数

- 同步 AGENTS.md、README.md
2026-07-11 03:07:16 +00:00
WaterRain
fbae6701b8
fix: DashboardViewModel 竞态条件导致 Codex 用量闪回旧值
- init 从两个独立协程改为单协程串行 loadFromCache→refresh
- refresh() 加 Mutex.tryLock() 防并发调用交错覆盖 UI
- AGENTS.md 补充刷新竞态红线说明
2026-07-10 22:54:13 +00:00
WaterRain
1a3fdff0c1
docs: 截图按高度分组——竖屏四张一排,横图两张一排对齐
Some checks failed
Release / build (push) Has been cancelled
2026-07-08 05:19:10 +00:00
WaterRain
24d8db179e
docs: 修正 Ollama 截图尺寸——横图加宽到 300px 保持视觉高度对齐 2026-07-08 05:18:13 +00:00
WaterRain
a980841a3b
docs: README 新增 Ollama Pro 截图(首页卡片 + 模型调用次数详情) 2026-07-08 05:16:18 +00:00
WaterRain
45e10ad063
fix: Ollama小组件logo独立命名,不影响首页图标
Ollama logo拆分为两个资源:
- ic_ollama_logo: 首页ServiceIcon用,无night变体,深色模式靠浅灰背景保证可见
- ic_ollama_logo_widget: 小组件用,有night变体(浅色版)

避免drawable-night-nodpi覆盖影响首页Ollama图标。
2026-07-07 22:07:09 +00:00
WaterRain
06f28c9e57
fix: 深色模式小组件logo不可见
新增 drawable-night-nodpi/ 目录,放入深色模式专用 PNG logo:
- ic_opencode_go_logo_widget.png (OCGO/CCGO)
- ic_codex_logo_widget.png (Codex)
- ic_ollama_logo.png (Ollama)

黑色像素替换为 #E8D8D5 暖灰色,透明通道保留。
Android 资源系统在深色模式自动选择 night-nodpi 同名资源。
DeepSeek 蓝色 logo 在深色背景上可见度足够,不需要变体。
2026-07-07 22:03:51 +00:00
WaterRain
9077605da6
feat: Ollama详情页新增模型调用次数显示
在 OllamaUsageCard 的 5h 和每周用量窗口进度条下方,
新增模型级调用次数列表(按次数降序,品牌粉标色)。
数据来自已有的 extras[session.models]/[weekly.models],
新增 parseModelRequests() 解析 + ModelRequestList Composable。
仅改详情页,首页 Dashboard 未动。
2026-07-07 21:46:59 +00:00
WaterRain
75a5c8f81e
fix: 对抗审查修复5项问题
1. BalanceCache.put() 并发写丢失 — read-modify-write 搬进 dataStore.edit{} 互斥锁
2. Widget VectorDrawable → PNG — 生成 ic_opencode_go_logo_widget.png 和 ic_codex_logo_widget.png
3. SimpleDateFormat 线程安全 — 共享实例改为每次调用创建局部实例
4. 增量同步按 workspace 过滤 — 新增 getIdsByWorkspace() 避免跨 workspace ID 碰撞
5. Codex amount 语义统一为 usedPct — balance_cache 版本 v2→v3 旧缓存失效

Doc: AGENTS.md/README.md 同步更新(›→>、RemoteViews PNG文件名、增量同步描述)
2026-07-07 05:40:16 +00:00
WaterRain
c5505c896a
fix: ic_deepseek_logo.png从ICO转为真PNG,修复CI AAPT编译失败
Some checks failed
Release / build (push) Has been cancelled
2026-07-07 02:16:11 +00:00
WaterRain
5e630c8895
fix: CI跳过国内Maven镜像,修复GitHub Actions构建失败 2026-07-07 02:10:21 +00:00
WaterRain
814ae61f01
feat: 使用小技巧系统 + 小组件点击提示 + 图表默认tooltip + 添加小组件权限检测
- 新增 AppTips.kt:13条操作技巧,Dashboard随机展示一条hint,设置页TipsScreen查看全部
- 小组件左上角加 › 箭头提示可点击进APP,其他区域点切换服务
- 图表默认选中最后一根柱子,tooltip首次可见
- 添加小组件二次确认弹窗 + Manifest权限 + MIUI AppOps检测
- Dashboard首次进入显示长按拖拽排序提示横幅
- NavHost新增Tips路由
- 同步AGENTS.md和README.md
2026-07-07 01:57:52 +00:00
WaterRain
86aaef9a60
docs: sync AGENTS.md and README.md for Room migration + refactors
AGENTS.md:
- Tech stack: DataStore → Room + DataStore
- UsageCache: DataStore JSON → Room DAO, auto-migration from old DataStore
- ChartSettingsStore: add StateFlow, remove runBlocking
- Data flow: insertAll persist/invalidateCache → Room DAO insert (IGNORE)
- RefreshBalanceUseCase: add retryOnTransientError note

README.md:
- Architecture diagram: UsageCache DataStore → Room
- Feature table: 内存缓存 → Room 数据库
- Dependency table: remove Jsoup, add Room
- Project structure: local/ updated with Room files
2026-07-07 00:27:31 +00:00
WaterRain
2c21add8c3
refactor: template CredentialEditViewModel testAndSave methods + cleanup
Step 6: CredentialEditViewModel template
- New: testAndRollback() template method — handles save/test/rollback/message
- testAndSaveApiKey: use testAndRollback(rollbackOnFailure=false)
- testAndSaveCommandCodeGo: use testAndRollback(rollbackOnFailure=false)
- testAndSaveOpenCodeGo: use testAndRollback(rollbackOnFailure=true)
- testAndSaveOllama: use testAndRollback(rollbackOnFailure=true)
- Unified error message formatting in one place
- ~50 lines of duplicate when(err) blocks removed

All 8 PLAN.md items complete — deleting plan file
2026-07-07 00:16:22 +00:00
WaterRain
6e629d8eb2
refactor: remove Jsoup dependency + replace runBlocking with Flow
Step 7: Remove Jsoup
- Delete implementation(libs.jsoup) from build.gradle.kts
- Delete jsoup version + library from libs.versions.toml
- 0 imports of Jsoup in codebase, pure dead dependency

Step 8: ChartSettingsStore Flow
- Replace runBlocking getUseUtc8() with StateFlow useUtc8State
- UsageChartViewModel: setWorkspace() reads useUtc8Flow.first() async
- ChartUiState: add useUtc8Initialized flag to prevent double-load
- Remove runBlocking import from ChartSettingsStore
2026-07-07 00:05:33 +00:00
WaterRain
220a74492a
feat: add retry with exponential backoff to all repositories
- New: RetryHelper.kt — retryOnTransientError(maxRetries=2, baseDelay=1s)
- Wraps in RefreshBalanceUseCase — single dispatch point for all 5 services
- Retries on Network and 5xx ServerError
- 401/403 (InvalidCredential), 429 (RateLimited), ParseError — no retry
2026-07-06 23:57:58 +00:00
WaterRain
f2e5eedaa4
refactor: extract duplicate format functions to FormatUtils
- New: ui/components/FormatUtils.kt — formatAmount/formatResetInSec/formatResetForWidget/normalizeWindowLabel
- ServiceBalanceCards.kt: remove internal definitions, import from FormatUtils
- ServiceDetailScreen.kt: remove private formatResetInSec/formatAmount, import from FormatUtils
- OpenCodeGoWidgetProvider.kt: remove private normalizeWindowLabel, import from FormatUtils
  (formatReset kept — widget uses short d/h/m format, not Chinese)
2026-07-06 23:47:20 +00:00
WaterRain
ec5f2dcb5c
refactor: split DashboardScreen into ServiceBalanceCards
- New: ServiceBalanceCards.kt (511 lines) — all service balance Composables +
  CompactUsageRow/CompactUsageRowEmpty + utility functions
  (formatAmount/formatResetInSec/normalizeWindowLabel/statusBadgeStyle/
  secondaryLine/footerText)
- DashboardScreen.kt: 1121 → 637 lines (-43%)
  Retained: DashboardScreen main + DraggableDashboardCards + DashboardCard +
  DashboardFooter
- All functions changed from private to internal for cross-file visibility
- No behavior change
2026-07-06 23:39:48 +00:00
WaterRain
c53266a16b
test: add parsing unit tests for Ollama and OpenCodeGo
- OllamaRepositoryTest: 17 tests covering plan/percentage/timestamp/model parsing
- OpenCodeGoRepositoryTest: 10 tests covering window parsing/nested objects/edge cases
- Move parseUsage/parseWindows to companion object as internal static methods
- Move ParsedUsage/ScrapedWindow to companion object as internal data classes
- All 27 tests pass via ./gradlew test
2026-07-06 23:29:13 +00:00
WaterRain
14083f4be1
refactor: migrate UsageCache from DataStore JSON to Room
- New: UsageRecordEntity (Room Entity, indexed on workspaceId+timeCreated)
- New: UsageDao (CRUD + time-range queries)
- New: UsageDatabase (Room Database, version 1)
- New: migrateDataStoreToRoom() — one-time migration from old DataStore JSON
- Rewrite: UsageCache internal impl from JSON serialize/deserialize to Room DAO
- UsageCache public API unchanged (getAll/insertAll/getRecords/count/etc.)
- Old DataStore key cleared after successful migration
- SharedPreferences flag prevents re-migration
- Room 2.7.1 + KSP compatible
- libs.versions.toml + build.gradle.kts updated
2026-07-06 23:21:43 +00:00
WaterRain
8469f01354
docs: sync AGENTS.md and README.md for Ollama Pro integration
- AGENTS.md: add Ollama to project overview, service list, data flow,
  widget service count (3→4), credential echo coverage, dashboard item count (7→8),
  fix widget refresh flow (DEEPSEEK+OPENCODE_GO → selectedService+DEEPSEEK)
- README.md: add Ollama to title, features, download requirements,
  architecture diagram, project structure; mark Jsoup as unused}
2026-07-06 22:57:38 +00:00
WaterRain
e3d21e0a8f
fix: crop Ollama logo padding, enlarge to 14dp on widget} 2026-07-06 22:48:40 +00:00
WaterRain
262918d9be
fix: only resize logo for Ollama, restore XML defaults for other services on switch} 2026-07-06 22:45:45 +00:00
WaterRain
7cdb3d9b3c
fix: unify widget logo height to 12dp for all services, prevent layout shift on switch 2026-07-06 22:41:57 +00:00
WaterRain
7ad1934474
fix: Ollama widget layout - hide empty 3rd row, put Plan in title} 2026-07-06 22:37:41 +00:00
WaterRain
d880b808d4
fix: replace DeepSeek emoji logo with official favicon, resize widget logo to 16dp} 2026-07-06 22:33:55 +00:00
WaterRain
e0301aad1d
fix: resize Ollama logo to 20x20dp on widget for square aspect ratio 2026-07-06 22:31:47 +00:00
WaterRain
f7662d065a
fix: use hello.png as Ollama logo, fillMaxSize for crisp display} 2026-07-06 22:29:43 +00:00
WaterRain
65e4dc5a2f
fix: Ollama pct parsing use Float then Int for decimal percentages
toIntOrNull() returns null for 35.1, causing 0% display.
toFloatOrNull()?.toInt() handles decimal percentages correctly.
2026-07-06 22:25:36 +00:00
WaterRain
2926e8ebb2
fix: Ollama detail page usage card, 5h label, real logo, widget data
- Add OllamaUsageCard to ServiceDetailScreen (5h + weekly windows)
- Unify Session → 5h across dashboard/detail/widget
- Download Ollama llama logo (icon-32x32.png) to drawable-nodpi
- ServiceIcon: use real PNG instead of emoji
- Widget: use ic_ollama_logo, fix row label to 5h
2026-07-06 22:20:29 +00:00
WaterRain
e1c2774c86
fix: show Ollama cookie as plaintext, no password masking 2026-07-06 22:03:31 +00:00
WaterRain
fb2e60d4a0
feat: add Ollama Pro Cloud usage service
- New ServiceType.OLLAMA enum + ServiceConfig
- OllamaRepository: OkHttp + Cookie auth, scrape ollama.com/settings HTML
  parse plan/session/weekly usage via regex (aria-label + data-time + data-model)
- SessionCredential: add ollamaCookie field
- NetworkModule: @Provides OllamaRepository
- RefreshBalanceUseCase: dispatch OLLAMA
- CredentialEditViewModel: load/save/test Ollama cookie
- CredentialEditScreen: OllamaCookieForm composable
- DashboardScreen: OllamaMainBalance + OllamaUsageWindows cards
- ServiceIcon: Ollama emoji placeholder
- Widget: shortName/logo/populateServiceRows + DISPLAY_SERVICES}
2026-07-06 21:59:18 +00:00
WaterRain
47d361b527
fix: LineChart tooltip UTC+8 date label mismatch
LineChart 点击 tooltip 的日期格式化未传入 useUtc8 参数,
导致 UTC+8 模式下首尾数据点标签差 8 小时(日粒度差一天)。
同步补上 UsageChartScreen 中 6 处图表调用的 useUtc8 传递。
2026-07-03 06:34:31 +00:00
WaterRain
9cfb962bc8
Add predictive back navigation polish 2026-07-02 22:29:09 +00:00
WaterRain
4e5dda5521
docs: 同步自定义日期和用量入口说明 2026-07-01 23:18:31 +00:00
WaterRain
45c8f71b8a
fix: 修复自定义日期时区并统一用量入口 2026-07-01 21:06:47 +00:00
WaterRain
1d98d4dee1
docs: 同步Codex凭据回显修复记录
AGENTS.md 新增「凭据回显红线」警示段落,防止后续新增凭据类型时遗漏 load() 回显分支。
2026-07-01 20:50:22 +00:00
WaterRain
23ae718327
fix: Codex配置页看不到已保存的凭证
CredentialEditViewModel.load() 缺少 CodexCredential 分支,
导致已保存的凭据不回显到表单。
2026-07-01 20:46:46 +00:00
WaterRain
a5ba79ea4b
feat: 首页拖拽排序 + Widget三服务切换 + 刷新优化
- DashboardScreen.kt: 长按自由拖动排序(原生Compose,原位节点+offset让位+中心命中模型+自动滚动补偿)
- OpenCodeGoWidgetProvider.kt: 新增三服务切换按钮(OCGO/CCGO/Codex循环)、showRefreshing()、isRefreshing互斥锁
- WidgetRefreshReceiver.kt: 只刷当前服务+DeepSeek、25s超时保护
- CodexRepository.kt: weekly→每周文案统一
- ServiceIcon.kt: CCGO复用OCGO图标
- widget_opencode_go.xml: 新增切换按钮布局
2026-07-01 15:29:34 +00:00
WaterRain
9a7c0284a1
docs: 同步拖拽排序和Widget切换文档
- AGENTS.md: 首页布局新增拖拽排序详细设计(中心命中、offset让位、坐标补偿)
- AGENTS.md: 服务新增文案统一标注
- AGENTS.md: Widget段更新三服务切换和刷新优化
- README.md: 特性表更新拖拽排序和Widget服务切换
2026-07-01 15:23:30 +00:00
WaterRain
4cbf593983
feat: add Codex/ChatGPT Plus service with auth.json import and auto token refresh
- New ServiceType.CODEX with CodexCredential (access_token + refresh_token + expiresAt)
- CodexRepository: query chatgpt.com/backend-api/wham/usage, auto refresh token 60min before expiry
- Import full auth.json in settings, supports automatic OAuth token refresh
- ChatGPT favicon SVG icon (ic_codex_logo.xml)
- Dashboard card shows 5h/Weekly usage windows with reset times
- Update AGENTS.md and README.md with Codex service info
2026-07-01 04:59:52 +00:00
WaterRain
1d11aa8ed2
fix: 连点左上角返回导致空页面/旧页面残影卡在上层的导航过渡 bug
Some checks failed
Release / build (push) Has been cancelled
根因三重:
① mutableStateOf 写入触发 NavHost 重组,干扰 AnimatedContent 过渡状态机
② popExitTransition=None 字面零帧,连续 pop 时退出 composable 未从 layout 树清除
③ popExitTransition=None + 默认 popEnter(700ms) 不匹配,第二次 pop 中断进入动画

修复:
- PopGuard 普通对象替代 mutableStateOf,不触发 NavHost 重组
- popExitTransition=fadeOut(tween(1)),极短非零帧确保 composable 正确移除
- popEnterTransition=EnterTransition.None,返回导航瞬时完成
- previousBackStackEntry 空值检查 + popBackStack() 返回值检查
2026-06-29 05:09:57 +00:00
WaterRain
2ab5f2f342
docs+nav: sync AGENTS.md 导航过时内容 + guardedPop 围栏方案
- AGENTS.md: AnimatedContent → when(pane) 原子切换
- AGENTS.md: 新增 Compact guardedPop 机制说明 + 已知连点空页面 Bug
- RainyTokenNavHost.kt: Compact 用 guardedPop(150ms) + popExitTransition=None
- Expanded 局部 NavHost 同步加 popExitTransition=None(预防性)
2026-06-29 04:20:52 +00:00
WaterRain
ea9f6f7e72
feat: 平板自适应全面适配 — BoxWithConstraints 双窗格 + 文档同步
P0 — 容器宽度自适应布局:
- 新增 WindowSizeUtils.kt (rememberWindowSizeClass 封装)
- 添加 material3-window-size-class 依赖
- DashboardScreen: BoxWithConstraints >600dp 卡片双列
- UsageDetailScreen/UsageChartScreen: >700dp 图表并排
- UsageDataScreen/UsageOverviewScreen: 移除全局 WindowSizeClass

P1 — 双窗格导航:
- RainyTokenNavHost 拆分 CompactNavHost/ExpandedLayout
- Expanded 模式: 左侧 35% Dashboard + 右侧 65% AnimatedContent
- 右侧内置子路由(图表⇄总览⇄数据) + Settings 局部 NavHost
- Compact 模式: 原 NavHost 一字未改,零回归

文档同步:
- AGENTS.md: 新增自适应断点说明 + 双窗格导航图
- README.md: 新增平板适配特性条目
- 项目记忆: 更新概述 + 删除旧 WIP 记录
2026-06-29 02:06:04 +00:00
WaterRain
26693c92d4
feat: 12小时10分钟粒度 + UTC时区切换持久化 + 自动降级 + 刻度优化
Some checks failed
Release / build (push) Has been cancelled
- 新增 LAST_12H_10MIN 枚举及 aggregate10Min 聚合器(10分钟桶)
- ChartSettingsStore 持久化 UTC 偏好,跨会话保留
- 自动降级链:5h无数据→12h→7天→当月(用户手动选择后关闭降级)
- niceCeil 加细阈值,图表刻度不再太空
- setWorkspace 保留 useUtc8,CCGO 切换不丢失
- UTC 按钮追加 Snackbar 反馈 + "点击切换"提示
- 文档同步:AGENTS.md / README.md
2026-06-27 03:49:44 +00:00
WaterRain
4a63db2840
style: README 运营风格统一——雨晴系列一致性修复
- 添加双语标语(中英文)
- 关于部分雨晴系列描述统一 — “第 4 个成员”
- “守护主人的每一分算力预算”尾部句
2026-06-26 21:10:16 +00:00
WaterRain
0c5f0850c0
docs: README 添加 4 张截图展示
- 戴表盘(亮x色)
- 戴表盘(深色)
- 用量图表详情
- 桌面小组件
2026-06-26 21:03:41 +00:00
WaterRain
ef30cad3a4
fix: CCGO 凭证引导优化 —— Cookie 说明改为全选复制 + 帮助弹窗
- 帮助弹窗 Cookie 步骤改为"全选复制全部 Cookie",无需逐个挑选
- 输入框 placeholder 改为 F12 全选复制粘贴操作提示
- 新增"如何获取 API Key 和 Cookie?"帮助按钮
- README 警告文字更新(含 CommandCode Go)
2026-06-26 20:48:07 +00:00
WaterRain
cc7aecc331
feat: CCGO 用量功能 + 首页布局修复 + 文档同步
- 新增 CommandCode Go (CCGO) 用量抓取与展示
- CCGO 输入 token 语义修正(inputTokens = tokensIn - cacheRead)
- 清除数据按钮迁移至 CCGO 详情页顶栏(带倒计时二次确认)
- 移除三个 ViewModel 的 init 自动加载,改为 LaunchedEffect 显式触发
- 修复 hiltViewModel key 碰撞导致 CCGO 详情页无限加载
- 首页 LazyColumn 改为 Column + verticalScroll + PullToRefreshBox
- OCGO/CCGO 页面 autoLoad 参数隔离重复 load
- AGENTS.md / README.md 文档同步更新
2026-06-26 20:37:07 +00:00
WaterRain
bb51efde09
feat: add CommandCode Go usage history API (cursor-based pagination)
- CommandCodeUsageRepository: GET /internal/usage?limit=100&cursor=<base64>
  parses JSON response, converts creditsTotal → cost (COST_DENOM)
- SyncCommandCodeUsageUseCase: fullSync + incrementalSync
  reuses existing UsageCache with workspaceId="commandcode"
- NetworkModule: DI for CommandCodeUsageRepository
- PAGE_SIZE=100 for fewer page requests
2026-06-26 08:18:10 +00:00
WaterRain
0f26269b7e
feat: add CommandCode Go support (JSON API via API Key)
- New CommandCodeGoRepository: calls api.commandcode.ai/alpha/billing/credits + /alpha/billing/subscriptions
- ServiceType: COMMANDCODE_GO added (reordered: OCGO → CCGO → DEEPSEEK)
- ServiceConfigProvider: REST_API config with $ unit
- NetworkModule: DI for CommandCodeGoRepository
- RefreshBalanceUseCase: COMMANDCODE_GO branch
- CredentialEditViewModel: inject CommandCodeGoRepository, allow testAndSave
- ServiceIcon: ⚡ emoji for CommandCode Go
- DashboardScreen: CCGO card with 3 windows (5h/weekly/monthly) like OCGO
- ServiceDetailScreen: CommandCodeGoUsageCard with 5h→weekly→monthly order
- Fixed resetAt: epoch millis → remaining seconds conversion
2026-06-26 08:04:16 +00:00
WaterRain
d37589b89a
chore: 删除误提交的临时文件 2026-06-22 21:46:01 +00:00
172 changed files with 31022 additions and 1916 deletions

View File

@ -1,7 +0,0 @@
fix: 图表模型颜色从8色扩展为12色 Material Design 500调色板
modelColors 从8色扩展到12色 MD500 色阶,解决11个模型颜色不够区分、
后3个模型与前面颜色循环重复的问题。
UsageChartScreen.kt — modelColors: 8色 → 12色 (Red/Pink/Purple/Deep Purple/
Indigo/Blue/Cyan/Teal/Green/Orange/Brown/Blue Grey)

129
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,129 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
name: Unit Tests & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
java-version: '17'
distribution: 'temurin'
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: 'platforms;android-35 build-tools;35.0.0'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Upload test results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: unit-test-results
path: app/build/test-results/testDebugUnitTest/*.xml
retention-days: 14
- name: Run lint
run: ./gradlew lintDebug
- name: Upload lint results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: lint-results
path: app/build/reports/lint-results-debug.*
retention-days: 14
build-debug:
name: Debug APK
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
java-version: '17'
distribution: 'temurin'
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: 'platforms;android-35 build-tools;35.0.0'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Assemble Debug
run: ./gradlew assembleDebug
- name: Upload Debug APK
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: debug-apk
path: app/build/outputs/apk/debug/app-debug.apk
retention-days: 14
build-release:
name: Release APK (debug-signed)
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
java-version: '17'
distribution: 'temurin'
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: 'platforms;android-35 build-tools;35.0.0'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# 确保存在 debug keystore(某些 runner 镜像可能没有)
- name: Ensure debug keystore
run: |
KS="$HOME/.android/debug.keystore"
mkdir -p "$HOME/.android"
if [ ! -f "$KS" ]; then
keytool -genkeypair -v -keystore "$KS" -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
echo "Generated debug keystore"
else
echo "Debug keystore exists"
fi
# CI 无 release.jks;build.gradle.kts 自动 fallback 到 debug keystore
# 只验证编译 + 资源完整性,签名由 Release workflow 用 secrets 完成
- name: Assemble Release
run: ./gradlew assembleRelease
- name: Verify Release APK
run: |
APK=app/build/outputs/apk/release/app-release.apk
if [ ! -f "$APK" ]; then
echo "❌ Release APK not found"
exit 1
fi
echo "✅ Release APK size: $(du -h "$APK" | cut -f1)"
unzip -l "$APK" | grep -q "AndroidManifest.xml" || { echo "❌ AndroidManifest.xml missing"; exit 1; }
unzip -l "$APK" | grep -q "resources.arsc" || { echo "❌ resources.arsc missing"; exit 1; }
unzip -l "$APK" | grep -q "res/" || { echo "❌ res/ directory missing"; exit 1; }
echo "✅ APK integrity verified: manifest + arsc + res/ all present"
- name: Upload Release APK
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-apk
path: app/build/outputs/apk/release/app-release.apk
retention-days: 14

View File

@ -10,24 +10,31 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
java-version: '17'
distribution: 'temurin'
- uses: android-actions/setup-android@v3
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: 'platforms;android-35 build-tools;35.0.0'
- name: Decode Keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > release.jks
- name: Build Release APK
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
chmod +x gradlew
./gradlew assembleRelease
- name: Cleanup keystore (always run for security)
run: rm -f release.jks
if: always()
- name: Rename & Release
run: |
cp app/build/outputs/apk/release/app-release.apk "RainyToken-${GITHUB_REF#refs/tags/}.apk"
- uses: softprops/action-gh-release@v2
- uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
name: "🎉 RainyToken ${{ github.ref_name }}"
files: RainyToken-${{ github.ref_name }}.apk

4
.gitignore vendored
View File

@ -7,6 +7,7 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/
.DS_Store
/build
app/build/
@ -19,3 +20,6 @@ release.jks
.operit/
.kotlin/
*.jks
tmp_cc/
goal.md
/reference/

109
AGENTS.md
View File

@ -1,108 +1,5 @@
# 雨晴Token — AI 余额查询 APP
任务开始前必须先读取 taste.md(协作偏好),任务全程遵守其中约定,包括:修改后派发独立 subagent 审计(无阻断问题 + 无影响用户体验问题),修复后用同一个 subagent 复审。
## 项目概述
未经用户允许禁止提交推送
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go 两项服务的余额/配额。
DeepSeek 走 REST API,OpenCode Go 通过 OkHttp 抓取 dashboard HTML 解析 SSR hydration 数据。
APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
## 技术栈
- Kotlin 100% · Jetpack Compose + Material 3
- MVVM + Repository + `RefreshBalanceUseCase`(单一 UseCase,按 ServiceType 分发)
- Hilt + KSP(DI)
- Retrofit 2 + OkHttp 4 + Kotlinx Serialization
- DataStore(本地缓存)+ Android Keystore(凭据加密,AES-256 GCM)
- `minSdk=31`(Android 12+)
- WorkManager(计划中)
## 当前实现状态
**服务**:
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
- ❌ OpenCode Zen / 小米 MiMo — 未实现
**用量统计系统**:
- ✅ `UsageCache`(DataStore,~3700 条记录)— 全量 JSON 序列化 + 内存缓存(`@Volatile cachedAll`),仅在写入后失效
- ✅ `SyncUsageUseCase` — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合)
- ✅ `UsageViewModel` — `loadStatsInternal()` 单次 `getRecords()`→ 内存聚合 Overview/ModelStats/DailyStats,所有重操作包在 `withContext(Dispatchers.Default)` 避免主线程卡顿
- ✅ `UsageChartViewModel` — 图表粒度(5h/24h/今天/昨天/7天/当月/自定义日/月/范围),模型多选,3 张 Canvas 图表
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)
**页面导航**:
```
Dashboard → UsageDetail(图表) → UsageOverview(总统计)
↘ UsageData(原始数据)
```
**桌面小组件(Widget)**:
- 显示 OpenCode Go 三个用量窗口(5h/本周/本月)+ 进度条 + 重置时间 + DeepSeek 余额
- 右上角 ↻ 手动刷新按钮(后台广播 → `WidgetRefreshReceiver` → EntryPoints 获取 `RefreshBalanceUseCase`)
- 进度条颜色按百分比动态变化(<50% 草莓粉 / 50-80% 暖橙 / >80% 玫红)
- **MIUI Widget 适配**:`miuiWidget` 标识 → 可拖入负一屏;`miui.appwidget.action.APPWIDGET_UPDATE` 曝光刷新(划到即触发,20s 冷却);`@android:id/background` 根布局 ID(系统统一裁切圆角)
- **自动刷新**:`onUpdate()` 内缓存为空或超过 5 分钟冷却时自动发送 `WidgetRefreshReceiver` 广播
- **一键添桌面**:Dashboard 顶部栏 + 按钮 → `requestPinAppWidget`(有 fallback 到 `ACTION_APPWIDGET_PICK`)
## RemoteViews 兼容性红线
以下元素在 Widget 布局中**不可用**,会导致「载入出现问题」:
| ❌ 不可用 | ✅ 替代方案 |
|-----------|------------|
| `<Space>` | 透明 ProgressBar(`0dp + weight=1`) |
| `<View>` | ProgressBar 或 TextView |
| `<ImageView>` + 矢量 drawable | PNG(`drawable-nodpi`) |
| `<TextView>` `0dp+weight=1` 空串 spacer | ProgressBar spacer |
| `<TextView>` 固定 dp 宽度 + `gravity` | 仅固定 dp,不加 gravity |
## 关键命令
```bash
cd /data/user/0/com.ai.assistance.operit/files/workspace/Rainytoken
export ANDROID_HOME=$HOME/Android
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64
./gradlew assembleDebug
# APK: app/build/outputs/apk/debug/app-debug.apk
```
## 数据流
```
DashboardViewModel.refresh()
→ RefreshBalanceUseCase(service)
→ DeepSeekRepository.fetchBalance() / OpenCodeGoRepository.fetchBalance()
→ BalanceCache.put(service, result)
→ OpenCodeGoWidgetProvider.notifyDataChanged(context)
Dashboard 下拉刷新 → usageSyncTrigger++ → UsageViewModel.sync()
→ SyncUsageUseCase.fullSync() / incrementalSync()
→ OpenCodeUsageRepository.fetchPage(cursor) 逐页抓取
→ UsageCache.insertAll() → persist() → invalidateCache()
→ UsageViewModel.loadStats() → getRecords() → 内存聚合
Widget 刷新按钮:
↻ → PendingIntent.getBroadcast() → WidgetRefreshReceiver
→ EntryPoints → RefreshBalanceUseCase(DEEPSEEK + OPENCODE_GO)
→ notifyDataChanged()
MIUI 曝光刷新(用户划到负一屏/桌面):
→ miui.appwidget.action.APPWIDGET_UPDATE → onReceive() → onUpdate()
→ 读缓存渲染
→ 缓存为空/过期? → sendBroadcast(WidgetRefreshReceiver)
```
## 品牌色
| 用途 | 色值 |
|------|------|
| 主品牌草莓粉 | `#FF85A2` |
| 樱粉背景/点缀 | `#FFD1DC` |
| 浅粉背景 | `#FFF0F5` |
| 深暖文字 | `#3D2C35` |
| 暖灰辅助 | `#8A7A82` |
| 玫红(>80% 警示) | `#E91E63` |
| 暖橙(50-80%) | `#FFA726` |
深色模式下文字颜色由 `inkWarm()` / `inkMuted()` composable 自动切换(定义在 `Theme.kt`),静态资源通过 `drawable-night/` / `layout-night/` 适配。
派发子代理任务(独立审计/探索/通用 subagent)时,prompt 中涉及工作区文件的所有路径必须使用完整绝对路径(以工作区根目录为前缀)。子代理没有工作区附着上下文,相对路径会导致其找不到文件或无法执行。

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Rainy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

238
README.en.md Normal file
View File

@ -0,0 +1,238 @@
# RainyToken (雨晴Token)
> *"AI Balance & Usage at a Glance"*
[![CI](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml)
[![Release](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/release.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions)
[![Version](https://img.shields.io/github/v/release/CATMIAOZHI/Rainytoken?color=ff85a2)](https://github.com/CATMIAOZHI/Rainytoken/releases)
Android app for checking AI balance & usage — unified view of DeepSeek, OpenCode Go, CommandCode, Codex / ChatGPT, and Ollama balances and usage quotas. Pink-toned brand UI, with companion home screen widgets.
RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family tools.
---
## 📸 Screenshots
<p align="center">
<img src="docs/screenshots/dashboard-light.jpg" width="200" alt="Dashboard (light)" />
<img src="docs/screenshots/dashboard-dark.jpg" width="200" alt="Dashboard (dark)" />
<img src="docs/screenshots/detail.jpg" width="200" alt="Usage charts" />
<img src="docs/screenshots/ollama-detail.jpg" width="200" alt="Ollama model call counts" />
</p>
<p align="center">
<em>Dashboard (light) · Dashboard (dark) · Usage charts · Ollama model call counts</em>
</p>
<p align="center">
<img src="docs/screenshots/widget.jpg" width="300" alt="Home screen widget" />
<img src="docs/screenshots/ollama-card.jpg" width="200" alt="Ollama card" />
</p>
<p align="center">
<em>Home screen widget · Ollama home card</em>
</p>
<p align="center">
<img src="docs/screenshots/heatmap.jpg" width="200" alt="Token Activity" />
</p>
<p align="center">
<em>Token Activity</em>
</p>
---
## ✨ Features
| Feature | Description |
|------|------|
| 📊 **Dashboard** | DeepSeek balance (¥) + usage/balance cards for each service; OCGO/CCGO cards link directly to usage details; long-press & drag to reorder (persisted); pull-to-refresh globally · tablet-adaptive two-pane layout |
| 📈 **Usage charts** | 3 Canvas-drawn charts — amount spent / API request count / token consumption (dual data sources: OCGO & CCGO); UTC+0/UTC+8 timezone switching and custom day/month/range; automatic fallback (no data in last 5h → 12h → 7 days → current month); side-by-side on tablets |
| 🗓️ **Token Activity** | OCGO daily token heatmap (GitHub contribution-graph style): daily / weekly / cumulative views + year switching (last 365 days by default, or a calendar year); personal profile + activity insights (total requests / top 3 request hours) + yearly stats (total / peak / streaks); 6-level pink quantile scale, tap / slide to inspect |
| 📱 **Tablet adaptation** | Global `BoxWithConstraints` adaptive container width; ≥600dp dual-column cards, ≥700dp side-by-side charts; two-pane 35/65 split (Expanded mode); supports Android 13+ predictive back gestures |
| 📋 **Detailed data** | Paginated browsing of raw records with time + model filtering; tap to view full fields |
| 🔍 **Multi-granularity filters** | 5 hours / 12 hours (10-minute buckets) / 24 hours / today / yesterday / last 7 days / last 30 days / current month / custom day · month · range |
| 🏷️ **Model filtering** | Multi-select / single-select / select all, with dynamic legends that wrap automatically |
| 📱 **Home screen widget** | Check usage without opening the app; switch between four services (OCGO/CCGO/Codex/Ollama) + DeepSeek balance; top-left corner → open app, tap elsewhere to switch, ↻ to refresh; can be added to the At a Glance screen; auto-refreshes when scrolled into view (MIUI exposure refresh) |
| 🔄 **Auto sync** | Pull-to-refresh on home auto-syncs usage; auto full sync on startup when no cache; CCGO detail page supports manual clear & re-sync |
| 🌙 **Dark mode** | Fully adaptive — in-app text/icons/background switch automatically; widget adapts independently with a dark layout |
| ➕ **One-tap add to home screen** | Tap + inside the app to add the widget directly, no need to browse the system list; double confirmation + permission check |
| 💡 **Usage tips** | Home page shows a random operation tip (refreshed on each launch); settings page lists all 13 hidden tips |
| ⚡ **Room database** | Usage records stored in Room (indexed on workspaceId+timeCreated); DAO queries replace full JSON serialization; auto-migration from the legacy DataStore on first launch |
| 🎀 **Rainy pink theme** | Material Design 3 · Refined Rose primary #C9436C · Blush white background #FDEFF4 |
| 🔐 **Codex OAuth sign-in** | Headless OAuth PKCE: the app generates an authorization link → sign in in an external browser → paste the callback URL to complete authorization; no manual auth.json export needed |
| ⚡ **Codex one-tap usage activation** | Codex detail page can send a short request to the ChatGPT API to trigger usage tracking; model list fetched dynamically from models.dev and persisted, with manual refresh; response dialog is copyable |
| ⚡ **OCGO / Ollama one-tap usage activation** | OCGO detail page can send a request to `opencode.ai/zen/v1`, Ollama detail page to `ollama.com/v1` to trigger usage tracking; API keys are entered manually in settings; model lists fetched dynamically from models.dev |
| 🐛 **Debug logs** | In-app "Debug Logs" page showing detailed logs of Repository network requests, token refreshes, parse errors, etc.; no computer needed |
| 🌐 **Multilingual** | Simplified Chinese / 繁體中文 / English interface; one-tap switching in settings (follow system / Simplified / Traditional / English); two-way sync with the system "App languages" page on Android 13+; non-CN/EN system languages fall back to English; home screen app name and widget follow the language |
---
## 📦 Download
Download the latest APK from [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases).
> ⚠️ You need a DeepSeek API Key, OpenCode Go login credentials, CommandCode API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Cookie to fetch data.
---
## 🏗️ Architecture
```
┌──────────────────────────────────────────────────────────────────┐
│ Android App │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Compose UI (3-layer screens) │ │
│ │ Dashboard · Usage Charts · Totals │ │
│ │ Detail · Token Activity · Settings │ │
│ └──────────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼─────────────────────────────┐ │
│ │ ViewModel Layer (MVVM) │ │
│ │ DashboardVM · UsageVM · UsageChartVM │ │
│ │ · UsageDataVM (Hilt injected) │ │
│ └──────────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼─────────────────────────────┐ │
│ │ UseCase Layer │ │
│ │ RefreshBalanceUseCase (balance) │ │
│ │ SyncUsageUseCase / SyncCommandCodeUsageUseCase │ │
│ └──────────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼─────────────────────────────┐ │
│ │ Repository + Network │ │
│ │ DeepSeekApi (Retrofit) · OpenCodeGo web scraping │ │
│ │ · OpenCodeUsageRepository · CommandCodeUsageRepository │ │
│ │ · CodexRepository · OllamaRepository (OkHttp) │ │
│ └──────────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼─────────────────────────────┐ │
│ │ Local Storage │ │
│ │ BalanceCache (DataStore) │ │
│ │ UsageCache (Room, indexed on workspaceId+timeCreated) │ │
│ │ CredentialRepository (Keystore AES-256 GCM) │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
---
## 📁 Project Structure
```
Rainytoken/
├── app/src/main/java/com/rainy/token/
│ ├── data/
│ │ ├── cache/ # BalanceCache (DataStore)
│ │ ├── local/ # UsageCache (Room), UsageRecordEntity, UsageDao, UsageDatabase, ChartBucket
│ │ ├── remote/ # DeepSeekApi (Retrofit) + OpenCodeGo scraping + UsageRepository
│ │ └── repository/ # DeepSeek / OpenCodeGo / CommandCode / Codex / Ollama / Credential repositories
│ ├── domain/
│ │ ├── model/ # ServiceBalance, Credential, etc.
│ │ ├── service/ # ServiceType enum
│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase / SyncCommandCodeUsageUseCase
│ ├── ui/
│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen / UsageOverviewScreen / UsageDataScreen
│ │ ├── heatmap/ # Token Activity heatmap (HeatmapScreen / HeatmapCanvas / HeatmapViewModel)
│ │ ├── servicedetail/ # Service detail (ServiceDetailScreen)
│ │ ├── settings/ # Settings / credential editing (SettingsScreen / CredentialEditScreen)
│ │ ├── webview/ # Web login / Codex OAuth (WebViewLoginScreen / CodexOAuthScreen)
│ │ ├── widget/ # Home screen widget (OpenCodeGoWidgetProvider)
│ │ ├── components/ # ServiceIcon / StatusChip, etc.
│ │ ├── theme/ # Rainy pink theme (StrawberryPink / InkMuted)
│ │ └── RainyTokenNavHost.kt # Navigation routes
│ └── di/ # Hilt modules
├── gradle/libs.versions.toml # Version Catalog dependency management
├── build.gradle.kts # Project-level configuration
└── settings.gradle.kts # Project settings
```
---
## 🛠️ Build
### Option 1: Android Studio (recommended)
1. Clone the repository: `git clone https://github.com/CATMIAOZHI/Rainytoken.git`
2. Open the project in Android Studio
3. Sync Gradle, connect a device, and hit Run ▶️
### Option 2: Command line
```bash
# Run unit tests
./gradlew testDebugUnitTest
# Build a Debug APK
./gradlew assembleDebug
# APK output: app/build/outputs/apk/debug/app-debug.apk
# Build a Release APK
./gradlew assembleRelease
```
<details>
<summary>🔧 ARM64 environment notes (optional)</summary>
The project bundles an ARM64 AAPT2 binary, which is enabled automatically in ARM64 environments such as Proot/Termux:
```bash
chmod +x ./setup_android_env.sh
./setup_android_env.sh
```
The script configures `$ANDROID_HOME` and uses the project-bundled ARM64 build-tools.
**Extra Release build configuration**: Under ARM64 Proot, AGP 9.0 passes `--resource-path-shortening-map=<path>` (equals-sign syntax) to `optimizeReleaseResources`, which the ARM64 AAPT2 does not accept. Configure the AAPT2 wrapper in `~/.gradle/gradle.properties` (global, not committed to the repository):
```properties
android.aapt2FromMavenOverride=/path/to/android-aapt2-wrapper/aapt2
```
The wrapper script splits the equals-sign syntax into two separate space-delimited argv entries. The project's `build.gradle.kts` includes a built-in `guardReleaseResources` task as a build safeguard (falls back to the linked `.ap_` automatically when the optimized `.ap_` is missing).
> On x86_64 environments (GitHub Actions / regular Linux), the official AAPT2 is used automatically — no extra steps required.
</details>
---
## 📦 Dependency Management
The project manages all dependencies with a Gradle Version Catalog (`gradle/libs.versions.toml`).
| Dependency | Purpose |
|------|------|
| `androidx.compose:compose-bom` | Jetpack Compose BOM |
| `androidx.navigation:navigation-compose` | Page navigation |
| `com.squareup.retrofit2:retrofit` | DeepSeek REST API |
| `com.squareup.okhttp3:okhttp` | OpenCode Go web scraping |
| `org.jetbrains.kotlinx:kotlinx-serialization-json` | JSON serialization |
| `androidx.room:room-runtime` | Local usage database (Room) |
| `androidx.datastore:datastore-preferences` | Local caching |
| `com.google.dagger:hilt-android` | Dependency injection |
| `com.google.devtools.ksp:symbol-processing-api` | KSP annotation processing |
---
## 🔒 Security
- ✅ API keys / session credentials are stored in the **Android Keystore** (AES-256 GCM encrypted)
- ✅ Network requests are only sent to the official DeepSeek / OpenCode APIs
- ✅ `allowBackup=false`: encrypted credentials and usage data are excluded from system backups, preventing undecryptable ciphertext when restoring on a new device
- ✅ Fixed signing key — every Release can be installed over the previous version
- ✅ Signing keys and passwords are stored encrypted in GitHub Secrets and decrypted during CI
---
## 🐱 About
RainyToken (雨晴Token) is the 4th member of the "Rainy Family" tools, maintained by [雨晴喵 (Rainy)](https://github.com/CATMIAOZHI), released alongside the [Rainy Family](https://github.com/CATMIAOZHI?tab=repositories):
- [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — fully offline Android local LLM inference server (Gemma + OpenAI-compatible API)
- [RainyScanner](https://github.com/CATMIAOZHI/RainyScanner) — Android QR scanner that neither intercepts nor redirects
- [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — fully local · zero network · biometric-protected TOTP authenticator
- **RainyToken** — AI Balance & Usage Quota Query (this project)
---
## 📄 License
MIT License © 2026 Rainy
---
<p align="center">RainyToken · the Rainy Family tools</p>

115
README.md
View File

@ -1,11 +1,44 @@
# 🌧️ 雨晴Token (RainyToken)
> *"AI 用量,尽在掌握"* 🐱✨
> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"*
[![CI](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml)
[![Release](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/release.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions)
[![Version](https://img.shields.io/github/v/release/CATMIAOZHI/Rainytoken?color=ff85a2)](https://github.com/CATMIAOZHI/Rainytoken/releases)
Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode、Codex / ChatGPT、Ollama 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Family tools.
Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
---
## 📸 截图
<p align="center">
<img src="docs/screenshots/dashboard-light.jpg" width="200" alt="仪表盘(亮色)" />
<img src="docs/screenshots/dashboard-dark.jpg" width="200" alt="仪表盘(深色)" />
<img src="docs/screenshots/detail.jpg" width="200" alt="用量图表" />
<img src="docs/screenshots/ollama-detail.jpg" width="200" alt="Ollama 模型调用次数" />
</p>
<p align="center">
<em>仪表盘(亮色) · 仪表盘(深色) · 用量图表 · Ollama 模型调用次数</em>
</p>
<p align="center">
<img src="docs/screenshots/widget.jpg" width="300" alt="桌面小组件" />
<img src="docs/screenshots/ollama-card.jpg" width="200" alt="Ollama 卡片" />
</p>
<p align="center">
<em>桌面小组件 · Ollama 首页卡片</em>
</p>
<p align="center">
<img src="docs/screenshots/heatmap.jpg" width="200" alt="Token 活动" />
</p>
<p align="center">
<em>Token 活动</em>
</p>
---
@ -13,17 +46,25 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
| 特性 | 说明 |
|------|------|
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ 下拉全局刷新 |
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗 |
| 📊 **仪表盘** | DeepSeek 余额(¥)+ 各服务用量/余额卡片;OCGO/CCGO 卡片可直达用量详情;长按拖动自由排序(持久化);下拉全局刷新 · 平板自适应双窗格布局 |
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换和自定义日/月/范围;自动降级(近5h无数据→12h→7天→当月);平板并排展示 |
| 🗓️ **Token活动** | OCGO每日 Token热力图(GitHub贡献图风格):每日 /每周 /累计三视图 +年份切换(默认最近 365 天,可切换自然年);个人资料 +活动洞察(总请求次数 /最多请求时段 Top 3)+年度统计(累计 /峰值 /连续天数);6 级粉色分位色阶,点击 /滑动查看 |
| 📱 **平板适配** | 全局 `BoxWithConstraints` 自适应容器宽度;≥600dp 卡片双列,≥700dp 图表并排;双窗格 35/65 左右分栏(Expanded 模式);支持 Android 13+ 预见性返回手势 |
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
| 🔍 **多粒度筛选** | 5小时 / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
| 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 最近30天 / 当月 / 自定义日·月·范围 |
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
| 📱 **桌面小组件** | 不打开 APP 也能看用量;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步 |
| 📱 **桌面小组件** | 不打开 APP 也能看用量;支持四服务切换(OCGO/CCGO/Codex/Ollama)+ DeepSeek 余额;左上角 > 进 APP、其他区域点切换、↻ 刷新;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 |
| 🌙 **深色模式** | 全局自适应 — App 内文字/图标/背景自动切换,小组件独立适配暗色布局 |
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻 |
| ⚡ **内存缓存** | DataStore 全量 JSON 仅反序列化一次,后续操作零 IO |
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
| 🎀 **雨晴粉主题** | Material Design 3 · 精致玫瑰粉 主色 #C9436C · 粉白背景 #FDEFF4 |
| 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json |
| ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 |
| ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 |
| 🐛 **调试日志** | APP 内「调试日志」页面,查看 Repository 网络请求、Token 刷新、解析错误等详细日志,无需连接电脑 |
| 🌐 **多语言** | 简体中文 / 繁體中文 / English 三语言界面;设置页一键切换(跟随系统 / 简体 / 繁体 / English);Android 13+ 与系统「应用语言」页双向同步;非中英系统语言自动回退英文;桌面 APP 名称与小组件跟随语言 |
---
@ -31,7 +72,7 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。
> ⚠️ 需要配置 DeepSeek API Key 和/或 OpenCode Go 登录凭据才能拉取数据。
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Cookie 才能拉取数据。
---
@ -43,7 +84,8 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Compose UI(3 层页面) │ │
│ │ 仪表盘 · 用量图表 · 总统计 · 详细数据 · 设置│ │
│ │ 仪表盘 · 用量图表 · 总统计 │ │
│ │ 详细数据 · Token活动 · 设置 │ │
│ └────────────────────┬────────────────────────┘ │
│ │ │
│ ┌────────────────────▼─────────────────────────┐ │
@ -55,19 +97,19 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
│ ┌────────────────────▼─────────────────────────┐ │
│ │ UseCase 层 │ │
│ │ RefreshBalanceUseCase(余额) │ │
│ │ SyncUsageUseCase(用量全量/增量同步) │ │
│ │ SyncUsageUseCase / SyncCommandCodeUsageUseCase│ │
│ └────────────────────┬────────────────────────┘ │
│ │ │
│ ┌────────────────────▼─────────────────────────┐ │
│ │ Repository + Network │ │
│ │ DeepSeekApi(Retrofit)· OpenCodeGo 网页抓取 │ │
│ │ · OpenCodeUsageRepository(分页翻页) │ │
│ │ DeepSeekApi(Retrofit)· OpenCodeGo 网页抓取
│ │ · OpenCodeUsageRepository · CommandCodeUsageRepository · CodexRepository · OllamaRepository(OkHttp)│ │
│ └────────────────────┬────────────────────────┘ │
│ │ │
│ ┌────────────────────▼─────────────────────────┐ │
│ │ 本地存储 │ │
│ │ BalanceCache(DataStore) │ │
│ │ UsageCache(DataStore + @Volatile 内存缓存) │ │
│ │ UsageCache(Room,indexed on workspaceId+timeCreated) │ │
│ │ CredentialRepository(Keystore AES-256 GCM) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
@ -82,15 +124,19 @@ Rainytoken/
├── app/src/main/java/com/rainy/token/
│ ├── data/
│ │ ├── cache/ # BalanceCache(DataStore)
│ │ ├── local/ # UsageCache(DataStore + 内存缓存)、UsageRecord、ChartBucket
│ │ ├── remote/ # DeepSeekApi(Retrofit)+ OpenCodeGo 抓取 + OpenCodeUsageRepository
│ │ └── repository/ # DeepSeekRepository / OpenCodeGoRepository / CredentialRepository
│ │ ├── local/ # UsageCache(Room)、UsageRecordEntity、UsageDao、UsageDatabase、ChartBucket
│ │ ├── remote/ # DeepSeekApi(Retrofit)+ OpenCodeGo 抓取 + UsageRepository
│ │ └── repository/ # DeepSeek / OpenCodeGo / CommandCode / Codex / Ollama / Credential Repository
│ ├── domain/
│ │ ├── model/ # ServiceBalance、Credential 等
│ │ ├── service/ # ServiceType 枚举
│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase
│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase / SyncCommandCodeUsageUseCase
│ ├── ui/
│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen(图表)/ UsageOverviewScreen(总统计)/ UsageDataScreen(原始数据)
│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen / UsageOverviewScreen / UsageDataScreen
│ │ ├── heatmap/ # Token活动热力图(HeatmapScreen / HeatmapCanvas / HeatmapViewModel)
│ │ ├── servicedetail/ # 服务详情(ServiceDetailScreen)
│ │ ├── settings/ # 设置 /凭据编辑(SettingsScreen / CredentialEditScreen)
│ │ ├── webview/ # 网页登录 / Codex OAuth(WebViewLoginScreen / CodexOAuthScreen)
│ │ ├── widget/ # 桌面小组件(OpenCodeGoWidgetProvider)
│ │ ├── components/ # ServiceIcon / StatusChip 等
│ │ ├── theme/ # 雨晴粉主题(StrawberryPink / InkMuted)
@ -112,6 +158,9 @@ Rainytoken/
### 方式二:命令行
```bash
# 运行单元测试
./gradlew testDebugUnitTest
# 构建 Debug APK
./gradlew assembleDebug
# APK 输出:app/build/outputs/apk/debug/app-debug.apk
@ -129,6 +178,12 @@ chmod +x ./setup_android_env.sh
./setup_android_env.sh
```
该脚本会配置 `$ANDROID_HOME` 并使用项目内置的 ARM64 build-tools。
**Release 构建额外配置**:ARM64 Proot 下 AGP 9.0 的 `optimizeReleaseResources` 传入 `--resource-path-shortening-map=<path>` 等号参数,ARM64 AAPT2 不接受此语法,需在 `~/.gradle/gradle.properties`(全局,不提交项目仓库)中配置 AAPT2 wrapper:
```properties
android.aapt2FromMavenOverride=/path/to/android-aapt2-wrapper/aapt2
```
wrapper 脚本将等号形式拆分为空格分隔的两个独立 argv。项目 `build.gradle.kts` 中已内置 `guardReleaseResources` 任务作为构建防护(optimized `.ap_` 缺失时自动 fallback 到 linked `.ap_`)。
> 在 x86_64 环境(GitHub Actions / 普通 Linux)中会自动走官方 AAPT2,无需额外操作。
</details>
@ -145,7 +200,7 @@ chmod +x ./setup_android_env.sh
| `com.squareup.retrofit2:retrofit` | DeepSeek REST API |
| `com.squareup.okhttp3:okhttp` | OpenCode Go 网页抓取 |
| `org.jetbrains.kotlinx:kotlinx-serialization-json` | JSON 序列化 |
| `org.jsoup:jsoup` | HTML 解析(SSR hydration 数据提取) |
| `androidx.room:room-runtime` | 用量数据本地数据库(Room) |
| `androidx.datastore:datastore-preferences` | 本地缓存 |
| `com.google.dagger:hilt-android` | 依赖注入 |
| `com.google.devtools.ksp:symbol-processing-api` | KSP 注解处理 |
@ -156,20 +211,22 @@ chmod +x ./setup_android_env.sh
- ✅ API Key / Session 凭据存入 **Android Keystore**(AES-256 GCM 加密)
- ✅ 网络请求仅向 DeepSeek / OpenCode 官方 API 发出
- ✅ `allowBackup="false"`,拒绝应用数据被备份
- ✅ `allowBackup=false`:凭据密文、用量数据均不参与系统备份,避免换机恢复后密文无法解密
- ✅ 签名密钥固定,每次 Release 可覆盖安装
- ✅ GitHub Secrets 加密存储签名密钥,CI 中解码使用
- ✅ GitHub Secrets 加密存储签名密钥及密码,CI 中解码使用
---
## 🐱 关于
RainyToken 由 [雨晴喵](https://github.com/CATMIAOZHI) 与水晴共同打造,属于「雨晴系列」工具之一:
RainyToken(雨晴Token)是「雨晴系列」的第 4 个成员,由 [雨晴喵](https://github.com/CATMIAOZHI) 开发维护,随 [雨晴系列](https://github.com/CATMIAOZHI?tab=repositories) 发布:
- [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — 手机本地 LLM 推理服务器(Gemma + OpenAI 兼容 API)
- [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — 纯离线 Android 本地 LLM 推理服务器(Gemma + OpenAI 兼容 API)
- [RainyScanner](https://github.com/CATMIAOZHI/RainyScanner) — 不拦截不跳转的 Android 扫码工具
- [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — 纯本地生物识别保护的 TOTP 验证器
- **RainyToken** — AI 余额用量查询(本项目)
- [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — 纯本地 · 零联网 · 生物识别保护的 TOTP 验证器
- **RainyToken** — AI 余额与用量查询 APP | AI Balance & Usage Quota Query(本项目)
> 守护每一分 AI 算力预算 💖
---
@ -179,4 +236,4 @@ MIT License © 2026 Rainy
---
<p align="center">💖 Made with love by 雨晴喵</p>
<p align="center">RainyToken · the Rainy Family tools</p>

View File

@ -1,3 +1,5 @@
import java.io.File
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
@ -14,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionCode = 14
versionName = "1.6.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -25,10 +27,40 @@ android {
signingConfigs {
create("release") {
storeFile = rootProject.file("release.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "RainyToken2026!"
keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "rainy"
keyPassword = System.getenv("KEY_PASSWORD") ?: "RainyToken2026!"
// CI 环境没有 release.jks,自动 fallback 到 debug keystore
// Release workflow 通过 Secret 注入 release.jks
val keystoreFile = rootProject.file("release.jks")
if (keystoreFile.exists()) {
storeFile = keystoreFile
// 凭据必须通过环境变量注入;缺失或为空时直接报错,不提供任何隐式 fallback
// 注意:GitHub Actions 中未设置的 secret 会被替换为空字符串(非 null),
// 因此用 takeIf { isNotBlank() } 同时防御 null 和空字符串
storePassword = System.getenv("KEYSTORE_PASSWORD")
?.takeIf { it.isNotBlank() }
?: throw GradleException("KEYSTORE_PASSWORD env var not set or empty — cannot sign release")
keyAlias = System.getenv("KEYSTORE_ALIAS")
?.takeIf { it.isNotBlank() }
?: throw GradleException("KEYSTORE_ALIAS env var not set or empty — cannot sign release")
keyPassword = System.getenv("KEY_PASSWORD")
?.takeIf { it.isNotBlank() }
?: throw GradleException("KEY_PASSWORD env var not set or empty — cannot sign release")
} else {
// 仅在 CI 环境中 fallback 到 debug keystore(用于编译/资源完整性验证)
// 本地构建缺少 release.jks 时直接报错,避免静默生成 debug 签名的 Release APK
if (System.getenv("CI") != null) {
val debugKeystore = file("${System.getProperty("user.home")}/.android/debug.keystore")
storeFile = debugKeystore
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
} else {
throw GradleException(
"release.jks 不存在,且当前不是 CI 环境。\n" +
"正式 Release 构建需要 release.jks 密钥库文件。\n" +
"如需本地验证编译,请设置环境变量 CI=true 或使用 assembleDebug。"
)
}
}
}
}
@ -45,12 +77,18 @@ android {
)
}
}
// noCompress workaround removed — no longer needed
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
androidResources {
// Android 13+ 应用级语言设置:根据 values-* 目录自动生成 locales_config
generateLocaleConfig = true
}
packaging {
resources {
@ -59,6 +97,84 @@ android {
}
}
// ═══════════════════════════════════════════════════════
// AGP 9.0 ARM64 Proot: Release resource guard + fallback
//
// 根因:AGP 9.0 的 optimizeReleaseResources 传入 --resource-path-shortening-map=<path>
// 等号形式,ARM64 AAPT2 不接受此语法(只接受空格分隔),exit code=1 但 AGP 不检查,
// 导致 optimized .ap_ 缺失,packageRelease 生成无资源残缺 APK。
//
// 修复链:
// 1. ~/.gradle/gradle.properties → android.aapt2FromMavenOverride 指向 wrapper
// wrapper 将 --resource-path-shortening-map=<path> 拆分为两个独立 argv
// 2. 本任务作为构建防护:optimizeReleaseResources 之后验证 optimized .ap_ 完整性,
// 若缺失则复制 linked .ap_ 作为 fallback(功能正确,仅跳过 path shortening)
// 3. packageRelease 依赖此任务,确保消费到完整资源
// ═══════════════════════════════════════════════════════
if (System.getProperty("os.arch") == "aarch64") {
tasks.register("guardReleaseResources") {
dependsOn("optimizeReleaseResources")
doLast {
val linkedAp = layout.buildDirectory
.file("intermediates/linked_resources_binary_format/release/processReleaseResources/linked-resources-binary-format-release.ap_")
.get().asFile
val optimizedDir = layout.buildDirectory
.dir("intermediates/optimized_processed_res/release/optimizeReleaseResources")
.get().asFile
val optimizedAp = File(optimizedDir, "resources-release-optimize.ap_")
if (optimizedAp.exists() && optimizedAp.length() > 0) {
// optimized .ap_ 存在,验证内容完整性
val zipList = providers.exec {
commandLine("unzip", "-l", optimizedAp.absolutePath)
isIgnoreExitValue = true
}.standardOutput.asText.get()
val hasManifest = zipList.contains("AndroidManifest.xml")
val hasArsc = zipList.contains("resources.arsc")
val hasRes = zipList.contains("res/")
if (hasManifest && hasArsc && hasRes) {
logger.lifecycle("GuardReleaseResources: optimized .ap_ OK (${optimizedAp.length()} bytes)")
return@doLast
}
logger.warn("GuardReleaseResources: optimized .ap_ incomplete (manifest=$hasManifest arsc=$hasArsc res=$hasRes)")
} else {
logger.warn("GuardReleaseResources: optimized .ap_ missing or empty")
}
// Fallback: copy linked .ap_ → optimized .ap_
if (!linkedAp.exists() || linkedAp.length() == 0L) {
throw GradleException("GuardReleaseResources: linked .ap_ also missing or empty — cannot recover")
}
optimizedDir.mkdirs()
linkedAp.copyTo(optimizedAp, overwrite = true)
// Verify the copy
val verifyList = providers.exec {
commandLine("unzip", "-l", optimizedAp.absolutePath)
isIgnoreExitValue = true
}.standardOutput.asText.get()
val vManifest = verifyList.contains("AndroidManifest.xml")
val vArsc = verifyList.contains("resources.arsc")
val vRes = verifyList.contains("res/")
if (!vManifest || !vArsc || !vRes) {
throw GradleException("GuardReleaseResources: fallback copy verification failed (manifest=$vManifest arsc=$vArsc res=$vRes)")
}
logger.lifecycle("GuardReleaseResources: fallback — copied linked .ap_ → optimized .ap_ (${optimizedAp.length()} bytes, path shortening skipped)")
}
}
// packageRelease 必须依赖此防护任务
project.tasks.matching { it.name == "packageRelease" }.configureEach {
dependsOn("guardReleaseResources")
}
}
// Force ARM64 AAPT2 in Proot environment (local only; GitHub Actions x86_64 uses default)
if (System.getProperty("os.arch") == "aarch64") {
configurations.all {
@ -74,6 +190,7 @@ dependencies {
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidsvg)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
@ -85,6 +202,7 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material3.window.size)
// Navigation
implementation(libs.androidx.navigation.compose)
@ -95,7 +213,6 @@ dependencies {
implementation(libs.okhttp.logging.interceptor)
implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit.kotlinx.serialization.converter)
implementation(libs.jsoup)
// DataStore
implementation(libs.androidx.datastore.preferences)
@ -108,6 +225,11 @@ dependencies {
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// Test
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@ -1,24 +0,0 @@
package com.java.myapplication
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.java.myapplication", appContext.packageName)
}
}

View File

@ -3,12 +3,13 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".RainyTokenApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@ -33,6 +34,15 @@
android:name=".ui.widget.WidgetRefreshReceiver"
android:exported="false" />
<!-- 每日自动签到:零点闹钟广播 + 开机后排程(Hilt 经 EntryPoint 注入,无 @AndroidEntryPoint) -->
<receiver
android:name=".ui.dashboard.autocheckin.AutoCheckinReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- OpenCode Go 小组件(MIUI Widget) -->
<receiver
android:name=".ui.widget.OpenCodeGoWidgetProvider"
@ -66,6 +76,16 @@
android:name="miuiWidgetVersion"
android:value="1" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@ -1,5 +1,6 @@
package com.rainy.token
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@ -14,12 +15,17 @@ import dagger.hilt.android.AndroidEntryPoint
*
* 布局策略:
* - 外层只套 RainyTokenTheme(统一品牌色 + 字体)
* - 再套 RainyBackground(樱粉渐变背景,全局共享)
* - 再套 RainyBackground(精致粉调渐变背景,全局共享)
* - NavHost 在背景之上,每个页面自己用 Scaffold 处理 TopAppBar 和 padding
* - **不**在外层再套 Scaffold —— 避免双 Scaffold 嵌套的 padding 计算混乱
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun attachBaseContext(newBase: Context) {
// 应用内语言偏好在此层生效:recreate() 后资源按所选语言重新解析
super.attachBaseContext(com.rainy.token.util.LocaleManager.wrapContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()

View File

@ -12,6 +12,12 @@ import dagger.hilt.android.HiltAndroidApp
*/
@HiltAndroidApp
class RainyTokenApplication : Application() {
override fun attachBaseContext(base: Context) {
// 应用内语言偏好(跟随系统 / 中文 / English)在 Application 层生效,
// 保证 appContext 与系统级回调(如小组件)之外的代码都使用所选语言。
super.attachBaseContext(com.rainy.token.util.LocaleManager.wrapContext(base))
}
override fun onCreate() {
super.onCreate()
appContext = applicationContext

View File

@ -6,8 +6,10 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.rainy.token.data.repository.RefreshWriteSession
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.MapSerializer
@ -19,48 +21,204 @@ import kotlinx.serialization.json.Json
*
* 计划 7.1:断网时 UI 仍可展示 stale 数据。DataStore 文件名 `balance_cache`。
*
* 存储结构:JSON 序列化的 `Map<ServiceType, CachedBalance>`。
* 存储结构:
* - `balance_cache_v3`:JSON 序列化的 `Map<ServiceType, CachedBalance>`,供单账号服务
* (以及多账号服务的默认账号镜像,兼容旧读取路径)。
* - `balance_cache_accounts_v1`:JSON 序列化的 `Map<String, CachedBalance>`,复合键
* `"{service.storageKey}:{accountId}"`,存放多账号服务每个账号的独立缓存。
*/
class BalanceCache(
private val dataStore: DataStore<Preferences>,
private val json: Json = DefaultJson
) {
// v2: 2026-06 重构 extras 字段名(rollingUsagePercent → rolling.pct 等)。
// 升 v2 让旧版缓存失效,避免显示 0%。
private val cacheKey = stringPreferencesKey("balance_cache_v2")
// v3: 2026-07 统一 Codex amount 为 usedPct(之前是 remainingPct),旧缓存失效
private val cacheKey = stringPreferencesKey("balance_cache_v3")
// v1: 2026-10 多账号服务按账号独立缓存
private val accountsCacheKey = stringPreferencesKey("balance_cache_accounts_v1")
suspend fun getAll(): Map<ServiceType, CachedBalance> {
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyMap()
return runCatching {
val map = json.decodeFromString(
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
raw
)
map
}.getOrDefault(emptyMap())
return decode(raw)
}
suspend fun get(service: ServiceType): CachedBalance? = getAll()[service]
suspend fun put(service: ServiceType, balance: ServiceBalance) {
val current = getAll().toMutableMap()
current[service] = CachedBalance(balance = balance, fetchedAt = System.currentTimeMillis())
persist(current)
/** 读取缓存:无 [accountId] 时读旧按服务缓存;指定账号时读账号分片缓存。 */
suspend fun get(service: ServiceType, accountId: String?): CachedBalance? =
if (accountId.isNullOrBlank()) {
getAll()[service]
} else {
getAllAccounts()[accountKey(service, accountId)]
}
/** 所有按账号分片的缓存,复合键 `"{service.storageKey}:{accountId}"`。 */
suspend fun getAllAccounts(): Map<String, CachedBalance> {
val raw = dataStore.data.map { it[accountsCacheKey] }.first() ?: return emptyMap()
return decodeAccounts(raw)
}
/** 读取指定账号的缓存(不含默认账号的旧镜像回退,由调用方处理)。 */
suspend fun getAccountCached(service: ServiceType, accountId: String): CachedBalance? =
getAllAccounts()[accountKey(service, accountId)]
/**
* 写入余额缓存。
*
* - [accountId] 为 null:写入旧的按服务缓存(单账号服务 / 多账号默认账号镜像)。
* - [accountId] 非 null:写入按账号缓存。
* 在 [RefreshWriteSession] 上下文中一律只暂存,由会话提交时按快照账号路由。
*/
suspend fun put(
service: ServiceType,
balance: ServiceBalance,
accountId: String? = null
) {
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
session.stageBalance(service, balance)
return
}
val cached = CachedBalance(balance = balance, fetchedAt = System.currentTimeMillis())
if (accountId.isNullOrBlank()) {
putCached(service, cached)
} else {
putAccountCached(service, accountId, cached)
}
}
/** 恢复一份已有缓存并保留原 fetchedAt;仅供凭据测试安全回滚使用。 */
internal suspend fun putCached(service: ServiceType, cachedBalance: CachedBalance) {
dataStore.edit { prefs ->
val updated = decode(prefs[cacheKey]).toMutableMap()
updated[service] = cachedBalance
prefs[cacheKey] = encode(updated)
}
}
/** 以账号复合键写入缓存(保留原 fetchedAt)。 */
internal suspend fun putAccountCached(
service: ServiceType,
accountId: String,
cachedBalance: CachedBalance
) {
val key = accountKey(service, accountId)
dataStore.edit { prefs ->
val updated = decodeAccounts(prefs[accountsCacheKey]).toMutableMap()
updated[key] = cachedBalance
prefs[accountsCacheKey] = encodeAccounts(updated)
}
}
/** 删除单个服务的旧结构缓存。凭据新增、替换或删除时由 CredentialRepository 调用。 */
suspend fun remove(service: ServiceType) {
dataStore.edit { prefs ->
var changed = false
val current = decode(prefs[cacheKey])
if (service in current) {
val updated = current.toMutableMap()
updated.remove(service)
if (updated.isEmpty()) {
prefs.remove(cacheKey)
} else {
prefs[cacheKey] = encode(updated)
}
changed = true
}
// 顺带清理该服务全部账号缓存(多账号服务删除整个服务时)
val accounts = decodeAccounts(prefs[accountsCacheKey])
val prefix = service.storageKey + ":"
val kept = accounts.filterKeys { !it.startsWith(prefix) }
if (kept.size != accounts.size) {
if (kept.isEmpty()) {
prefs.remove(accountsCacheKey)
} else {
prefs[accountsCacheKey] = encodeAccounts(kept)
}
changed = true
}
if (!changed) return@edit
}
}
/**
* 仅删除旧结构镜像缓存([balance_cache_v3] 中该服务的条目),不动账号分片缓存。
*
* 多账号服务切换/删除默认账号时调用:旧镜像属于旧默认账号,若不清除,新默认账号
* 在尚无独立分片缓存时会回退读到旧默认账号的余额(缓存串号)。
*/
suspend fun removeLegacy(service: ServiceType) {
dataStore.edit { prefs ->
val current = decode(prefs[cacheKey])
if (service !in current) return@edit
val updated = current.toMutableMap()
updated.remove(service)
if (updated.isEmpty()) {
prefs.remove(cacheKey)
} else {
prefs[cacheKey] = encode(updated)
}
}
}
/** 删除单个账号的缓存(默认账号的旧结构镜像由调用方另行清理)。 */
suspend fun removeAccount(service: ServiceType, accountId: String) {
val key = accountKey(service, accountId)
dataStore.edit { prefs ->
val current = decodeAccounts(prefs[accountsCacheKey])
if (key !in current) return@edit
val updated = current.toMutableMap()
updated.remove(key)
if (updated.isEmpty()) {
prefs.remove(accountsCacheKey)
} else {
prefs[accountsCacheKey] = encodeAccounts(updated)
}
}
}
suspend fun clear() {
dataStore.edit { it.remove(cacheKey) }
dataStore.edit {
it.remove(cacheKey)
it.remove(accountsCacheKey)
}
}
private suspend fun persist(map: Map<ServiceType, CachedBalance>) {
val raw = json.encodeToString(
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
map
)
dataStore.edit { it[cacheKey] = raw }
private fun accountKey(service: ServiceType, accountId: String) =
"${service.storageKey}:$accountId"
private fun decode(raw: String?): Map<ServiceType, CachedBalance> {
if (raw == null) return emptyMap()
return runCatching {
json.decodeFromString(
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
raw
)
}.getOrDefault(emptyMap())
}
private fun encode(value: Map<ServiceType, CachedBalance>): String =
json.encodeToString(
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
value
)
private fun decodeAccounts(raw: String?): Map<String, CachedBalance> {
if (raw == null) return emptyMap()
return runCatching {
json.decodeFromString(
MapSerializer(String.serializer(), CachedBalance.serializer()),
raw
)
}.getOrDefault(emptyMap())
}
private fun encodeAccounts(value: Map<String, CachedBalance>): String =
json.encodeToString(
MapSerializer(String.serializer(), CachedBalance.serializer()),
value
)
companion object {
val DefaultJson = Json {
ignoreUnknownKeys = true

View File

@ -0,0 +1,51 @@
package com.rainy.token.data.debug
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.ConcurrentLinkedDeque
/**
* APP 内调试日志,内存 ring buffer(默认 200 条)。
* 各 Repository 在关键路径写入,用户可在设置页 → 调试日志 查看。
*/
object DebugLog {
enum class Level(val label: String) { INFO("INFO"), WARN("WARN"), ERROR("ERROR") }
data class Entry(
val timestamp: Long,
val tag: String,
val level: Level,
val message: String
) {
override fun toString(): String {
val fmt = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
return "${fmt.format(Calendar.getInstance().apply { timeInMillis = this@Entry.timestamp }.time)} ${level.label}/$tag: $message"
}
}
private const val MAX_SIZE = 200
private val deque = ConcurrentLinkedDeque<Entry>()
private val _entries = MutableStateFlow<List<Entry>>(emptyList())
val entries: StateFlow<List<Entry>> = _entries.asStateFlow()
fun log(tag: String, level: Level, message: String) {
val entry = Entry(System.currentTimeMillis(), tag, level, message)
deque.addFirst(entry)
while (deque.size > MAX_SIZE) deque.pollLast()
_entries.value = deque.toList()
}
fun i(tag: String, message: String) = log(tag, Level.INFO, message)
fun w(tag: String, message: String) = log(tag, Level.WARN, message)
fun e(tag: String, message: String) = log(tag, Level.ERROR, message)
fun clear() {
deque.clear()
_entries.value = emptyList()
}
}

View File

@ -1,5 +1,10 @@
package com.rainy.token.data.local
import androidx.annotation.StringRes
import com.rainy.token.R
import java.time.Instant
import java.time.ZoneOffset
/**
* 图表数据点——按时间桶聚合后的单条数据。
*/
@ -22,40 +27,78 @@ data class ModelBucket(
val outputTokens: Long
)
/** 时间粒度 */
enum class ChartGranularity(val label: String) {
LAST_5H_HOURLY("最近5小时"),
LAST_24H_HOURLY("最近24小时"),
TODAY_HOURLY("今天"),
YESTERDAY_HOURLY("昨天"),
LAST_7D_DAILY("最近7天"),
THIS_MONTH_DAILY("当月"),
CUSTOM_DAY_HOURLY("自定义日"),
CUSTOM_MONTH_DAILY("自定义月"),
CUSTOM_RANGE_DAILY("自定义日期")
/** 时间粒度(label 用字符串资源 ID,UI 层按当前语言解析) */
enum class ChartGranularity(@StringRes val labelRes: Int) {
LAST_5H_HOURLY(R.string.time_last_5h),
LAST_12H_10MIN(R.string.time_last_12h),
LAST_24H_HOURLY(R.string.time_last_24h),
TODAY_HOURLY(R.string.time_today),
YESTERDAY_HOURLY(R.string.time_yesterday),
LAST_7D_DAILY(R.string.time_last_7d),
LAST_30D_DAILY(R.string.time_last_30d),
THIS_MONTH_DAILY(R.string.time_this_month),
CUSTOM_DAY_HOURLY(R.string.time_custom_day),
CUSTOM_MONTH_DAILY(R.string.time_custom_month),
CUSTOM_RANGE_DAILY(R.string.time_custom_range)
}
/** 聚合工具 */
object ChartAggregator {
/**
* 按小时聚合(用于5小时/当天)。
* hourStart = timeCreated / 3600_000 * 3600_000
* 按 10 分钟聚合(用于最近12小时)。
* @param offsetHours 时区偏移。
*/
fun aggregateHourly(records: List<UsageRecord>): List<ChartBucket> {
fun aggregate10Min(records: List<UsageRecord>, offsetHours: Int = 0): List<ChartBucket> {
val offset = if (offsetHours == 0) ZoneOffset.UTC else ZoneOffset.ofHours(offsetHours)
return records
.groupBy { it.timeCreated / 3600_000L * 3600_000L }
.groupBy {
val ldt = Instant.ofEpochMilli(it.timeCreated).atOffset(offset).toLocalDateTime()
val min = ldt.minute / 10 * 10
ldt.withMinute(min).withSecond(0).withNano(0).atOffset(offset).toInstant().toEpochMilli()
}
.map { (ts, recs) -> bucketOf(ts, recs) }
.sortedBy { it.ts }
}
/**
* 按小时聚合。
* @param offsetHours 时区偏移(0=UTC,8=UTC+8),影响桶边界对齐。
*/
fun aggregateHourly(records: List<UsageRecord>, offsetHours: Int = 0): List<ChartBucket> {
if (offsetHours == 0) {
return records
.groupBy { it.timeCreated / 3600_000L * 3600_000L }
.map { (hourTs, recs) -> bucketOf(hourTs, recs) }
.sortedBy { it.ts }
}
val offset = ZoneOffset.ofHours(offsetHours)
return records
.groupBy {
val ldt = Instant.ofEpochMilli(it.timeCreated).atOffset(offset).toLocalDateTime()
ldt.withMinute(0).withSecond(0).withNano(0).atOffset(offset).toInstant().toEpochMilli()
}
.map { (hourTs, recs) -> bucketOf(hourTs, recs) }
.sortedBy { it.ts }
}
/**
* 按天聚合(用于当月/自定义月)。
* dayStart = timeCreated / 86400_000 * 86400_000
* @param offsetHours 时区偏移(0=UTC,8=UTC+8),影响桶边界对齐。
*/
fun aggregateDaily(records: List<UsageRecord>): List<ChartBucket> {
fun aggregateDaily(records: List<UsageRecord>, offsetHours: Int = 0): List<ChartBucket> {
if (offsetHours == 0) {
return records
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
.map { (dayTs, recs) -> bucketOf(dayTs, recs) }
.sortedBy { it.ts }
}
val offset = ZoneOffset.ofHours(offsetHours)
return records
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
.groupBy {
val localDate = Instant.ofEpochMilli(it.timeCreated).atOffset(offset).toLocalDate()
localDate.atStartOfDay(offset).toInstant().toEpochMilli()
}
.map { (dayTs, recs) -> bucketOf(dayTs, recs) }
.sortedBy { it.ts }
}

View File

@ -0,0 +1,47 @@
package com.rainy.token.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Dispatchers
private val Context.chartSettingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "chart_settings"
)
private val chartSettingsScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** 图表页偏好设置持久化 */
class ChartSettingsStore(private val dataStore: DataStore<Preferences>) {
companion object {
private val KEY_USE_UTC8 = booleanPreferencesKey("use_utc8")
}
/** 流式读取偏好 */
val useUtc8Flow: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_USE_UTC8] ?: false
}
/** StateFlow 版本,UI 层 collectAsState 直接使用 */
val useUtc8State: StateFlow<Boolean> = useUtc8Flow
.stateIn(chartSettingsScope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false)
/** 写入偏好 */
suspend fun setUseUtc8(value: Boolean) {
dataStore.edit { it[KEY_USE_UTC8] = value }
}
}
/** 顶层 DataStore 委托 */
val Context.chartSettingsStore: ChartSettingsStore
get() = ChartSettingsStore(chartSettingsDataStore)

View File

@ -3,81 +3,87 @@ package com.rainy.token.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
/**
* 用量记录本地缓存(DataStore,非 Room)。
* 用量记录本地缓存(Room 实现)。
*
* 所有记录序列化为 JSON 列表存入 DataStore。
* 约 3700 条记录,每条约 300 字节 → ~1.1MB,DataStore 可承受。
* 接口与旧 DataStore 版本完全一致,内部改为 Room DAO 查询。
* 首次访问时自动从旧 DataStore JSON 迁移数据到 Room。
*/
class UsageCache(
private val context: Context,
private val dao: UsageDao,
private val dataStore: DataStore<Preferences>,
private val json: Json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
) {
private val cacheKey = stringPreferencesKey("usage_cache_v1")
private val migrationMutex = Mutex()
@Volatile private var migrated = false
// ── 内存缓存:避免每次都反序列化整个 JSON ──
@Volatile private var cachedAll: List<UsageRecord>? = null
@Volatile private var cachedIds: Set<String>? = null
/** 读取全量(优先内存缓存) */
private suspend fun loadAll(): List<UsageRecord> {
cachedAll?.let { return it }
val records = readFromStore()
cachedAll = records
cachedIds = records.map { it.id }.toSet()
return records
}
private suspend fun readFromStore(): List<UsageRecord> {
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyList()
return runCatching {
json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw)
}.getOrDefault(emptyList())
}
/** 写入后清空内存缓存 */
private fun invalidateCache() {
cachedAll = null
cachedIds = null
}
suspend fun getAll(): List<UsageRecord> = loadAll()
fun getAllFlow(): Flow<List<UsageRecord>> =
dataStore.data.map { prefs ->
val raw = prefs[cacheKey] ?: return@map emptyList()
runCatching {
json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw)
}.getOrDefault(emptyList())
/** 确保旧数据已迁移到 Room(仅执行一次) */
private suspend fun ensureMigrated() {
if (migrated) return
migrationMutex.withLock {
if (migrated) return
migrateDataStoreToRoom(context, dataStore, dao, json)
migrated = true
}
}
suspend fun getAll(): List<UsageRecord> {
ensureMigrated()
return dao.getAll().map { it.toDomain() }
}
fun getAllFlow(): Flow<List<UsageRecord>> = flow {
ensureMigrated()
emit(dao.getAll().map { it.toDomain() })
}
suspend fun insertAll(newRecords: List<UsageRecord>) {
val current = loadAll().toMutableList()
val existingIds = cachedIds ?: current.map { it.id }.toSet()
val toAdd = newRecords.filter { it.id !in existingIds }
if (toAdd.isEmpty()) return
current.addAll(toAdd)
persist(current)
ensureMigrated()
if (newRecords.isEmpty()) return
val entities = newRecords.map { it.toEntity() }
dao.insertAll(entities)
}
suspend fun getLatest(): UsageRecord? = loadAll().maxByOrNull { it.timeCreated }
suspend fun getLatest(): UsageRecord? {
ensureMigrated()
return dao.getLatest()?.toDomain()
}
suspend fun getAllIds(): Set<String> {
cachedIds?.let { return it }
return loadAll().map { it.id }.toSet()
ensureMigrated()
return dao.getAllIds().toSet()
}
suspend fun count(): Int = loadAll().size
/** 按 workspaceId 获取已有记录 ID 集合(增量同步用) */
suspend fun getIdsByWorkspace(workspaceId: String): Set<String> {
ensureMigrated()
return dao.getIdsByWorkspace(workspaceId).toSet()
}
/** 按 workspaceId 删除所有记录。用于修复旧数据格式问题后重新全量同步。 */
suspend fun deleteByWorkspaceId(workspaceId: String) {
ensureMigrated()
dao.deleteByWorkspaceId(workspaceId)
}
suspend fun count(): Int {
ensureMigrated()
return dao.count()
}
/** 按 workspaceId 统计记录数。 */
suspend fun count(workspaceId: String): Int {
ensureMigrated()
return dao.countByWorkspace(workspaceId)
}
/** 获取过滤后的原始记录列表(供图表等聚合使用) */
suspend fun getRecords(
@ -85,24 +91,20 @@ class UsageCache(
fromTs: Long? = null,
toTs: Long? = null
): List<UsageRecord> {
return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
ensureMigrated()
val entities = when {
fromTs != null && toTs != null -> dao.getByWorkspaceAndTime(workspaceId, fromTs, toTs)
fromTs != null -> dao.getByWorkspaceFrom(workspaceId, fromTs)
toTs != null -> dao.getByWorkspaceTo(workspaceId, toTs)
else -> dao.getByWorkspace(workspaceId)
}
return entities.map { it.toDomain() }
}
/** 获取所有不同模型名称 */
suspend fun getDistinctModels(workspaceId: String): List<String> {
return loadAll()
.filter { it.workspaceId == workspaceId }
.map { it.model }
.distinct()
.sorted()
}
/** 按时间范围过滤记录 */
private fun filterByTime(records: List<UsageRecord>, fromTs: Long?, toTs: Long?): List<UsageRecord> {
var result = records
if (fromTs != null) result = result.filter { it.timeCreated >= fromTs }
if (toTs != null) result = result.filter { it.timeCreated <= toTs }
return result
ensureMigrated()
return dao.getDistinctModels(workspaceId)
}
suspend fun getStatsByModel(
@ -110,14 +112,15 @@ class UsageCache(
fromTs: Long? = null,
toTs: Long? = null
): List<ModelStats> {
return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
val records = getRecords(workspaceId, fromTs, toTs)
return records
.groupBy { it.model }
.map { (model, records) ->
.map { (model, recs) ->
ModelStats(
model = model,
totalTokens = records.sumOf { it.totalTokens },
totalCost = records.sumOf { it.cost },
count = records.size
totalTokens = recs.sumOf { it.totalTokens },
totalCost = recs.sumOf { it.cost },
count = recs.size
)
}
.sortedByDescending { it.totalTokens }
@ -129,14 +132,15 @@ class UsageCache(
fromTs: Long? = null,
toTs: Long? = null
): List<DailyStats> {
return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
val records = getRecords(workspaceId, fromTs, toTs)
return records
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
.map { (dayTs, records) ->
.map { (dayTs, recs) ->
DailyStats(
dayTs = dayTs,
totalTokens = records.sumOf { it.totalTokens },
totalCost = records.sumOf { it.cost },
count = records.size
totalTokens = recs.sumOf { it.totalTokens },
totalCost = recs.sumOf { it.cost },
count = recs.size
)
}
.sortedByDescending { it.dayTs }
@ -148,7 +152,7 @@ class UsageCache(
fromTs: Long? = null,
toTs: Long? = null
): OverviewStats? {
val records = filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
val records = getRecords(workspaceId, fromTs, toTs)
if (records.isEmpty()) return null
return OverviewStats(
totalTokens = records.sumOf { it.inputTokens + it.cacheReadTokens + it.outputTokens },
@ -163,12 +167,6 @@ class UsageCache(
cacheWrite1hTokens = records.sumOf { it.cacheWrite1hTokens }
)
}
private suspend fun persist(list: List<UsageRecord>) {
val raw = json.encodeToString(ListSerializer(UsageRecord.serializer()), list)
dataStore.edit { it[cacheKey] = raw }
invalidateCache()
}
}
@Serializable
@ -224,7 +222,7 @@ data class OverviewStats(
val cacheWriteTokens: Long get() = cacheWrite5mTokens + cacheWrite1hTokens
}
/** DataStore 委托 */
val Context.usageCacheDataStore: DataStore<Preferences> by preferencesDataStore(
/** DataStore 委托 — 保留用于数据迁移,迁移完成后旧 key 会被清除 */
val Context.usageCacheDataStore: DataStore<Preferences> by androidx.datastore.preferences.preferencesDataStore(
name = "usage_cache"
)

View File

@ -0,0 +1,52 @@
package com.rainy.token.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface UsageDao {
@Query("SELECT * FROM usage_records")
suspend fun getAll(): List<UsageRecordEntity>
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId ORDER BY timeCreated DESC")
suspend fun getByWorkspace(workspaceId: String): List<UsageRecordEntity>
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated >= :fromTs AND timeCreated <= :toTs ORDER BY timeCreated DESC")
suspend fun getByWorkspaceAndTime(workspaceId: String, fromTs: Long, toTs: Long): List<UsageRecordEntity>
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated >= :fromTs ORDER BY timeCreated DESC")
suspend fun getByWorkspaceFrom(workspaceId: String, fromTs: Long): List<UsageRecordEntity>
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated <= :toTs ORDER BY timeCreated DESC")
suspend fun getByWorkspaceTo(workspaceId: String, toTs: Long): List<UsageRecordEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(records: List<UsageRecordEntity>): List<Long>
@Query("DELETE FROM usage_records WHERE workspaceId = :workspaceId")
suspend fun deleteByWorkspaceId(workspaceId: String): Int
@Query("SELECT COUNT(*) FROM usage_records")
suspend fun count(): Int
@Query("SELECT COUNT(*) FROM usage_records WHERE workspaceId = :workspaceId")
suspend fun countByWorkspace(workspaceId: String): Int
@Query("SELECT DISTINCT model FROM usage_records WHERE workspaceId = :workspaceId ORDER BY model ASC")
suspend fun getDistinctModels(workspaceId: String): List<String>
@Query("SELECT * FROM usage_records ORDER BY timeCreated DESC LIMIT 1")
suspend fun getLatest(): UsageRecordEntity?
@Query("SELECT id FROM usage_records")
suspend fun getAllIds(): List<String>
@Query("SELECT id FROM usage_records WHERE workspaceId = :workspaceId")
suspend fun getIdsByWorkspace(workspaceId: String): List<String>
@Query("SELECT MAX(timeCreated) FROM usage_records WHERE workspaceId = :workspaceId")
suspend fun getMaxTimeCreated(workspaceId: String): Long?
}

View File

@ -0,0 +1,80 @@
package com.rainy.token.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
@Database(
entities = [UsageRecordEntity::class],
version = 1,
exportSchema = false
)
abstract class UsageDatabase : RoomDatabase() {
abstract fun usageDao(): UsageDao
companion object {
@Volatile
private var INSTANCE: UsageDatabase? = null
fun getInstance(context: Context): UsageDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
UsageDatabase::class.java,
"usage_database"
).build().also { INSTANCE = it }
}
}
}
}
/**
* One-time migration from old DataStore JSON cache to Room.
*
* Called by [UsageCache] on first access. Reads the old JSON blob from DataStore,
* deserializes it, inserts into Room, then clears the old key.
*
* A SharedPreferences flag prevents re-running if the old data was already consumed
* (e.g. if the DataStore was cleared but the flag was set).
*
* @return true if migration ran and inserted records, false if no migration was needed.
*/
suspend fun migrateDataStoreToRoom(
context: Context,
dataStore: DataStore<Preferences>,
dao: UsageDao,
json: Json
): Boolean {
val prefs = context.getSharedPreferences("usage_cache_migration", Context.MODE_PRIVATE)
if (prefs.getBoolean("migrated_to_room", false)) return false
val cacheKey = stringPreferencesKey("usage_cache_v1")
val raw = dataStore.data.map { it[cacheKey] }.first() ?: run {
// No old data — mark as migrated so we never check again
prefs.edit().putBoolean("migrated_to_room", true).apply()
return false
}
val records = runCatching {
json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw)
}.getOrDefault(emptyList())
if (records.isNotEmpty()) {
val entities = records.map { it.toEntity() }
dao.insertAll(entities)
}
// Mark migrated and clear old key to free space
prefs.edit().putBoolean("migrated_to_room", true).apply()
dataStore.edit { it.remove(cacheKey) }
return records.isNotEmpty()
}

View File

@ -0,0 +1,81 @@
package com.rainy.token.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Room Entity for usage records.
*
* Mirrors the fields of [UsageRecord] exactly.
* Indexes on workspaceId + timeCreated for fast time-range queries.
*/
@Entity(
tableName = "usage_records",
indices = [
Index(value = ["workspaceId", "timeCreated"]),
Index(value = ["workspaceId", "model"]),
Index(value = ["id"], unique = true)
]
)
data class UsageRecordEntity(
@PrimaryKey
val id: String,
val workspaceId: String,
val timeCreated: Long,
val timeUpdated: Long,
val model: String,
val provider: String,
val inputTokens: Long,
val outputTokens: Long,
val reasoningTokens: Long,
val cacheReadTokens: Long,
val cacheWrite5mTokens: Long = 0,
val cacheWrite1hTokens: Long = 0,
val cost: Long,
val keyId: String,
val sessionId: String,
val enrichmentPlan: String = ""
) {
/** Convert Entity → domain model */
fun toDomain(): UsageRecord = UsageRecord(
id = id,
workspaceId = workspaceId,
timeCreated = timeCreated,
timeUpdated = timeUpdated,
model = model,
provider = provider,
inputTokens = inputTokens,
outputTokens = outputTokens,
reasoningTokens = reasoningTokens,
cacheReadTokens = cacheReadTokens,
cacheWrite5mTokens = cacheWrite5mTokens,
cacheWrite1hTokens = cacheWrite1hTokens,
cost = cost,
keyId = keyId,
sessionId = sessionId,
enrichmentPlan = enrichmentPlan
)
val totalTokens: Long get() = inputTokens + outputTokens + reasoningTokens
}
/** Convert domain model → Entity */
fun UsageRecord.toEntity(): UsageRecordEntity = UsageRecordEntity(
id = id,
workspaceId = workspaceId,
timeCreated = timeCreated,
timeUpdated = timeUpdated,
model = model,
provider = provider,
inputTokens = inputTokens,
outputTokens = outputTokens,
reasoningTokens = reasoningTokens,
cacheReadTokens = cacheReadTokens,
cacheWrite5mTokens = cacheWrite5mTokens,
cacheWrite1hTokens = cacheWrite1hTokens,
cost = cost,
keyId = keyId,
sessionId = sessionId,
enrichmentPlan = enrichmentPlan
)

View File

@ -0,0 +1,141 @@
package com.rainy.token.data.repository
import android.content.Context
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
import org.json.JSONObject
/**
* 每日签到状态的统一本地记录(SharedPreferences "checkin_status")。
*
* 一条记录包含:
* - checkinDate:签到日期(本地时区 yyyy-MM-dd)
* - status:签到结果状态(SUCCESS/ALREADY/FAILED/RETRYABLE/DEVICE_BLOCKED)
* - checkinId:签到 id(服务端未提供时为空;新签到成功时生成本地 UUID 便于追溯)
* - credits:签到获得数量
* - updatedAt:本地记录更新时间戳
*
* 「今日已签到」的判定 = checkinDate == 今天 && status ∈ {SUCCESS, ALREADY}。
* 因此 FAILED/RETRYABLE/DEVICE_BLOCKED 的记录不会挡住当天重试:标识位和时间
* 对不上时,重开 App 会再次唤起签到流程。
*/
@Singleton
class CheckinStatusStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private fun prefs() =
context.getSharedPreferences("checkin_status", Context.MODE_PRIVATE)
/** 该服务今日是否已「成功」签到(本地时区 yyyy-MM-dd 比对)。 */
fun isCheckedToday(service: ServiceType, accountId: String? = null): Boolean {
val record = lastRecord(service, accountId) ?: return false
return record.checkinDate == LocalDate.now().toString() &&
(record.status == STATUS_SUCCESS || record.status == STATUS_ALREADY)
}
/** 最近一次签到记录(含失败记录),无记录返回 null。 */
fun lastRecord(service: ServiceType, accountId: String? = null): CheckinRecord? {
val raw = prefs().getString(recordKey(service, accountId), null)
if (raw != null) {
return runCatching {
val obj = JSONObject(raw)
val id = if (obj.isNull(KEY_ID)) null else obj.optString(KEY_ID).takeIf { it.isNotBlank() }
CheckinRecord(
service = service.storageKey,
accountId = accountId,
checkinDate = obj.optString(KEY_DATE),
status = obj.optString(KEY_STATUS),
checkinId = id,
credits = obj.optLong(KEY_CREDITS),
updatedAt = obj.optLong(KEY_UPDATED_AT)
)
}.getOrNull()
}
// 旧版 date-only 记录迁移:没有 JSON 记录时回退读旧键
val legacyDate = legacyDate(service, accountId) ?: return null
return CheckinRecord(
service = service.storageKey,
accountId = accountId,
checkinDate = legacyDate,
status = STATUS_SUCCESS,
checkinId = null,
credits = 0,
updatedAt = 0
)
}
/** 最近一次成功签到日期(yyyy-MM-dd),无记录返回 null。 */
fun lastCheckinDate(service: ServiceType, accountId: String? = null): String? =
lastRecord(service, accountId)
?.takeIf { it.status == STATUS_SUCCESS || it.status == STATUS_ALREADY }
?.checkinDate
/** 记录一条签到结果(成功或失败都会落库,便于当天重试/统计)。 */
fun record(
service: ServiceType,
accountId: String? = null,
status: String,
checkinId: String? = null,
credits: Long = 0,
updatedAt: Long = System.currentTimeMillis()
) {
val obj = JSONObject()
obj.put(KEY_DATE, LocalDate.now().toString())
obj.put(KEY_STATUS, status)
obj.put(KEY_ID, checkinId ?: JSONObject.NULL)
obj.put(KEY_CREDITS, credits)
obj.put(KEY_UPDATED_AT, updatedAt)
prefs().edit().putString(recordKey(service, accountId), obj.toString()).apply()
}
/** 旧版「已成功签到」快捷写入(服务端确认已签/手动已签场景)。 */
fun markChecked(service: ServiceType, accountId: String? = null) {
record(service, accountId, STATUS_ALREADY)
}
private fun recordKey(service: ServiceType, accountId: String?): String =
"checkin_record_${service.storageKey}" + if (accountId.isNullOrBlank()) "" else ":$accountId"
private fun legacyDate(service: ServiceType, accountId: String?): String? {
val p = prefs()
val scopedKey = "last_checkin_${service.storageKey}" + if (accountId.isNullOrBlank()) "" else ":$accountId"
val scoped = p.getString(scopedKey, null)
if (scoped != null) return scoped
// 仅旧版迁移来的 “default” 账号回退读旧版单账号键(其他账号没有旧记录,不能共用),
// 避免升级当日重复签到请求
return if (accountId == CredentialRepository.LEGACY_DEFAULT_ACCOUNT_ID) {
p.getString("last_checkin_${service.storageKey}", null)
} else {
null
}
}
/** 单条签到记录。 */
data class CheckinRecord(
val service: String,
val accountId: String?,
val checkinDate: String,
val status: String,
val checkinId: String?,
val credits: Long,
val updatedAt: Long
)
companion object {
const val STATUS_SUCCESS = "SUCCESS"
const val STATUS_ALREADY = "ALREADY"
const val STATUS_FAILED = "FAILED"
const val STATUS_RETRYABLE = "RETRYABLE"
const val STATUS_DEVICE_BLOCKED = "DEVICE_BLOCKED"
private const val KEY_DATE = "date"
private const val KEY_STATUS = "status"
private const val KEY_ID = "id"
private const val KEY_CREDITS = "credits"
private const val KEY_UPDATED_AT = "updated_at"
}
}

View File

@ -0,0 +1,175 @@
package com.rainy.token.data.repository
import android.util.Base64
import com.rainy.token.data.debug.DebugLog
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.security.MessageDigest
import java.security.SecureRandom
/**
* Codex / ChatGPT OAuth PKCE 辅助工具。
*
* 参考:7shi/codex-oauth 的 Python 实现,移植到 Kotlin/Android。
* 流程:
* 1. 生成 code_verifier(随机 96 字节 base64url)
* 2. 计算 code_challenge = SHA256(code_verifier) base64url 无填充
* 3. 构建 auth URL → 用户在 WebView 中登录
* 4. 拦截 localhost:1455/auth/callback?code=xxx&state=xxx
* 5. POST token endpoint 用 code + code_verifier 换 access/refresh token
* 6. 从 JWT (id_token / access_token) 提取 chatgpt_account_id
*/
object CodexOAuthHelper {
private const val TAG = "CodexOAuth"
const val AUTH_URL = "https://auth.openai.com/oauth/authorize"
const val TOKEN_URL = "https://auth.openai.com/oauth/token"
const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const val REDIRECT_URI = "http://localhost:1455/auth/callback"
const val SCOPE = "openid profile email offline_access"
const val CALLBACK_PREFIX = "http://localhost:1455/auth/callback"
private val json = Json { ignoreUnknownKeys = true }
/** PKCE 参数对 */
data class PkcePair(val codeVerifier: String, val codeChallenge: String)
/** 生成 PKCE code_verifier 和 code_challenge (S256) */
fun generatePkce(): PkcePair {
val randomBytes = ByteArray(96)
SecureRandom().nextBytes(randomBytes)
val codeVerifier = Base64.encodeToString(
randomBytes,
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
)
val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray())
val codeChallenge = Base64.encodeToString(
digest,
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
)
return PkcePair(codeVerifier, codeChallenge)
}
/** 生成随机 state(CSRF 防护) */
fun generateState(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}
/** 构建授权 URL */
fun buildAuthUrl(codeChallenge: String, state: String): String {
val params = mapOf(
"response_type" to "code",
"client_id" to CLIENT_ID,
"redirect_uri" to REDIRECT_URI,
"scope" to SCOPE,
"code_challenge" to codeChallenge,
"code_challenge_method" to "S256",
"state" to state,
"id_token_add_organizations" to "true",
"codex_cli_simplified_flow" to "true",
"originator" to "opencode"
)
// 手动拼 URL(不用 URLEncoder.encode,因为 OAuth 参数不需要编码特殊字符)
val query = params.entries.joinToString("&") { (k, v) ->
"$k=${java.net.URLEncoder.encode(v, "UTF-8")}"
}
return "$AUTH_URL?$query"
}
/** Token 交换响应 */
@Serializable
data class TokenResponse(
@kotlinx.serialization.SerialName("access_token") val accessToken: String,
@kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null,
@kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600,
@kotlinx.serialization.SerialName("id_token") val idToken: String? = null,
@kotlinx.serialization.SerialName("token_type") val tokenType: String? = null
)
/** 用 authorization code 换 token */
fun exchangeCode(
okHttpClient: OkHttpClient,
code: String,
codeVerifier: String
): TokenResponse? {
val formBody = buildString {
append("grant_type=authorization_code")
append("&code=").append(java.net.URLEncoder.encode(code, "UTF-8"))
append("&redirect_uri=").append(java.net.URLEncoder.encode(REDIRECT_URI, "UTF-8"))
append("&client_id=").append(CLIENT_ID)
append("&code_verifier=").append(codeVerifier)
}.toRequestBody("application/x-www-form-urlencoded".toMediaType())
val request = Request.Builder()
.url(TOKEN_URL)
.header("Content-Type", "application/x-www-form-urlencoded")
.post(formBody)
.build()
return try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
val errorBody = resp.body?.string()
DebugLog.e(TAG, "token exchange failed: HTTP ${resp.code} | $errorBody")
return@use null
}
val body = resp.body?.string() ?: return@use null
json.decodeFromString(TokenResponse.serializer(), body)
}
} catch (e: Exception) {
DebugLog.e(TAG, "token exchange exception: ${e::class.simpleName}: ${e.message}")
null
}
}
/**
* 从 JWT 中提取 chatgpt_account_id(不验证签名,只读 payload)。
* 3 级回退:
* 1. payload.chatgpt_account_id
* 2. payload["https://api.openai.com/auth"].chatgpt_account_id
* 3. payload.organizations[0].id
*/
fun extractAccountId(idToken: String?, accessToken: String?): String? {
for (token in listOfNotNull(idToken, accessToken)) {
val payload = decodeJWTPayload(token) ?: continue
// 1. top-level
payload["chatgpt_account_id"]?.jsonPrimitive?.content?.let { return it }
// 2. nested namespace
payload["https://api.openai.com/auth"]?.jsonObject
?.get("chatgpt_account_id")?.jsonPrimitive?.content?.let { return it }
// 3. organizations[0].id
(payload["organizations"] as? kotlinx.serialization.json.JsonArray)
?.firstOrNull()
?.let { it as? JsonObject }
?.get("id")?.jsonPrimitive?.content?.let { return it }
}
return null
}
/** 解码 JWT payload(第二段),不验证签名 */
private fun decodeJWTPayload(jwt: String): JsonObject? {
return try {
val parts = jwt.split(".")
if (parts.size < 2) return null
val payloadB64 = parts[1]
// base64url decode,补 padding
val decoded = Base64.decode(
payloadB64,
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
)
json.parseToJsonElement(String(decoded)).jsonObject
} catch (_: Exception) {
null
}
}
}

View File

@ -0,0 +1,479 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import android.util.Log
import com.rainy.token.data.debug.DebugLog
import java.io.IOException
import javax.inject.Singleton
@Singleton
class CodexRepository(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
/** 用量窗口来源分组:CODEX = 普通 Codex 限额,SPARK = GPT-5.3-Codex-Spark 独立限额 */
internal enum class WindowGroup { CODEX, SPARK }
internal data class UsageWindow(
val label: String,
val remainingPct: Int,
val resetAt: Long?,
val group: WindowGroup = WindowGroup.CODEX
)
companion object {
private const val WHAM_USAGE = "https://chatgpt.com/backend-api/wham/usage"
private const val RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
private const val MODELS_API = "https://models.dev/api.json"
private const val OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
private const val TAG = "Codex"
internal fun parseUsageWindows(data: JsonObject): List<UsageWindow> {
val result = mutableListOf<UsageWindow>()
fun addWindows(rl: JsonObject?, group: WindowGroup) {
if (rl == null) return
for (key in listOf("primary_window", "secondary_window")) {
val w = rl[key] as? JsonObject ?: continue
val usedPct = (w["used_percent"] as? JsonPrimitive)?.floatOrNull ?: continue
val remaining = (100 - usedPct).toInt().coerceIn(0, 100)
result.add(UsageWindow(durationLabel((w["limit_window_seconds"] as? JsonPrimitive)?.longOrNull), remaining, (w["reset_at"] as? JsonPrimitive)?.longOrNull?.times(1000L), group))
}
}
// 顶层 rate_limit 始终属于普通 Codex 限额
addWindows(data["rate_limit"] as? JsonObject, WindowGroup.CODEX)
// additional_rate_limits 每项可携带 limit_name,含 "spark" 的归入 Spark 独立限额组
(data["additional_rate_limits"] as? kotlinx.serialization.json.JsonArray)?.forEach { item ->
if (item is JsonObject) {
val name = (item["limit_name"] as? JsonPrimitive)?.contentOrNull.orEmpty()
val group = if (name.contains("spark", ignoreCase = true)) WindowGroup.SPARK else WindowGroup.CODEX
addWindows(item["rate_limit"] as? JsonObject, group)
}
}
return result
}
/**
* 选择主余额窗口:
* 优先普通 Codex 组的 5h 窗口(与旧逻辑一致);
* 普通组没有 5h 时取普通组第一个(如仅 weekly 的 prolite 账号);
* 普通组为空才 fallback 到任意第一个(避免 Spark 5h 抢走主余额)。
*/
internal fun selectPrimaryWindow(windows: List<UsageWindow>): UsageWindow =
windows.firstOrNull { it.group == WindowGroup.CODEX && it.label.contains("h") }
?: windows.firstOrNull { it.group == WindowGroup.CODEX }
?: windows.first()
/** 窗口时长 → 中性标识(weekly/monthly/usage/数字标签),由 UI 层按语言本地化 */
internal fun durationLabel(seconds: Long?): String = when {
seconds == null -> "usage"
seconds / 60.0 >= 10079 -> "weekly"
seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"
seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"
else -> "${maxOf(1, (seconds / 60).toInt())}m"
}
}
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.CODEX)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
if (credential !is Credential.CodexCredential)
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
val effectiveCred = if (tokenNeedsRefresh(credential)) {
DebugLog.i(TAG, "access_token 即将过期,尝试刷新(expiresAt=${credential.expiresAt})")
when (val r = refreshToken(credential)) {
is RefreshResult.Success -> {
DebugLog.i(TAG, "token 刷新成功,新 expiresAt=${r.cred.expiresAt}")
credentialRepository.save(r.cred); r.cred
}
is RefreshResult.Failure -> {
DebugLog.e(TAG, "token 主动刷新失败: ${r.reason}")
credential
}
}
} else credential
val usageResult = try {
fetchJson(WHAM_USAGE, effectiveCred.accessToken)
} catch (e: IOException) {
DebugLog.e(TAG, "网络异常: ${e.message}")
return@withContext Result.failure(RepositoryError.Network(e))
} catch (e: RepositoryError) {
if (e is RepositoryError.InvalidCredential && effectiveCred == credential) {
DebugLog.w(TAG, "401 收到,尝试用 refresh_token 二次刷新")
when (val r = refreshToken(credential)) {
is RefreshResult.Success -> {
DebugLog.i(TAG, "二次刷新成功")
credentialRepository.save(r.cred)
try { fetchJson(WHAM_USAGE, r.cred.accessToken) }
catch (e2: RepositoryError) { return@withContext Result.failure(e2) }
catch (e2: IOException) { return@withContext Result.failure(RepositoryError.Network(e2)) }
catch (e2: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e2)) }
}
is RefreshResult.Failure -> {
DebugLog.e(TAG, "二次刷新也失败: ${r.reason}")
return@withContext Result.failure(RepositoryError.InvalidCredential(r.reason))
}
}
} else return@withContext Result.failure(e)
} catch (e: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e)) }
val windows = try {
parseUsageWindows(usageResult)
} catch (e: Exception) {
DebugLog.e(TAG, "解析 Codex 用量失败: ${e::class.simpleName}: ${e.message}")
return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}"))
}
if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据"))
val config = ServiceConfigProvider.get(ServiceType.CODEX)
val primary = selectPrimaryWindow(windows)
val extras = buildMap {
windows.forEachIndexed { i, w ->
put("window_$i.label", w.label)
put("window_$i.remainingPct", w.remainingPct.toString())
put("window_$i.resetAt", w.resetAt?.toString() ?: "")
put("window_$i.group", w.group.name)
}
put("primary.label", primary.label)
(usageResult["plan_type"] as? JsonPrimitive)?.contentOrNull?.let { put("plan", it) }
(usageResult["credits"] as? JsonObject)?.let { c ->
(c["balance"] as? JsonPrimitive)?.floatOrNull?.let { put("usageCredits", it.toString()) }
}
}
val balance = ServiceBalance(ServiceType.CODEX, (100 - primary.remainingPct).coerceIn(0, 100).toDouble(), config.displayUnit, true, null, null, primary.resetAt, extras)
balanceCache.put(ServiceType.CODEX, balance)
credentialRepository.save(effectiveCred.copy(lastVerifiedAt = System.currentTimeMillis()))
Result.success(balance)
}
/**
* 从 models.dev/api.json 获取 OpenAI 可用模型列表。
* 该 API 不需要认证,返回所有 provider 的模型目录。
* 参考 OpenCode 的 ModelsDev 服务实现。
*/
suspend fun fetchModels(): Result<List<String>> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(MODELS_API)
.header("Accept", "application/json")
.header("User-Agent", "codex-reset-tracker/0.1")
.get().build()
val models = okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject
?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
// 结构: { "openai": { "models": { "gpt-5.6": {...}, ... } } }
val openaiProvider = root["openai"] as? JsonObject
val modelsObj = openaiProvider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenAI 模型列表")
}
if (models.isEmpty()) {
return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
}
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
Result.success(models)
} catch (e: IOException) {
DebugLog.e(TAG, "fetchModels 网络异常: ${e.message}")
Result.failure(RepositoryError.Network(e))
} catch (e: RepositoryError) {
Result.failure(e)
} catch (e: Throwable) {
DebugLog.e(TAG, "fetchModels 异常: ${e::class.simpleName}: ${e.message}")
Result.failure(RepositoryError.Unknown(e))
}
}
/** 从 access_token JWT 中解析 chatgpt_account_id */
private fun extractAccountId(accessToken: String): String? {
return try {
val parts = accessToken.split(".")
if (parts.size != 3) return null
// JWT payload 是 Base64Url 编码
val payload = android.util.Base64.decode(
parts[1].replace("-", "+").replace("_", "/").padEnd(4, '='),
android.util.Base64.DEFAULT
).toString(Charsets.UTF_8)
val json = this.json.parseToJsonElement(payload) as? JsonObject ?: return null
(json["chatgpt_account_id"] as? JsonPrimitive)?.contentOrNull
?: ((json["https://api.openai.com/auth"] as? JsonObject)?.get("chatgpt_account_id") as? JsonPrimitive)?.contentOrNull
?: (json["organizations"] as? kotlinx.serialization.json.JsonArray)
?.firstOrNull()?.let { (it as? JsonObject)?.get("id") as? JsonPrimitive }?.contentOrNull
} catch (_: Exception) {
null
}
}
/**
* 一键激活用量:向 ChatGPT responses API 发送一条请求。
* @param model 用户选择的模型 slug
* @return Result.success(响应体原文) — 供 UI 展示
*/
suspend fun triggerUsage(model: String): Result<TriggerSummary> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.CODEX)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
if (credential !is Credential.CodexCredential)
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
val effectiveCred = ensureToken(credential)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("token 刷新失败"))
val accountId = extractAccountId(effectiveCred.accessToken)
DebugLog.i(TAG, "triggerUsage: accountId=$accountId, model=$model")
val requestBody = """{"model":"$model","input":[{"role":"user","content":[{"type":"input_text","text":"hello"}]}],"stream":true,"store":false}"""
.toRequestBody("application/json".toMediaType())
val request = buildPostRequest(RESPONSES_URL, effectiveCred.accessToken, requestBody, accountId)
try {
okHttpClient.newCall(request).execute().use { resp ->
val bodyStr = resp.body?.string() ?: ""
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
if (!resp.isSuccessful) {
DebugLog.e(TAG, "triggerUsage failed: HTTP ${resp.code} body=${bodyStr.take(200)}")
if (resp.code in listOf(401, 403)) {
// token 过期,二次刷新重试
DebugLog.w(TAG, "triggerUsage: 401,尝试二次刷新")
when (val r = refreshToken(credential)) {
is RefreshResult.Success -> {
credentialRepository.save(r.cred)
val retryAccountId = extractAccountId(r.cred.accessToken)
val retryRequest = buildPostRequest(RESPONSES_URL, r.cred.accessToken, requestBody, retryAccountId)
okHttpClient.newCall(retryRequest).execute().use { resp2 ->
val body2 = resp2.body?.string() ?: ""
DebugLog.i(TAG, "triggerUsage retry: HTTP ${resp2.code}, body=${body2.take(500)}")
if (!resp2.isSuccessful) {
DebugLog.e(TAG, "triggerUsage retry failed: HTTP ${resp2.code}")
return@use Result.failure<TriggerSummary>(
TriggerError(
"HTTP ${resp2.code}",
body2.ifBlank { "" }
)
)
}
Result.success(parseSseResponse(body2, model))
}
}
is RefreshResult.Failure -> {
DebugLog.e(TAG, "triggerUsage: 二次刷新失败: ${r.reason}")
return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", "", TriggerErrorReason.TOKEN_REFRESH))
}
}
} else {
return@use Result.failure<TriggerSummary>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
)
}
} else {
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
val formatted = parseSseResponse(bodyStr, model)
Result.success(formatted)
}
}
} catch (e: IOException) {
DebugLog.e(TAG, "triggerUsage 网络异常: ${e.message}")
Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
DebugLog.e(TAG, "triggerUsage 异常: ${e::class.simpleName}: ${e.message}")
Result.failure(RepositoryError.Unknown(e))
}
}
/** 确保 access_token 有效,返回刷新后的凭据或 null(刷新失败时) */
private suspend fun ensureToken(credential: Credential.CodexCredential): Credential.CodexCredential? {
if (!tokenNeedsRefresh(credential)) return credential
DebugLog.i(TAG, "ensureToken: access_token 即将过期,尝试刷新")
return when (val r = refreshToken(credential)) {
is RefreshResult.Success -> {
credentialRepository.save(r.cred); r.cred
}
is RefreshResult.Failure -> {
DebugLog.e(TAG, "ensureToken: token 刷新失败: ${r.reason}")
null
}
}
}
/** 构建 responses POST 请求 */
private fun buildPostRequest(url: String, token: String, body: okhttp3.RequestBody, accountId: String? = null): Request =
Request.Builder().url(url)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("OpenAI-Beta", "codex-1")
.header("OAI-Language", "en")
.header("User-Agent", "codex-reset-tracker/0.1")
.header("originator", "codex-reset-tracker")
.header("Authorization", "Bearer $token")
.apply {
if (accountId != null) header("ChatGPT-Account-Id", accountId)
}
.post(body).build()
private fun tokenNeedsRefresh(cred: Credential.CodexCredential): Boolean =
System.currentTimeMillis() >= cred.expiresAt - REFRESH_BUFFER_MS
private sealed class RefreshResult {
data class Success(val cred: Credential.CodexCredential) : RefreshResult()
data class Failure(val reason: String) : RefreshResult()
}
private fun refreshToken(cred: Credential.CodexCredential): RefreshResult {
// OpenAI auth endpoint 要求 form-urlencoded,不能用 JSON(否则返回 401)
val formBody = "grant_type=refresh_token&refresh_token=${cred.refreshToken}&client_id=$CLIENT_ID"
.toRequestBody("application/x-www-form-urlencoded".toMediaType())
val request = Request.Builder().url(OAUTH_TOKEN_URL)
.header("Content-Type", "application/x-www-form-urlencoded")
.post(formBody).build()
return try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
val errorBody = resp.body?.string()
Log.w("CodexRepository", "token refresh failed: HTTP ${resp.code} ${resp.message} body=$errorBody")
DebugLog.e(TAG, "token refresh failed: HTTP ${resp.code} ${resp.message}" +
(errorBody?.take(200)?.let { " | $it" } ?: ""))
// 解析 OpenAI 错误响应,给出用户可读的提示
val reason = parseRefreshError(resp.code, errorBody)
return@use RefreshResult.Failure(reason)
}
val bodyStr = resp.body?.string() ?: return@use RefreshResult.Failure("响应体为空")
val tr = json.decodeFromString(OAuthRefreshResponse.serializer(), bodyStr)
// OpenAI 轮换 refresh_token:响应中可能含新 refresh_token,也可能不含(不轮换时)
RefreshResult.Success(cred.copy(
accessToken = tr.accessToken,
refreshToken = tr.refreshToken ?: cred.refreshToken,
expiresAt = System.currentTimeMillis() + tr.expiresIn * 1000L,
lastVerifiedAt = System.currentTimeMillis()
))
}
} catch (e: Exception) {
Log.w("CodexRepository", "token refresh exception: ${e::class.simpleName}: ${e.message}")
DebugLog.e(TAG, "token refresh exception: ${e::class.simpleName}: ${e.message}")
RefreshResult.Failure("网络异常: ${e::class.simpleName}: ${e.message}")
}
}
/** 把 OpenAI token endpoint 的错误响应翻译成用户可读的中文提示 */
private fun parseRefreshError(httpCode: Int, errorBody: String?): String {
if (errorBody == null) return "HTTP $httpCode,无错误详情"
// 尝试提取 error.message 字段
val msg = try {
(json.parseToJsonElement(errorBody) as? JsonObject)?.let { root ->
(root["error"] as? JsonObject)?.let { err ->
(err["message"] as? JsonPrimitive)?.contentOrNull
}
}
} catch (_: Exception) { null }
return when {
msg != null && msg.contains("already been used") ->
"refresh_token 已被使用(被其他工具轮换),请重新导出 auth.json 并导入"
msg != null && msg.contains("sign in", ignoreCase = true) ->
"refresh_token 已失效,请重新登录获取新 auth.json"
httpCode == 401 && msg != null -> "认证失败: $msg"
httpCode == 401 -> "认证失败 (HTTP 401),refresh_token 可能已过期"
httpCode == 400 && msg != null -> "请求参数错误: $msg"
else -> "HTTP $httpCode: ${msg ?: errorBody.take(100)}"
}
}
private fun fetchJson(url: String, token: String): JsonObject {
val request = Request.Builder().url(url)
.header("Accept", "application/json")
.header("OpenAI-Beta", "codex-1")
.header("OAI-Language", "en")
.header("User-Agent", "codex-reset-tracker/0.1")
.header("originator", "codex-reset-tracker")
.header("Authorization", "Bearer $token").get().build()
val resp = okHttpClient.newCall(request).execute()
resp.use {
if (!it.isSuccessful) {
Log.w("CodexRepository", "fetchJson failed: HTTP ${it.code} ${it.message} url=$url")
DebugLog.e(TAG, "fetchJson failed: HTTP ${it.code} ${it.message} url=$url")
throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential("HTTP ${it.code}") else RepositoryError.ServerError(it.code)
}
return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject)
?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
}
}
@Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null)
}
/** TriggerError 的结构化原因(UI 层据此映射本地化文案;null = HTTP 错误,summary 为 ASCII 可直显) */
enum class TriggerErrorReason {
/** token 刷新失败(summary 中为中文用户提示,不直接透传 UI) */
TOKEN_REFRESH
}
/** 携带完整响应体的错误类,供 UI 展示服务端返回的详细信息 */
class TriggerError(val summary: String, val responseBody: String, val reason: TriggerErrorReason? = null) : Exception(summary)
/**
* 将 SSE 流响应解析为简洁的文本摘要。
* 提取:模型回复文本、用量统计(input/output tokens)。
*/
internal fun parseSseResponse(sseText: String, model: String): TriggerSummary {
val sseJson = Json { ignoreUnknownKeys = true }
val outputText = StringBuilder()
var inputTokens: String? = null
var outputTokens: String? = null
var responseId: String? = null
for (line in sseText.lines()) {
if (!line.startsWith("data: ")) continue
val jsonStr = line.removePrefix("data: ").trim()
if (jsonStr == "[DONE]") continue
try {
val obj = sseJson.parseToJsonElement(jsonStr) as? JsonObject ?: continue
when (obj["type"]?.toString()?.trim('"')) {
"response.output_text.done" -> {
// 最终文本
(obj["text"] as? JsonPrimitive)?.contentOrNull?.let { outputText.clear(); outputText.append(it) }
}
"response.completed" -> {
val resp = obj["response"] as? JsonObject
responseId = (resp?.get("id") as? JsonPrimitive)?.contentOrNull
val usage = resp?.get("usage") as? JsonObject
inputTokens = (usage?.get("input_tokens") as? JsonPrimitive)?.contentOrNull
outputTokens = (usage?.get("output_tokens") as? JsonPrimitive)?.contentOrNull
}
}
} catch (_: Exception) { }
}
return TriggerSummary(
model = model,
reply = outputText.toString(),
inputTokens = inputTokens,
outputTokens = outputTokens,
responseId = responseId
)
}

View File

@ -0,0 +1,249 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.double
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import javax.inject.Singleton
/**
* CommandCode 余额仓库。
*
* 调 JSON API 获取月度配额余额 + 用量窗口信息:
* GET https://api.commandcode.ai/alpha/billing/credits
* Authorization: Bearer <API Key>
*
* 调 subscription 端点获取计划信息(用来算已用/总量百分比):
* GET https://api.commandcode.ai/alpha/billing/subscriptions
* Authorization: Bearer <API Key>
*/
@Singleton
class CommandCodeGoRepository(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
private val apiBase = "https://api.commandcode.ai"
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.COMMANDCODE_GO)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.SessionCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
// token 字段存 API Key
val apiKey = credential.token
if (apiKey.isNullOrBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
// 顺序拉取 credits + subscriptions(credits 是主要数据源,subscription 用于补充计划信息)
val creditsResult = runCatching { fetchCredits(apiKey) }
val subResult = runCatching { fetchSubscription(apiKey) }
val creditsPayload = creditsResult.getOrElse { e ->
return@withContext Result.failure(
if (e is IOException) RepositoryError.Network(e)
else RepositoryError.Unknown(e)
)
}
// 从订阅信息拿计划名称,查 plan catalog 拿总量
val monthlyTotal = subResult.getOrNull()?.let { sub ->
planMonthlyQuota(sub.planId)
}
val billingPeriodEndMillis = subResult.getOrNull()?.let { parseIsoToEpoch(it.currentPeriodEnd) }
val config = ServiceConfigProvider.get(ServiceType.COMMANDCODE_GO)
val used = monthlyTotal?.let { total ->
maxOf(0.0, total - creditsPayload.monthlyCredits)
}
val extras = buildMap {
put("monthlyRemaining", creditsPayload.monthlyCredits.toString())
put("purchasedCredits", creditsPayload.purchasedCredits.toString())
put("freeCredits", creditsPayload.freeCredits.toString())
monthlyTotal?.let { put("monthlyTotal", it.toString()) }
used?.let { put("monthlyUsed", it.toString()) }
creditsPayload.fiveHourUsed?.let { put("fiveHour.used", it.toString()) }
creditsPayload.fiveHourCap?.let { put("fiveHour.cap", it.toString()) }
creditsPayload.fiveHourResetAt?.let { put("fiveHour.resetInSec", epochToRemainingSec(it).toString()) }
creditsPayload.weeklyUsed?.let { put("weekly.used", it.toString()) }
creditsPayload.weeklyCap?.let { put("weekly.cap", it.toString()) }
creditsPayload.weeklyResetAt?.let { put("weekly.resetInSec", epochToRemainingSec(it).toString()) }
billingPeriodEndMillis?.let {
put("billingPeriodEnd", it.toString())
put("monthly.resetInSec", epochToRemainingSec(it).toString())
}
subResult.getOrNull()?.planId?.let { put("planId", it) }
subResult.getOrNull()?.planId?.let { put("planName", planDisplayName(it)) }
}
val balance = ServiceBalance(
service = ServiceType.COMMANDCODE_GO,
amount = creditsPayload.monthlyCredits,
unit = config.displayUnit,
isAvailable = true,
monthlySpent = used,
totalQuota = monthlyTotal,
nextResetAt = billingPeriodEndMillis,
extras = extras
)
balanceCache.put(ServiceType.COMMANDCODE_GO, balance)
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
Result.success(balance)
}
private fun fetchCredits(apiKey: String): CreditsPayload {
val request = Request.Builder()
.url("$apiBase/alpha/billing/credits")
.header("Authorization", "Bearer $apiKey")
.header("Accept", "application/json")
.get()
.build()
val response = okHttpClient.newCall(request).execute()
if (!response.isSuccessful) {
throw IOException("HTTP ${response.code}: ${response.body?.string().orEmpty()}")
}
val body = response.body?.string() ?: throw IOException("空响应体")
val root = json.parseToJsonElement(body).jsonObject
val credits = root["credits"]?.jsonObject ?: throw IOException("缺少 credits 字段")
fun getDouble(obj: JsonObject, key: String): Double =
obj[key]?.jsonPrimitive?.double ?: 0.0
fun getLong(obj: JsonObject, key: String): Long? =
obj[key]?.jsonPrimitive?.long
val fiveHour = root["windowLimits"]?.jsonObject?.let { limits ->
if (limits["limited"]?.jsonPrimitive?.content == "true") {
limits["fiveHour"]?.jsonObject
} else null
}
val weekly = root["windowLimits"]?.jsonObject?.let { limits ->
if (limits["limited"]?.jsonPrimitive?.content == "true") {
limits["weekly"]?.jsonObject
} else null
}
return CreditsPayload(
monthlyCredits = getDouble(credits, "monthlyCredits"),
purchasedCredits = getDouble(credits, "purchasedCredits"),
freeCredits = getDouble(credits, "freeCredits"),
fiveHourUsed = fiveHour?.let { getDouble(it, "used") },
fiveHourCap = fiveHour?.let { getDouble(it, "cap") },
fiveHourResetAt = fiveHour?.let { getLong(it, "resetAt") },
weeklyUsed = weekly?.let { getDouble(it, "used") },
weeklyCap = weekly?.let { getDouble(it, "cap") },
weeklyResetAt = weekly?.let { getLong(it, "resetAt") }
)
}
private fun fetchSubscription(apiKey: String): SubscriptionPayload? {
val request = Request.Builder()
.url("$apiBase/alpha/billing/subscriptions")
.header("Authorization", "Bearer $apiKey")
.header("Accept", "application/json")
.get()
.build()
val response = try {
okHttpClient.newCall(request).execute()
} catch (_: Exception) {
return null
}
if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null
return try {
val root = json.parseToJsonElement(body).jsonObject
if (root["success"]?.jsonPrimitive?.content != "true") return null
val data = root["data"]?.jsonObject ?: return null
SubscriptionPayload(
planId = data["planId"]?.jsonPrimitive?.content.orEmpty(),
currentPeriodEnd = data["currentPeriodEnd"]?.jsonPrimitive?.content
)
} catch (_: Exception) { null }
}
companion object {
/** 计划 → 月度额度(美元)。2026-08 起官网新计划体系:
* Go $1 → $10、GOAT $10 → $70、Pro $20 → $80、Max 10× $100 → $150、Max 20× $200 → $300。
* 旧值 individual-go=10 与 individual-goat=70 并存(goat 是当前主流计划)。
*/
private val PLANS = mapOf(
"individual-go" to 10.0,
"individual-goat" to 70.0,
"individual-pro" to 80.0,
"individual-max" to 150.0,
"individual-ultra" to 300.0
)
private val PLAN_NAMES = mapOf(
"individual-go" to "Go",
"individual-goat" to "GOAT",
"individual-pro" to "Pro",
"individual-max" to "Max",
"individual-ultra" to "Ultra"
)
/** 按 planId 查月度额度(美元);未知计划返回 null(调用方安全降级)。 */
internal fun planMonthlyQuota(planId: String?): Double? =
planId?.lowercase()?.let { PLANS[it] }
/** 按 planId 查展示名;未知计划原样返回。 */
internal fun planDisplayName(planId: String?): String =
planId?.lowercase()?.let { PLAN_NAMES[it] } ?: planId ?: ""
/** API 返回的是 epoch millis,转为距现在的剩余秒数 */
private fun epochToRemainingSec(epochMillis: Long): Long =
maxOf(0L, (epochMillis - System.currentTimeMillis()) / 1000)
private fun parseIsoToEpoch(isoStr: String?): Long? {
if (isoStr == null) return null
return try {
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US)
sdf.timeZone = java.util.TimeZone.getTimeZone("UTC")
sdf.parse(isoStr.take(19))?.time
} catch (_: Exception) { null }
}
}
private data class CreditsPayload(
val monthlyCredits: Double,
val purchasedCredits: Double,
val freeCredits: Double,
val fiveHourUsed: Double?,
val fiveHourCap: Double?,
val fiveHourResetAt: Long?,
val weeklyUsed: Double?,
val weeklyCap: Double?,
val weeklyResetAt: Long?
)
private data class SubscriptionPayload(
val planId: String,
val currentPeriodEnd: String?
)
}

View File

@ -0,0 +1,231 @@
package com.rainy.token.data.repository
import com.rainy.token.data.local.UsageRecord
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.CookieEntry
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Base64
import java.util.Locale
import java.util.TimeZone
import javax.inject.Singleton
/**
* CommandCode 用量记录仓库。
*
* 调 JSON API 分页抓取 usage 记录:
* GET https://api.commandcode.ai/internal/usage?limit=50
* GET https://api.commandcode.ai/internal/usage?limit=50&cursor=<base64>
*
* cursor 是末条记录的 { createdAt, id } 的 base64 编码。
* 第一页不用 cursor。
*/
@Singleton
class CommandCodeUsageRepository(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository
) {
private val apiBase = "https://api.commandcode.ai"
companion object {
const val PAGE_SIZE = 100
/** cost / DENOM = USD */
const val COST_DENOM = 100_000_000L
/** CCGO 用量数据在 UsageCache 中的 workspaceId 区分键 */
const val CCGO_WORKSPACE_ID = "commandcode"
private val json = Json { ignoreUnknownKeys = true }
// ===== 纯解析逻辑(无网络依赖,可直接单测)=====
/**
* 解析 JSON 响应。
*
* 后端 2026-08 起改为新格式:
* - 顶层新增 nextCursor / limit / periodBasis / window;
* 分页游标由服务端 nextCursor 直接给出(缺失时回退按末条自编码,兼容旧格式)。
* - 每条记录新增 durationTotal / status / message / type / mode;
* tokensTotal / creditsTotal 已移除。
* - meta 新增 totalCost / inputCost / outputCost / cacheCost / traceId;
* provider / cacheReadInputTokens 已移除。
*/
internal fun parseUsageResponse(body: String): Pair<List<UsageRecord>, String?> {
val root = json.parseToJsonElement(body).jsonObject
val usages = root["usages"]?.jsonArray ?: return emptyList<UsageRecord>() to null
val records = usages.mapNotNull { elem ->
parseUsageObject(elem.jsonObject)
}
// 新格式:优先用服务端游标;缺失或显式 null 时回退按末条自编码(兼容旧格式)
// 注意:JSON null 的 jsonPrimitive.content 是字符串 "null",必须用 is JsonNull 拦截,
// 否则会把 "null" 当游标传给服务端(被忽略→返回第一页),导致 fullSync 死循环。
val serverCursor = root["nextCursor"]?.takeIf { it !is JsonNull }
?.jsonPrimitive?.content?.takeIf { it.isNotEmpty() }
val nextCursor = serverCursor ?: if (records.size >= PAGE_SIZE) {
val last = records.last()
encodeCursor(last.id, last.timeCreated)
} else null
return records to nextCursor
}
/**
* 解析单条 usage 对象。
*
* cost 来源:新格式 meta.totalCost(美元数值);旧格式顶层 creditsTotal(兼容)。
* provider:新格式已移除,缺失时为空字符串;旧格式 meta.provider 兼容读取。
* cacheReadInputTokens:新格式已移除,缺失时按 0(tokensIn 不再拆分缓存命中)。
*/
internal fun parseUsageObject(obj: JsonObject): UsageRecord? {
val id = obj["id"]?.jsonPrimitive?.content ?: return null
val createdAt = obj["createdAt"]?.jsonPrimitive?.content ?: return null
val timeCreated = parseIsoDate(createdAt) ?: return null
val tokensIn = obj["tokensIn"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
val tokensOut = obj["tokensOut"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
val meta = obj["meta"]?.jsonObject
val creditsTotal = meta?.get("totalCost")?.jsonPrimitive?.content?.toDoubleOrNull()
?: obj["creditsTotal"]?.jsonPrimitive?.content?.toDoubleOrNull()
?: 0.0
val cost = (creditsTotal * COST_DENOM).toLong()
val model = meta?.get("model")?.jsonPrimitive?.content ?: ""
val provider = meta?.get("provider")?.jsonPrimitive?.content ?: ""
val cacheReadInputTokens = meta?.get("cacheReadInputTokens")?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
// CCGO 的 tokensIn 是总输入(缓存命中 + 未命中),按 OCGO 惯例拆分为 inputTokens(未命中)和 cacheReadTokens(命中)
// 新格式无缓存 token 数时无法拆分,inputTokens 直接取 tokensIn
val inputMissTokens = (tokensIn - cacheReadInputTokens).coerceAtLeast(0)
return UsageRecord(
id = id,
workspaceId = CCGO_WORKSPACE_ID,
timeCreated = timeCreated,
timeUpdated = timeCreated,
model = model,
provider = provider,
inputTokens = inputMissTokens,
outputTokens = tokensOut,
reasoningTokens = 0L,
cacheReadTokens = cacheReadInputTokens,
cacheWrite5mTokens = 0L,
cacheWrite1hTokens = 0L,
cost = cost,
keyId = "",
sessionId = "",
enrichmentPlan = ""
)
}
private fun parseIsoDate(iso: String): Long? {
// 处理末尾 Z 和时区偏移
val normalized = iso
.replace("Z", "X")
.replace(Regex("""[+-]\d{2}:\d{2}$"""), "X")
return try {
// SimpleDateFormat 非线程安全,每次创建新实例
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'X'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
sdf.parse(normalized)?.time
?: run {
val sdf2 = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'X'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
sdf2.parse(normalized)?.time
}
} catch (_: Exception) { null }
}
/** 从记录信息编码为 base64 cursor */
private fun encodeCursor(id: String, timeCreated: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val createdAt = sdf.format(java.util.Date(timeCreated))
val cursorJson = """{"createdAt":"$createdAt","id":"$id"}"""
return Base64.getUrlEncoder().withoutPadding().encodeToString(cursorJson.toByteArray())
}
}
private suspend fun getCookieHeader(): String {
val c = credentialRepository.get(ServiceType.COMMANDCODE_GO)
?: throw RepositoryError.InvalidCredential()
if (c !is Credential.SessionCredential) {
throw RepositoryError.InvalidCredential()
}
// 优先用 cookies 列表,否则尝试从 authCookie 字段解析
if (c.cookies.isNotEmpty()) {
return c.cookies.joinToString("; ") { "${it.name}=${it.value}" }
}
// fallback: 如果用户通过旧版接口存了 authCookie,尝试恢复
throw RepositoryError.InvalidCredential()
}
/**
* 获取指定游标页的用量记录。
* cursor=null 为最新页。
* 返回 (记录列表, 下一页游标)。如果返回的列表长度 < PAGE_SIZE,表示到底。
*/
suspend fun fetchPage(cursor: String?): Result<Pair<List<UsageRecord>, String?>> =
withContext(Dispatchers.IO) {
val cookieHeader = try {
getCookieHeader()
} catch (e: RepositoryError) {
return@withContext Result.failure(e)
}
val url = buildString {
append("$apiBase/internal/usage?limit=$PAGE_SIZE")
if (cursor != null) append("&cursor=$cursor")
}
val request = Request.Builder()
.url(url)
.header("Cookie", cookieHeader)
.header("Accept", "application/json")
.header("Origin", "https://commandcode.ai")
.header("Referer", "https://commandcode.ai/")
.get()
.build()
val response = try {
okHttpClient.newCall(request).execute()
} catch (e: IOException) {
return@withContext Result.failure(RepositoryError.Network(e))
}
response.use { resp ->
val body = resp.body?.string()
if (!resp.isSuccessful) {
if (resp.code == 401 || resp.code == 403) {
val detail = if (body != null && body.length < 200) ":$body" else ""
return@withContext Result.failure(RepositoryError.InvalidCredential(
"HTTP ${resp.code}$detail"
))
}
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
if (body == null) return@withContext Result.failure(
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val records = parseUsageResponse(body)
Result.success(records)
}
}
}

View File

@ -1,66 +1,902 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.cache.CachedBalance
import com.rainy.token.data.local.SecureStorage
import com.rainy.token.domain.model.AccountStore
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceAccountInfo
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import kotlinx.serialization.builtins.serializer
import java.security.MessageDigest
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* 凭据的统一读写入口。封装 SecureStorage 的 key 命名规则(用 [ServiceType.storageKey]),
* 把 JSON 反序列化成密封类 [Credential]。
* 凭据的统一读写入口。
*
* 所有凭据变更都经过 [mutationMutex] 串行化,并维护进程内 revision。网络刷新先取得
* [CredentialSnapshot];请求结束后只有快照仍为当前版本时,暂存的凭据与余额才会提交。
*
* 多账号服务(Trae / WorkBuddy):
* - 账号元数据([ServiceAccountInfo])存 `accounts_{storageKey}`(加密 DataStore);
* - 每个账号的凭据独立存 `credential_{storageKey}_{accountId}`;
* - 旧版本单凭据存 `credential_{storageKey}`,首次读取时自动迁移为默认账号
* (固定 id = [LEGACY_DEFAULT_ACCOUNT_ID]),旧键保留作为兼容回退。
*/
@Singleton
class CredentialRepository @Inject constructor(
private val secureStorage: SecureStorage
private val secureStorage: SecureStorage,
private val balanceCache: BalanceCache
) {
private fun keyFor(service: ServiceType): String = "credential_${service.storageKey}"
suspend fun save(credential: Credential) {
secureStorage.put(
key = keyFor(credential.service),
value = credential,
serializer = Credential.serializer()
)
internal data class CredentialSnapshot(
val credential: Credential,
val revision: Long,
val fingerprint: String,
/** 快照所属账号;单账号服务为 null。 */
val accountId: String? = null,
/** 创建快照时调用方请求的账号 ID(多账号服务可能是 null=默认账号),用于会话内精确匹配。 */
val requestedAccountId: String? = accountId
) {
val service: ServiceType get() = credential.service
}
suspend fun get(service: ServiceType): Credential? =
secureStorage.get(
key = keyFor(service),
serializer = Credential.serializer()
)
internal data class LocalState(
val status: CredentialStatus,
val fingerprint: String?,
val cachedBalance: CachedBalance?,
/** 当前凭据对象(首页卡片可能需要读取如 Sub2API 站点 Logo 等附加字段)。 */
val credential: Credential? = null
)
suspend fun remove(service: ServiceType) {
secureStorage.remove(keyFor(service))
/** 按账号展开的本地状态(多账号服务一个账号一条;单账号服务一条 [account]=null)。 */
data class AccountLocalState(
val service: ServiceType,
val account: ServiceAccountInfo?,
val status: CredentialStatus,
val fingerprint: String?,
val cachedBalance: CachedBalance?,
val credential: Credential?
)
private data class CacheRollbackEntry(
val testedFingerprint: String,
val previousIdentity: String?,
val cachedBalance: CachedBalance?
)
private val mutationMutex = Mutex()
private val revisions = mutableMapOf<String, Long>()
private val cacheRollbackEntries = mutableMapOf<String, CacheRollbackEntry>()
private fun accountDocKeyFor(service: ServiceType): String = "accounts_${service.storageKey}"
/** 凭据存储键:单账号(或兼容回退)为 `credential_{storageKey}`。 */
private fun keyFor(service: ServiceType, accountId: String?): String =
if (accountId.isNullOrBlank()) {
"credential_${service.storageKey}"
} else {
"credential_${service.storageKey}_$accountId"
}
private fun accountCacheKey(service: ServiceType, accountId: String): String =
"${service.storageKey}:$accountId"
private fun revisionKey(service: ServiceType, accountId: String?): String =
"${service.storageKey}:${accountId ?: ""}"
// ─────────────────────────────────────────────
// 公开 API(多账号 + 单账号统一入口)
// ─────────────────────────────────────────────
/** 列出某服务的账号(多账号服务;单账号服务恒为空列表)。 */
suspend fun accountsFor(service: ServiceType): List<ServiceAccountInfo> =
mutationMutex.withLock { readAccountsUnlocked(service) }
/** 当前默认账号;无账号返回 null。 */
suspend fun getDefaultAccount(service: ServiceType): ServiceAccountInfo? =
mutationMutex.withLock { resolveAccountUnlocked(service, null) }
/**
* 新建账号(不写入凭据)。返回新账号;非多账号服务返回 null。
* 首个账号自动设为默认账号,label 为空时 UI 按“账号 N”兜底显示。
*/
suspend fun createAccount(service: ServiceType, label: String? = null): ServiceAccountInfo? =
mutationMutex.withLock {
if (!ServiceType.isMultiAccount(service)) return@withLock null
createAccountUnlocked(service, label)
}
/** 设置默认账号(账号不存在时忽略)。切换默认账号时清除旧结构镜像缓存,避免新默认账号读到旧默认账号的余额。 */
suspend fun setDefaultAccount(service: ServiceType, accountId: String) {
mutationMutex.withLock {
val accounts = readAccountsUnlocked(service)
if (accounts.none { it.id == accountId }) return@withLock
val previousDefault = accounts.firstOrNull { it.isDefault } ?: accounts.firstOrNull()
putAccountsUnlocked(
service,
accounts.map { it.copy(isDefault = it.id == accountId) }
)
if (previousDefault?.id != accountId) {
balanceCache.removeLegacy(service)
}
}
}
/** 重命名账号(空白 label 视为清除自定义名称)。 */
suspend fun renameAccount(service: ServiceType, accountId: String, label: String) {
mutationMutex.withLock {
val accounts = readAccountsUnlocked(service)
putAccountsUnlocked(
service,
accounts.map {
if (it.id == accountId) it.copy(label = label.takeIf { l -> l.isNotBlank() }) else it
}
)
}
}
/**
* 读取并转换为 UI 用的 [CredentialStatus]。未配置/已删除都返回 NOT_CONFIGURED。
* 删除单个账号:删除该账号凭据 + 独立缓存;若删除的是默认账号则提升第一个
* 剩余账号为默认;最后一个账号删除后整个服务回到未配置状态。
*/
suspend fun statusFor(service: ServiceType): CredentialStatus {
val credential = get(service) ?: return CredentialStatus(
suspend fun removeAccount(service: ServiceType, accountId: String) {
mutationMutex.withLock {
if (!ServiceType.isMultiAccount(service)) return@withLock
val accounts = readAccountsUnlocked(service)
val removed = accounts.firstOrNull { it.id == accountId } ?: return@withLock
val remaining = accounts.filterNot { it.id == accountId }
bumpRevision(service, accountId)
cacheRollbackEntries.remove(accountCacheKey(service, accountId))
balanceCache.removeAccount(service, accountId)
secureStorage.remove(keyFor(service, accountId))
if (remaining.isEmpty()) {
secureStorage.remove(accountDocKeyFor(service))
secureStorage.remove(keyFor(service, null))
balanceCache.remove(service)
clearRollbacksFor(service)
} else {
var fixed = remaining
if (fixed.none { it.isDefault }) {
val first = fixed.first()
fixed = fixed.map { if (it.id == first.id) it.copy(isDefault = true) else it }
}
putAccountsUnlocked(service, fixed)
// 删除的是默认账号时,旧结构镜像缓存属于被删账号,必须只清镜像(保留其余账号的独立分片缓存,
// 避免全部账号缓存被误清导致都要重新联网拉取)
if (removed.isDefault) {
balanceCache.removeLegacy(service)
}
}
}
}
/**
* 保存凭据。
*
* - 多账号服务:[accountId] 为 null 时**总是新建账号**(导航层保证 null 只来自
* 「添加账号」流程),首个新建账号自动成为默认账号;显式 [accountId] 覆盖对应账号。
* 返回实际写入的账号 ID(用于新建账号后继续编辑)。
* - 单账号服务:忽略 [accountId],行为与旧版一致,返回空串。
* - 在 [RefreshWriteSession] 上下文中只暂存,返回快照账号 ID。
*/
suspend fun save(credential: Credential, accountId: String? = null): String {
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
session.stageCredential(credential)
return session.snapshot.accountId ?: ""
}
mutationMutex.withLock {
val service = credential.service
val effectiveId = if (ServiceType.isMultiAccount(service)) {
if (accountId.isNullOrBlank()) {
// 多账号服务的 null accountId = “新建账号”模式(导航层保证),总是新建,
// 避免覆盖默认账号;首个账号自动成为默认账号。
createAccountUnlocked(service, null).id
} else {
resolveAccountUnlocked(service, accountId)?.id
?: createAccountUnlocked(service, null).id
}
} else {
null
}
val key = revisionKey(service, effectiveId)
val current = getUnlocked(service, effectiveId)
val currentIdentity = cacheIdentityFingerprint(current)
val newIdentity = cacheIdentityFingerprint(credential)
val cacheIdentityChanged = currentIdentity != newIdentity
bumpRevision(service, effectiveId)
if (cacheIdentityChanged) {
cacheRollbackEntries[key] = CacheRollbackEntry(
testedFingerprint = credentialFingerprint(credential)!!,
previousIdentity = currentIdentity,
cachedBalance = if (current != null) {
accountCacheFor(service, effectiveId)
} else {
null
}
)
removeCacheFor(service, effectiveId)
} else {
cacheRollbackEntries.remove(key)
}
putUnlocked(credential, effectiveId)
return effectiveId ?: ""
}
}
suspend fun get(service: ServiceType, accountId: String? = null): Credential? {
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
if (session.snapshot.service == service) {
val matches = if (ServiceType.isMultiAccount(service)) {
val requested = accountId?.takeIf { it.isNotBlank() }
// 只允许读取与快照同一账号的暂存凭据;null=默认账号也要看快照当初请求的是不是默认账号,
// 避免 A 账号刷新期间把 A 的暂存凭据误交给默认账号(B)的读请求。
requested == session.snapshot.requestedAccountId
} else {
true
}
if (matches) return session.credentialForRead()
}
}
return mutationMutex.withLock { getUnlocked(service, accountId) }
}
internal suspend fun readLocalState(
service: ServiceType,
accountId: String? = null
): LocalState =
mutationMutex.withLock {
val credential = getUnlocked(service, accountId)
localStateOf(service, credential, accountCacheFor(service, accountId))
}
/** 全部服务的按账号本地状态(保留单账号服务旧语义,多账号展开一条/账号)。 */
suspend fun readAccountStates(): List<AccountLocalState> =
mutationMutex.withLock { readAccountStatesUnlocked() }
internal suspend fun readLocalStates(): Map<ServiceType, LocalState> =
mutationMutex.withLock {
val accountStates = readAccountStatesUnlocked()
val legacyCached = balanceCache.getAll()
ServiceType.entries.associateWith { service ->
// 默认账号优先(多账号服务中自动签到/小组件使用的账号)
val entry = accountStates
.filter { it.service == service }
.minByOrNull { if (it.account?.isDefault == true) 0 else 1 }
if (entry != null) {
localStateOf(service, entry.credential, entry.cachedBalance)
} else {
val credential = readLegacyCredentialUnlocked(service)
localStateOf(service, credential, legacyCached[service])
}
}
}
suspend fun remove(service: ServiceType) {
mutationMutex.withLock {
val accounts = if (ServiceType.isMultiAccount(service)) {
readAccountsUnlocked(service)
} else {
emptyList()
}
clearRollbacksFor(service)
bumpRevision(service, null)
accounts.forEach { account ->
bumpRevision(service, account.id)
secureStorage.remove(keyFor(service, account.id))
}
secureStorage.remove(keyFor(service, null))
secureStorage.remove(accountDocKeyFor(service))
balanceCache.remove(service)
}
}
/**
* 仅当测试期间凭据 revision 与认证指纹都未变化时恢复旧凭据。
* 若替换凭据时清除了旧账户缓存,这里会一并恢复原缓存和原 fetchedAt。
*/
internal suspend fun restoreIfCurrent(
testedSnapshot: CredentialSnapshot,
previous: Credential?
): Boolean = mutationMutex.withLock {
val service = testedSnapshot.service
val accountId = testedSnapshot.accountId
val current = getUnlocked(service, accountId) ?: return@withLock false
if (
!snapshotMatches(
snapshotRevision = testedSnapshot.revision,
snapshotFingerprint = testedSnapshot.fingerprint,
currentRevision = revisionFor(service, accountId),
currentFingerprint = credentialFingerprint(current)
)
) return@withLock false
val currentIdentity = cacheIdentityFingerprint(current)
val previousIdentity = cacheIdentityFingerprint(previous)
val identityChanged = currentIdentity != previousIdentity
val rollbackEntry = cacheRollbackEntries[revisionKey(service, accountId)]?.takeIf {
it.testedFingerprint == testedSnapshot.fingerprint &&
it.previousIdentity == previousIdentity
}
bumpRevision(service, accountId)
if (identityChanged) {
removeCacheFor(service, accountId)
}
if (previous == null) {
secureStorage.remove(keyFor(service, accountId))
} else {
require(previous.service == service) { "回滚凭据服务不匹配" }
putUnlocked(previous, accountId)
}
if (identityChanged) {
rollbackEntry?.cachedBalance?.let {
if (accountId.isNullOrBlank()) {
balanceCache.putCached(service, it)
} else {
balanceCache.putAccountCached(service, accountId, it)
if (resolveAccountUnlocked(service, null)?.id == accountId) {
balanceCache.putCached(service, it)
}
}
}
}
cacheRollbackEntries.remove(revisionKey(service, accountId))
true
}
internal suspend fun snapshot(
service: ServiceType,
accountId: String? = null
): CredentialSnapshot? =
mutationMutex.withLock {
val resolvedId = if (ServiceType.isMultiAccount(service)) {
resolveAccountUnlocked(service, accountId)?.id
} else {
null
}
val credential = getUnlocked(service, resolvedId) ?: return@withLock null
CredentialSnapshot(
credential = credential,
revision = revisionFor(service, resolvedId),
fingerprint = credentialFingerprint(credential)!!,
accountId = resolvedId,
requestedAccountId = accountId?.takeIf { it.isNotBlank() }
)
}
/**
* 提交 [RefreshWriteSession] 中暂存的写入。
*
* 完全只读的请求忽略同一凭据的 revision-only 变化,但成功和失败都必须确认认证
* 指纹仍与请求起点一致,避免把旧账户结果发布到用户刚替换的新凭据。存在写入时
* 通常要求快照完全匹配;认证字段合法轮换时允许越过同账户纯元数据版本变化。
*/
internal suspend fun commit(
session: RefreshWriteSession,
includeBalance: Boolean
): Boolean = mutationMutex.withLock {
val pendingCredential = session.stagedCredential()
val pendingBalance = session.stagedBalance().takeIf { includeBalance }
val hasPendingWrites = pendingCredential != null || pendingBalance != null
val snapshot = session.snapshot
val service = snapshot.service
val accountId = snapshot.accountId
val current = getUnlocked(service, accountId) ?: return@withLock false
val currentFingerprint = credentialFingerprint(current)
if (!hasPendingWrites) {
return@withLock currentFingerprint == snapshot.fingerprint
}
val snapshotStillCurrent = snapshotMatches(
snapshotRevision = snapshot.revision,
snapshotFingerprint = snapshot.fingerprint,
currentRevision = revisionFor(service, accountId),
currentFingerprint = currentFingerprint
)
val canMergeRotatedCredential = pendingCredential != null &&
credentialFingerprint(pendingCredential) != snapshot.fingerprint &&
currentFingerprint == snapshot.fingerprint &&
sameRefreshLineage(snapshot.credential, pendingCredential)
if (!snapshotStillCurrent && !canMergeRotatedCredential) return@withLock false
val finalCredential = pendingCredential ?: current
val sameLineage = sameRefreshLineage(current, finalCredential)
val cacheIdentityChanged =
cacheIdentityFingerprint(current) != cacheIdentityFingerprint(finalCredential) &&
!sameLineage
bumpRevision(service, accountId)
if (cacheIdentityChanged) {
removeCacheFor(service, accountId)
}
if (pendingCredential != null) {
putUnlocked(pendingCredential, accountId)
}
if (pendingBalance != null) {
val cached = CachedBalance(balance = pendingBalance, fetchedAt = System.currentTimeMillis())
if (accountId.isNullOrBlank()) {
balanceCache.putCached(service, cached)
} else {
balanceCache.putAccountCached(service, accountId, cached)
if (resolveAccountUnlocked(service, null)?.id == accountId) {
balanceCache.putCached(service, cached)
}
}
cacheRollbackEntries.remove(revisionKey(service, accountId))
}
true
}
suspend fun statusFor(
service: ServiceType,
accountId: String? = null
): CredentialStatus {
val credential = get(service, accountId) ?: return CredentialStatus(
service = service,
state = CredentialStatus.State.NOT_CONFIGURED,
lastVerifiedAt = 0L
)
val now = System.currentTimeMillis()
// 简单启发:最近 7 天内有验证 → OK;否则按 lastVerifiedAt 是否为 0 判断
val state = when {
credential.lastVerifiedAt == 0L -> CredentialStatus.State.WARNING
now - credential.lastVerifiedAt > 7L * 24 * 3600 * 1000 -> CredentialStatus.State.WARNING
else -> CredentialStatus.State.OK
}
return CredentialStatus(
service = service,
state = state,
state = determineCredentialState(
credential.lastVerifiedAt,
System.currentTimeMillis()
),
lastVerifiedAt = credential.lastVerifiedAt
)
}
suspend fun statusForAll(): List<CredentialStatus> =
ServiceConfigProvider.all().map { statusFor(it.type) }
// ─────────────────────────────────────────────
// 实现细节
// ─────────────────────────────────────────────
private suspend fun readAccountStatesUnlocked(): List<AccountLocalState> {
val cached = balanceCache.getAllAccounts()
val legacyCached = balanceCache.getAll()
return ServiceType.entries.flatMap { service ->
if (ServiceType.isMultiAccount(service)) {
val accounts = readAccountsUnlocked(service)
if (accounts.isEmpty()) {
val legacy = readLegacyCredentialUnlocked(service)
if (legacy == null) {
emptyList()
} else {
listOf(accountLocalStateOf(service, null, legacy, legacyCached[service]))
}
} else {
accounts.map { account ->
val credential = getUnlocked(service, account.id)
val cache = cached[accountCacheKey(service, account.id)]
?: if (account.isDefault) legacyCached[service] else null
accountLocalStateOf(service, account, credential, cache)
}
}
} else {
val credential = readLegacyCredentialUnlocked(service)
if (credential == null) {
emptyList()
} else {
listOf(accountLocalStateOf(service, null, credential, legacyCached[service]))
}
}
}
}
private fun accountLocalStateOf(
service: ServiceType,
account: ServiceAccountInfo?,
credential: Credential?,
cached: CachedBalance?
): AccountLocalState {
val status = if (credential == null) {
CredentialStatus(
service = service,
state = CredentialStatus.State.NOT_CONFIGURED,
lastVerifiedAt = 0L
)
} else {
CredentialStatus(
service = service,
state = determineCredentialState(
credential.lastVerifiedAt,
System.currentTimeMillis()
),
lastVerifiedAt = credential.lastVerifiedAt
)
}
return AccountLocalState(
service = service,
account = account,
status = status,
fingerprint = credentialFingerprint(credential),
cachedBalance = cached,
credential = credential
)
}
private fun localStateOf(
service: ServiceType,
credential: Credential?,
cached: CachedBalance?
): LocalState {
val status = if (credential == null) {
CredentialStatus(
service = service,
state = CredentialStatus.State.NOT_CONFIGURED,
lastVerifiedAt = 0L
)
} else {
CredentialStatus(
service = service,
state = determineCredentialState(
credential.lastVerifiedAt,
System.currentTimeMillis()
),
lastVerifiedAt = credential.lastVerifiedAt
)
}
return LocalState(
status = status,
fingerprint = credentialFingerprint(credential),
cachedBalance = cached,
credential = credential
)
}
/** 读取账号元数据;多账号服务且文档不存在时尝试从旧单凭据迁移。 */
private suspend fun readAccountsUnlocked(service: ServiceType): List<ServiceAccountInfo> {
if (!ServiceType.isMultiAccount(service)) return emptyList()
val stored = secureStorage.get(accountDocKeyFor(service), AccountStore.serializer())
?: return migrateLegacyUnlocked(service)
return stored.accounts
}
/** 旧版本单凭据迁移为默认账号(幂等,失败重试安全)。 */
private suspend fun migrateLegacyUnlocked(service: ServiceType): List<ServiceAccountInfo> {
val legacy = readLegacyCredentialUnlocked(service) ?: return emptyList()
val account = ServiceAccountInfo(
id = LEGACY_DEFAULT_ACCOUNT_ID,
label = null,
isDefault = true,
createdAt = System.currentTimeMillis()
)
// 先复制凭据、再写账号文档;半途失败时下次读到旧键会重新迁移
secureStorage.put(keyFor(service, account.id), legacy, Credential.serializer())
putAccountsUnlocked(service, listOf(account))
return listOf(account)
}
private suspend fun putAccountsUnlocked(
service: ServiceType,
accounts: List<ServiceAccountInfo>
) {
secureStorage.put(
key = accountDocKeyFor(service),
value = AccountStore(service = service, accounts = accounts),
serializer = AccountStore.serializer()
)
}
private suspend fun createAccountUnlocked(
service: ServiceType,
label: String?
): ServiceAccountInfo {
val accounts = readAccountsUnlocked(service).toMutableList()
val account = ServiceAccountInfo(
id = "acct_" + UUID.randomUUID().toString().replace("-", "").take(12),
label = label?.takeIf { it.isNotBlank() },
isDefault = accounts.isEmpty(),
createdAt = System.currentTimeMillis()
)
accounts += account
putAccountsUnlocked(service, accounts)
return account
}
/** 解析目标账号:显式 ID → 默认账号(无显式时)。 */
private suspend fun resolveAccountUnlocked(
service: ServiceType,
accountId: String?
): ServiceAccountInfo? {
if (!ServiceType.isMultiAccount(service)) return null
val accounts = readAccountsUnlocked(service)
if (accounts.isEmpty()) return null
val id = accountId?.takeIf { it.isNotBlank() }
return if (id != null) {
accounts.firstOrNull { it.id == id }
} else {
accounts.firstOrNull { it.isDefault } ?: accounts.first()
}
}
private suspend fun accountsDocExistsUnlocked(service: ServiceType): Boolean =
secureStorage.get(accountDocKeyFor(service), AccountStore.serializer()) != null
/** 读取旧结构单凭据(多账号服务的旧版本数据 / 单账号服务的标准凭据)。 */
private suspend fun readLegacyCredentialUnlocked(service: ServiceType): Credential? =
secureStorage.get(keyFor(service, null), Credential.serializer())
/**
* 按账号读取凭据:
* - 多账号服务:解析账号后读独立键;无账号文档时回退旧键;
* - 单账号服务:恒读旧键。
*/
private suspend fun getUnlocked(
service: ServiceType,
accountId: String? = null
): Credential? {
if (ServiceType.isMultiAccount(service)) {
val resolved = resolveAccountUnlocked(service, accountId)
if (resolved != null) {
return secureStorage.get(keyFor(service, resolved.id), Credential.serializer())
}
return if (accountId.isNullOrBlank() && !accountsDocExistsUnlocked(service)) {
readLegacyCredentialUnlocked(service)
} else {
null
}
}
return readLegacyCredentialUnlocked(service)
}
private suspend fun putUnlocked(credential: Credential, accountId: String?) {
secureStorage.put(
key = keyFor(credential.service, accountId),
value = credential,
serializer = Credential.serializer()
)
}
/** 按账号读缓存:优先账号分片,默认账号回退旧结构镜像。 */
private suspend fun accountCacheFor(
service: ServiceType,
accountId: String?
): CachedBalance? {
if (!ServiceType.isMultiAccount(service)) return balanceCache.get(service)
val id = accountId?.takeIf { it.isNotBlank() }
return if (id != null) {
balanceCache.getAccountCached(service, id)
?: if (resolveAccountUnlocked(service, null)?.id == id) balanceCache.get(service) else null
} else {
resolveAccountUnlocked(service, null)?.let { account ->
balanceCache.getAccountCached(service, account.id) ?: balanceCache.get(service)
}
}
}
/** 删除某账号(或单账号服务)关联的余额缓存;默认账号会一并清理旧结构镜像。 */
private suspend fun removeCacheFor(service: ServiceType, accountId: String?) {
if (ServiceType.isMultiAccount(service)) {
if (accountId.isNullOrBlank()) {
balanceCache.removeLegacy(service)
} else {
balanceCache.removeAccount(service, accountId)
if (resolveAccountUnlocked(service, null)?.id == accountId) {
// 默认账号被替换:旧结构镜像属于旧默认账号,只清镜像,保留其余账号分片缓存
balanceCache.removeLegacy(service)
}
}
} else {
balanceCache.remove(service)
}
}
private fun clearRollbacksFor(service: ServiceType) {
val prefix = service.storageKey + ":"
cacheRollbackEntries.keys
.filter { it == service.storageKey || it.startsWith(prefix) }
.forEach { cacheRollbackEntries.remove(it) }
}
private fun revisionFor(service: ServiceType, accountId: String?): Long =
revisions[revisionKey(service, accountId)] ?: 0L
private fun bumpRevision(service: ServiceType, accountId: String?) {
revisions[revisionKey(service, accountId)] = revisionFor(service, accountId) + 1L
}
companion object {
/** 旧单凭据迁移为默认账号时使用的固定账号 ID。 */
const val LEGACY_DEFAULT_ACCOUNT_ID = "default"
internal fun determineCredentialState(
lastVerifiedAt: Long,
now: Long
): CredentialStatus.State = when {
lastVerifiedAt == 0L -> CredentialStatus.State.WARNING
now - lastVerifiedAt > 7L * 24 * 3600 * 1000 -> CredentialStatus.State.WARNING
else -> CredentialStatus.State.OK
}
/**
* 判断认证更新是否来自同一条合法刷新链。Codex 的 refresh 响应由旧凭据 copy
* 产生,因此 accountId(即使为空)必须保持一致;其他类型要求余额账户身份一致。
*/
internal fun sameRefreshLineage(
original: Credential,
updated: Credential
): Boolean = when {
original is Credential.CodexCredential && updated is Credential.CodexCredential ->
original.service == updated.service && original.accountId == updated.accountId
else -> cacheIdentityFingerprint(original) == cacheIdentityFingerprint(updated)
}
/**
* 余额缓存所属账户的不可逆标识。Codex 的短期 Token 轮换不会改变账户标识;
* SessionCredential 的触发用 API Key 也不会影响余额账户。
*/
internal fun cacheIdentityFingerprint(credential: Credential?): String? {
if (credential == null) return null
val material = buildString {
field("service", credential.service.storageKey)
when (credential) {
is Credential.ApiKeyCredential -> {
field("type", "api-key")
field("key", credential.key)
}
is Credential.SessionCredential -> {
field("type", "session")
field("token", credential.token)
field("authCookie", credential.authCookie)
field("workspaceId", credential.workspaceId)
field("ollamaCookie", credential.ollamaCookie)
credential.cookies.sortedForFingerprint().forEachIndexed { index, cookie ->
field("cookie[$index].name", cookie.name)
field("cookie[$index].value", cookie.value)
field("cookie[$index].domain", cookie.domain)
field("cookie[$index].path", cookie.path)
}
}
is Credential.CodexCredential -> {
field("type", "codex")
if (credential.accountId.isNotBlank()) {
field("accountId", credential.accountId)
} else {
field("accessToken", credential.accessToken)
field("refreshToken", credential.refreshToken)
}
}
is Credential.TraeCredential -> {
field("type", "trae")
field("jwt", credential.jwt)
field("region", credential.region)
}
is Credential.WorkBuddyCredential -> {
// WorkBuddy 无公开稳定账户 ID;access/refresh token 会话内会轮换,
// 若把 token 纳入身份,每次轮换都会误清余额缓存。这里用固定身份:
// 余额缓存归属同一账号(换账号场景由下一次成功刷新直接覆盖)。
field("type", "workbuddy")
field("identity", "account")
}
is Credential.Sub2ApiCredential -> {
field("type", "sub2api")
field("baseUrl", credential.baseUrl)
field("email", credential.email)
}
}
}
return sha256(material)
}
internal fun snapshotMatches(
snapshotRevision: Long,
snapshotFingerprint: String,
currentRevision: Long,
currentFingerprint: String?
): Boolean =
snapshotRevision == currentRevision && snapshotFingerprint == currentFingerprint
/** 对认证相关字段生成不可逆 SHA-256 指纹;不包含 lastVerifiedAt。 */
internal fun credentialFingerprint(credential: Credential?): String? {
if (credential == null) return null
val material = buildString {
field("service", credential.service.storageKey)
when (credential) {
is Credential.ApiKeyCredential -> {
field("type", "api-key")
field("key", credential.key)
}
is Credential.SessionCredential -> {
field("type", "session")
field("token", credential.token)
field("authCookie", credential.authCookie)
field("workspaceId", credential.workspaceId)
field("ollamaCookie", credential.ollamaCookie)
field("apiKey", credential.apiKey)
credential.cookies.sortedForFingerprint().forEachIndexed { index, cookie ->
field("cookie[$index].name", cookie.name)
field("cookie[$index].value", cookie.value)
field("cookie[$index].domain", cookie.domain)
field("cookie[$index].path", cookie.path)
field("cookie[$index].expiresAt", cookie.expiresAt?.toString())
field("cookie[$index].secure", cookie.isSecure.toString())
field("cookie[$index].httpOnly", cookie.isHttpOnly.toString())
}
}
is Credential.CodexCredential -> {
field("type", "codex")
field("accessToken", credential.accessToken)
field("refreshToken", credential.refreshToken)
field("accountId", credential.accountId)
}
is Credential.TraeCredential -> {
field("type", "trae")
field("jwt", credential.jwt)
field("region", credential.region)
}
is Credential.WorkBuddyCredential -> {
field("type", "workbuddy")
field("accessToken", credential.accessToken)
field("refreshToken", credential.refreshToken)
field("expiresAt", credential.expiresAt.toString())
}
is Credential.Sub2ApiCredential -> {
field("type", "sub2api")
field("baseUrl", credential.baseUrl)
field("email", credential.email)
field("password", credential.password)
field("apiKey", credential.apiKey)
}
}
}
return sha256(material)
}
/** 账号存储键(与 BalanceCache 的复合键格式一致,供测试断言)。 */
internal fun accountCacheKeyFor(service: ServiceType, accountId: String): String =
"${service.storageKey}:$accountId"
private fun List<com.rainy.token.domain.model.CookieEntry>.sortedForFingerprint() =
sortedWith(
compareBy(
{ it.name },
{ it.domain.orEmpty() },
{ it.path.orEmpty() },
{ it.value }
)
)
private fun StringBuilder.field(name: String, value: String?) {
append(name)
append('=')
if (value == null) {
append("-1:")
} else {
append(value.length)
append(':')
append(value)
}
append(';')
}
private fun sha256(value: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
val chars = CharArray(digest.size * 2)
val hex = "0123456789abcdef"
digest.forEachIndexed { index, byte ->
val unsigned = byte.toInt() and 0xff
chars[index * 2] = hex[unsigned ushr 4]
chars[index * 2 + 1] = hex[unsigned and 0x0f]
}
return String(chars)
}
}
}

View File

@ -76,7 +76,7 @@ class DeepSeekRepository @Inject constructor(
}
private fun mapHttpError(e: HttpException): RepositoryError = when (e.code()) {
401, 403 -> RepositoryError.InvalidCredential(e)
401, 403 -> RepositoryError.InvalidCredential(cause = e)
429 -> {
val retryAfter = e.response()?.headers()?.get("Retry-After")?.toLongOrNull()
RepositoryError.RateLimited(retryAfter)

View File

@ -0,0 +1,345 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import javax.inject.Singleton
/**
* Ollama Cloud 用量仓库。
*
* Ollama 没有公开的 Usage API(见 ollama/ollama#12532),
* 本 Repository 通过 OkHttp 搓 Cookie 请求 https://ollama.com/settings,
* 解析返回 HTML 中的用量数据:
*
* - Plan 层级(Pro / Max / Free):Cloud usage 标题旁的 rounded-full badge
* - Session usage 百分比:Session usage 区域的文本
* - Weekly usage 百分比:Weekly usage 区域的文本
* - 重置时间:data-time 属性(第 1 个=Session,第 2 个=Weekly)
* - 模型级请求次数:data-model + data-requests 属性
*
* 认证方式:用户从浏览器 DevTools 复制完整 Cookie 字符串(至少含 `__Secure-session`)。
*/
@Singleton
class OllamaRepository(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
companion object {
private const val TAG = "Ollama"
private const val SETTINGS_URL = "https://ollama.com/settings"
private const val MODELS_API = "https://models.dev/api.json"
private const val CHAT_API = "https://ollama.com/v1/chat/completions"
private const val USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
/**
* 解析 Ollama settings 页面 HTML,提取用量数据。
* 正则方案与 hermes-ollama-cloud-usage 和 CodexBar 一致。
*/
internal fun parseUsage(html: String): ParsedUsage? {
// Plan: "Cloud usage" 后的 rounded-full badge 文本
val planMatch = Regex(
"""Cloud usage[^<]*</span>\s*<span[^>]*class="[^"]*rounded-full[^"]*"[^>]*>\s*(pro|max|free)\s*</span""",
RegexOption.IGNORE_CASE
).find(html)
val plan = planMatch?.groupValues?.get(1)?.trim()?.replaceFirstChar { it.uppercaseChar() }
?: run {
// Fallback: 任意 rounded-full + text-neutral badge
val fallback = Regex(
"""<span[^>]*class="[^"]*rounded-full[^"]*text-neutral[^"]*"[^>]*>\s*\n?\s*(pro|max|free)\s*</span""",
RegexOption.IGNORE_CASE
).find(html)
fallback?.groupValues?.get(1)?.trim()?.replaceFirstChar { it.uppercaseChar() }
}
// Session usage: 先找 aria-label,再找 visible text
val sessionPct = Regex("""aria-label="Session usage\s+([\d.]+)%\s+used""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
?: Regex("""Session usage[^<]*>[\s\S]*?([\d.]+)%\s*used""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
// Weekly usage: 同上
val weeklyPct = Regex("""aria-label="Weekly usage\s+([\d.]+)%\s+used""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
?: Regex("""Weekly usage[^<]*>[\s\S]*?([\d.]+)%\s*used""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
// data-time 属性 → 重置时间戳
val dataTimes = Regex("""data-time="([^"]+)"""").findAll(html).map { it.groupValues[1] }.toList()
val sessionResetAt = dataTimes.getOrNull(0)?.let { parseIsoTime(it) }
val weeklyResetAt = dataTimes.getOrNull(1)?.let { parseIsoTime(it) }
// 模型级请求次数: data-model + data-requests
val weeklyStart = html.indexOf("Weekly usage")
val sessionModels = mutableListOf<Pair<String, Int>>()
val weeklyModels = mutableListOf<Pair<String, Int>>()
Regex("""data-model="([^"]+)"\s+data-requests="(\d+)"""").findAll(html).forEach { match ->
val model = match.groupValues[1]
val requests = match.groupValues[2].toIntOrNull() ?: 0
if (match.range.first < weeklyStart) {
sessionModels.add(model to requests)
} else {
weeklyModels.add(model to requests)
}
}
if (sessionPct == null && weeklyPct == null) return null
return ParsedUsage(
plan = plan.orEmpty(), // 空串由 UI 层兜底显示 "—",避免英文 "Unknown" 出现在中文 UI
sessionPercent = sessionPct ?: 0f,
weeklyPercent = weeklyPct ?: 0f,
sessionResetAt = sessionResetAt,
weeklyResetAt = weeklyResetAt,
sessionModels = sessionModels,
weeklyModels = weeklyModels
)
}
internal fun parseIsoTime(iso: String): Long? {
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
sdf.parse(iso)?.time
} catch (e: Exception) {
null
}
}
internal data class ParsedUsage(
val plan: String,
val sessionPercent: Float,
val weeklyPercent: Float,
val sessionResetAt: Long?,
val weeklyResetAt: Long?,
val sessionModels: List<Pair<String, Int>>,
val weeklyModels: List<Pair<String, Int>>
)
}
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OLLAMA)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.SessionCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val cookie = credential.ollamaCookie
if (cookie.isNullOrBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val request = Request.Builder()
.url(SETTINGS_URL)
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Cookie", cookie)
.get()
.build()
val response = try {
okHttpClient.newCall(request).execute()
} catch (e: IOException) {
return@withContext Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
return@withContext Result.failure(RepositoryError.Unknown(e))
}
response.use { resp ->
if (!resp.isSuccessful) {
if (resp.code == 401 || resp.code == 403) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
val html = resp.body?.string() ?: return@withContext Result.failure(
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val parsed = parseUsage(html)
if (parsed == null) {
return@withContext Result.failure(
RepositoryError.ParseError(
RepositoryError.ParseErrorReason.NO_WINDOWS,
"解析失败:未找到 Ollama Cloud 用量数据。HTML=${html.length} 字节。Cookie 可能已过期。"
)
)
}
// Session 是主要用量窗口(类似 OCGO 的 rollingUsage)
val sessionPct = parsed.sessionPercent
val weeklyPct = parsed.weeklyPercent
val extras = buildMap {
put("plan", parsed.plan)
put("session.pct", sessionPct.toString())
put("weekly.pct", weeklyPct.toString())
parsed.sessionResetAt?.let { put("session.resetAt", it.toString()) }
parsed.weeklyResetAt?.let { put("weekly.resetAt", it.toString()) }
// 模型级数据序列化为分号分隔
if (parsed.sessionModels.isNotEmpty()) {
put("session.models", parsed.sessionModels.joinToString(";") { "${it.first},${it.second}" })
}
if (parsed.weeklyModels.isNotEmpty()) {
put("weekly.models", parsed.weeklyModels.joinToString(";") { "${it.first},${it.second}" })
}
}
val balance = ServiceBalance(
service = ServiceType.OLLAMA,
amount = sessionPct.toDouble(),
unit = "%",
isAvailable = true,
totalQuota = null,
nextResetAt = parsed.sessionResetAt,
extras = extras
)
balanceCache.put(ServiceType.OLLAMA, balance)
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
Result.success(balance)
}
}
/**
* 从 models.dev/api.json 获取 Ollama Cloud 可用模型列表(provider key = "ollama-cloud")。
* 该 API 不需要认证。
*/
suspend fun fetchModels(): Result<List<String>> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(MODELS_API)
.header("Accept", "application/json")
.header("User-Agent", "rainy-token/0.1")
.get().build()
val models = okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject
?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
val provider = root["ollama-cloud"] as? JsonObject
val modelsObj = provider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 Ollama Cloud 模型列表")
}
if (models.isEmpty()) {
return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
}
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
Result.success(models)
} catch (e: IOException) {
DebugLog.e(TAG, "fetchModels 网络异常: ${e.message}")
Result.failure(RepositoryError.Network(e))
} catch (e: RepositoryError) {
Result.failure(e)
} catch (e: Throwable) {
DebugLog.e(TAG, "fetchModels 异常: ${e::class.simpleName}: ${e.message}")
Result.failure(RepositoryError.Unknown(e))
}
}
/**
* 一键激活用量:用 API Key 向 Ollama Cloud chat completions API 发送简短请求。
* @param model 用户选择的模型 slug
* @return Result.success(响应摘要文本) — 供 UI 展示
*/
suspend fun triggerUsage(model: String): Result<TriggerSummary> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OLLAMA)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Ollama 凭据"))
if (credential !is Credential.SessionCredential)
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
val apiKey = credential.apiKey
if (apiKey.isNullOrBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential("未配置 API Key,请在设置中填写"))
}
DebugLog.i(TAG, "triggerUsage: model=$model")
val requestBody = """{"model":"$model","messages":[{"role":"user","content":"hello"}],"max_tokens":50}"""
.toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(CHAT_API)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer $apiKey")
.header("User-Agent", "rainy-token/0.1")
.post(requestBody).build()
try {
okHttpClient.newCall(request).execute().use { resp ->
val bodyStr = resp.body?.string() ?: ""
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
if (!resp.isSuccessful) {
return@withContext Result.failure<TriggerSummary>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
)
}
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
Result.success(parseOllamaChatResponse(bodyStr, model))
}
} catch (e: IOException) {
DebugLog.e(TAG, "triggerUsage 网络异常: ${e.message}")
Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
DebugLog.e(TAG, "triggerUsage 异常: ${e::class.simpleName}: ${e.message}")
Result.failure(RepositoryError.Unknown(e))
}
}
}
/**
* 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。
*/
internal fun parseOllamaChatResponse(responseBody: String, model: String): TriggerSummary {
val json = Json { ignoreUnknownKeys = true }
return try {
val root = json.parseToJsonElement(responseBody) as? JsonObject
val choices = root?.get("choices") as? kotlinx.serialization.json.JsonArray
val firstChoice = choices?.firstOrNull() as? JsonObject
val message = firstChoice?.get("message") as? JsonObject
val content = (message?.get("content") as? JsonPrimitive)?.contentOrNull
val usage = root?.get("usage") as? JsonObject
val promptTokens = (usage?.get("prompt_tokens") as? JsonPrimitive)?.contentOrNull
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
TriggerSummary(
model = model,
reply = content,
inputTokens = promptTokens,
outputTokens = completionTokens,
totalTokens = totalTokens
)
} catch (_: Exception) {
TriggerSummary(model = model, reply = null, parseFailed = true)
}
}

View File

@ -1,16 +1,32 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
/**
@ -33,6 +49,8 @@ class OpenCodeGoRepository(
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
@ -74,13 +92,14 @@ class OpenCodeGoRepository(
}
val html = resp.body?.string() ?: return@withContext Result.failure(
RepositoryError.ParseError("响应体为空")
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val windows = parseWindows(html)
if (windows.isEmpty()) {
return@withContext Result.failure(
RepositoryError.ParseError(
RepositoryError.ParseErrorReason.NO_WINDOWS,
"解析失败:未找到任何 OpenCode Go 配额窗口。HTML=${html.length} 字节。"
)
)
@ -91,21 +110,29 @@ class OpenCodeGoRepository(
val config = ServiceConfigProvider.get(ServiceType.OPENCODE_GO)
// 把 3 个窗口的用量百分比 + 重置时间全部塞进 extras(详情页按窗口渲染)
// usage/limit 为页面新增的用量与限额字段(单位以服务端定义为准,保留供详情页展示,UI 兼容缺失场景)
val extras = buildMap {
windows["rollingUsage"]?.let { w ->
put("rolling.pct", w.usagePercent.toString())
put("rolling.resetInSec", w.resetInSec.toString())
w.usage?.let { put("rolling.usage", it.toString()) }
w.limit?.let { put("rolling.limit", it.toString()) }
}
windows["weeklyUsage"]?.let { w ->
put("weekly.pct", w.usagePercent.toString())
put("weekly.resetInSec", w.resetInSec.toString())
w.usage?.let { put("weekly.usage", it.toString()) }
w.limit?.let { put("weekly.limit", it.toString()) }
}
windows["monthlyUsage"]?.let { w ->
put("monthly.pct", w.usagePercent.toString())
put("monthly.resetInSec", w.resetInSec.toString())
w.usage?.let { put("monthly.usage", it.toString()) }
w.limit?.let { put("monthly.limit", it.toString()) }
}
}
// 主窗口数据先落缓存并返回(关键路径只依赖 HTML 解析一次请求)
val balance = ServiceBalance(
service = ServiceType.OPENCODE_GO,
amount = primary.usagePercent.toDouble(),
@ -116,100 +143,422 @@ class OpenCodeGoRepository(
nextResetAt = System.currentTimeMillis() + primary.resetInSec * 1000L,
extras = extras
)
balanceCache.put(ServiceType.OPENCODE_GO, balance)
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
Result.success(balance)
}
}
/**
* 解析 SolidJS SSR hydration 输出。匹配模式形如:
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
*
* 用精确前缀 "field:$R[" 定位 hydration 数据中的字段声明,
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。
* 用括号计数匹配闭合 "}",正确处理嵌套对象。
*/
private fun parseWindows(html: String): Map<String, ScrapedWindow> {
val result = mutableMapOf<String, ScrapedWindow>()
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
for (field in fields) {
// 精确模式:找 "field:$R[N]={" —— 这是 hydration 数据块的独有格式
val keyIdx = html.indexOf("$field:\$R[")
if (keyIdx < 0) continue
// 找 '=' 然后找 '{'
val eqIdx = html.indexOf('=', keyIdx)
if (eqIdx < 0 || eqIdx - keyIdx > 60) continue
val braceStart = html.indexOf('{', eqIdx)
if (braceStart < 0 || braceStart - eqIdx > 10) continue
// 括号计数找正确的闭合 "}"(处理嵌套对象)
val braceEnd = findMatchingBrace(html, braceStart) ?: continue
val body = html.substring(braceStart, braceEnd + 1)
val pct = extractNumberAfterKey(body, "usagePercent")?.toIntOrNull()
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull()
if (pct != null && reset != null) {
result[field] = ScrapedWindow(pct, reset)
// 模型级用量作为增量增强:并行拉取 + 短超时,失败/超时不影响主窗口数据与缓存
val modelUsage = fetchModelWindows(workspaceId, authCookie)
if (modelUsage.isEmpty()) {
return@withContext Result.success(balance)
}
}
return result
}
/**
* 从 openIdx('{' 的位置)开始,用深度计数找匹配的闭合 '}'。
* 正确处理嵌套对象:`{status:"ok", sub:{...}, usagePercent:34}`。
*/
private fun findMatchingBrace(s: String, openIdx: Int): Int? {
var depth = 0
for (i in openIdx until s.length) {
when (s[i]) {
'{' -> depth++
'}' -> {
depth--
if (depth == 0) return i
val enriched = balance.copy(
extras = buildMap {
putAll(extras)
modelUsage.forEach { (window, usage) -> put("$window.models", json.encodeToString(usage)) }
}
}
)
balanceCache.put(ServiceType.OPENCODE_GO, enriched)
Result.success(enriched)
}
return null
}
/**
* 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。
* 跳过 status 字符串值("ok" 之类)。
* 从 models.dev/api.json 获取 OpenCode Go 可用模型列表(provider key = "opencode-go")。
* 该 API 不需要认证。
*/
private fun extractNumberAfterKey(body: String, key: String): String? {
val keyIdx = body.indexOf(key)
if (keyIdx < 0) return null
var i = keyIdx + key.length
// 跳过 ":" 后面所有非数字、非负号、非小数点字符
while (i < body.length) {
val c = body[i]
if (c.isDigit() || c == '-' || c == '.') break
i++
}
if (i >= body.length) return null
// 收集数字
val start = i
while (i < body.length) {
val c = body[i]
if (c.isDigit() || c == '.' || (c == '-' && i == start)) {
i++
} else {
break
suspend fun fetchModels(): Result<List<String>> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(MODELS_API)
.header("Accept", "application/json")
.header("User-Agent", "rainy-token/0.1")
.get().build()
val models = okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject
?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
val provider = root["opencode-go"] as? JsonObject
val modelsObj = provider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenCode Go 模型列表")
}
if (models.isEmpty()) {
return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
}
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
Result.success(models)
} catch (e: IOException) {
DebugLog.e(TAG, "fetchModels 网络异常: ${e.message}")
Result.failure(RepositoryError.Network(e))
} catch (e: RepositoryError) {
Result.failure(e)
} catch (e: Throwable) {
DebugLog.e(TAG, "fetchModels 异常: ${e::class.simpleName}: ${e.message}")
Result.failure(RepositoryError.Unknown(e))
}
return body.substring(start, i).ifEmpty { null }
}
private data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long)
/**
* 一键激活用量:用 API Key 向 OpenCode chat completions API 发送简短请求。
* @param model 用户选择的模型 slug
* @return Result.success(响应摘要文本) — 供 UI 展示
*/
suspend fun triggerUsage(model: String): Result<TriggerSummary> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 OpenCode Go 凭据"))
if (credential !is Credential.SessionCredential)
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
val apiKey = credential.apiKey
if (apiKey.isNullOrBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential("未配置 API Key,请在设置中填写"))
}
DebugLog.i(TAG, "triggerUsage: model=$model")
val requestBody = """{"model":"$model","messages":[{"role":"user","content":"hello"}],"max_tokens":50}"""
.toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(CHAT_API)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer $apiKey")
.header("User-Agent", "rainy-token/0.1")
.post(requestBody).build()
try {
okHttpClient.newCall(request).execute().use { resp ->
val bodyStr = resp.body?.string() ?: ""
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
if (!resp.isSuccessful) {
return@withContext Result.failure<TriggerSummary>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
)
}
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
Result.success(parseChatResponse(bodyStr, model))
}
} catch (e: IOException) {
DebugLog.e(TAG, "triggerUsage 网络异常: ${e.message}")
Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
DebugLog.e(TAG, "triggerUsage 异常: ${e::class.simpleName}: ${e.message}")
Result.failure(RepositoryError.Unknown(e))
}
}
companion object {
private const val TAG = "OCGO"
private const val MODELS_API = "https://models.dev/api.json"
private const val CHAT_API = "https://opencode.ai/zen/go/v1/chat/completions"
/** _server 端点:模型级用量接口(f:31 + 窗口参数,需 x-server-id / x-server-instance 头) */
private const val SERVER_ENDPOINT = "https://opencode.ai/_server"
/** 窗口模型用量的 server function id(与请求头 x-server-id 一致,取自网页端实测) */
private const val MODEL_USAGE_SERVER_ID = "ba154d05c4028a885b8c753f9def7e45d87eb982e65fa8b14254cbe636168914"
/** 模型级用量单请求超时(毫秒):只影响增强数据,避免拖慢主窗口刷新 */
private const val MODEL_REQUEST_TIMEOUT_MS = 5_000L
private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
/**
* 解析 SolidJS SSR hydration 输出。匹配模式形如:
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
*
* 用精确前缀 "field:$R[" 定位 hydration 数据中的字段声明,
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。
* 用括号计数匹配闭合 "}",正确处理嵌套对象。
*/
internal fun parseWindows(html: String): Map<String, ScrapedWindow> {
val result = mutableMapOf<String, ScrapedWindow>()
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
for (field in fields) {
// 精确模式:找 "field:$R[N]={" —— 这是 hydration 数据块的独有格式
val keyIdx = html.indexOf("$field:\$R[")
if (keyIdx < 0) continue
// 找 '=' 然后找 '{'
val eqIdx = html.indexOf('=', keyIdx)
if (eqIdx < 0 || eqIdx - keyIdx > 60) continue
val braceStart = html.indexOf('{', eqIdx)
if (braceStart < 0 || braceStart - eqIdx > 10) continue
// 括号计数找正确的闭合 "}"(处理嵌套对象)
val braceEnd = findMatchingBrace(html, braceStart) ?: continue
val body = html.substring(braceStart, braceEnd + 1)
val pct = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull()
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull()
// usage/limit 是页面新增的用量与限额字段(单位以服务端定义为准,缺失不影响窗口识别)
// exactKey=true:避免 "usage" 误命中 "usagePercent" 前缀
val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull()
val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull()
if (pct != null && reset != null) {
result[field] = ScrapedWindow(pct, reset, usage, limit)
}
}
return result
}
/**
* 从 openIdx('{' 的位置)开始,用深度计数找匹配的闭合 '}'。
* 正确处理嵌套对象:`{status:"ok", sub:{...}, usagePercent:34}`。
*/
private fun findMatchingBrace(s: String, openIdx: Int): Int? {
var depth = 0
for (i in openIdx until s.length) {
when (s[i]) {
'{' -> depth++
'}' -> {
depth--
if (depth == 0) return i
}
}
}
return null
}
/**
* 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。
* 跳过 status 字符串值("ok" 之类)。
*
* @param exactKey 为 true 时要求 key 后紧跟 ':'(精确字段匹配),
* 避免 "usage" 误命中 "usagePercent" 这类前缀字段。
*/
private fun extractNumberAfterKey(body: String, key: String, exactKey: Boolean = false): String? {
val keyIdx = if (exactKey) {
// 精确匹配:key 后必须紧跟 ':'(如 "usage:"),不能是 "usagePercent"
var idx = body.indexOf(key)
while (idx >= 0) {
val after = idx + key.length
if (after < body.length && body[after] == ':') break
idx = body.indexOf(key, idx + 1)
}
idx
} else {
body.indexOf(key)
}
if (keyIdx < 0) return null
var i = keyIdx + key.length
// 跳过 ":" 后面所有非数字、非负号、非小数点字符
while (i < body.length) {
val c = body[i]
if (c.isDigit() || c == '-' || c == '.') break
i++
}
if (i >= body.length) return null
// 收集数字
val start = i
while (i < body.length) {
val c = body[i]
if (c.isDigit() || c == '.' || (c == '-' && i == start)) {
i++
} else {
break
}
}
return body.substring(start, i).ifEmpty { null }
}
internal data class ScrapedWindow(
val usagePercent: Float,
val resetInSec: Long,
val usage: Long? = null,
val limit: Long? = null
)
/**
* 解析 _server 返回的模型级用量 flight 响应。
*
* 实际响应形如:
* ```
* ;0x000003bc;((self.$R=self.$R||{})["server-fn:2"]=[],($R=>$R[0]={
* usage:1889462439,limit:6000000000,usagePercent:31.5,
* rows:$R[1]=[$R[2]={model:"deepseek-v4-flash",name:"DeepSeek V4 Flash",
* cost:1002607535,quotaCost:2005215070,multiplier:2,
* estimated:!0,contributionPercent:30.5},...]
* })($R["server-fn:2"]))
* ```
*
* 返回窗口模型用量(usage/limit/usagePercent + 每模型行),解析失败返回 null。
* cost/quotaCost 单位为 1e-8 美元(cost × 10⁻⁸ = 美元)。
*/
internal fun parseModelRows(flight: String): WindowModelUsage? {
// 定位 "$R[0]={" 数据块(窗口模型用量的固定 hydration 格式)
val rootIdx = flight.indexOf("\$R[0]={")
if (rootIdx < 0) return null
val braceStart = rootIdx + "\$R[0]=".length
val braceEnd = findMatchingBrace(flight, braceStart) ?: return null
val body = flight.substring(braceStart, braceEnd + 1)
val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull()
val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull()
val usagePercent = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull()
return WindowModelUsage(
usage = usage,
limit = limit,
usagePercent = usagePercent,
rows = extractModelRows(body)
)
}
/** 从外层对象 body 中提取所有 {model:"..."} 模型行对象。 */
private fun extractModelRows(body: String): List<ModelUsageRow> {
val rows = mutableListOf<ModelUsageRow>()
var idx = body.indexOf("{model:")
while (idx >= 0) {
val braceEnd = findMatchingBrace(body, idx) ?: break
val obj = body.substring(idx, braceEnd + 1)
val model = extractQuoted(obj, "model") ?: ""
val name = extractQuoted(obj, "name") ?: ""
val cost = extractNumberAfterKey(obj, "cost")?.toLongOrNull() ?: 0L
val quotaCost = extractNumberAfterKey(obj, "quotaCost")?.toLongOrNull() ?: 0L
val multiplier = extractNumberAfterKey(obj, "multiplier")?.toDoubleOrNull() ?: 1.0
val estimated = obj.contains("estimated:!0")
val contributionPercent = extractNumberAfterKey(obj, "contributionPercent")?.toDoubleOrNull() ?: 0.0
rows += ModelUsageRow(
model = model,
name = name,
cost = cost,
quotaCost = quotaCost,
multiplier = multiplier,
estimated = estimated,
contributionPercent = contributionPercent
)
idx = body.indexOf("{model:", braceEnd + 1)
}
return rows
}
/** 提取 "key:" 后面紧跟的 "..." 引号字符串值。 */
private fun extractQuoted(body: String, key: String): String? {
val keyIdx = body.indexOf("$key:\"")
if (keyIdx < 0) return null
val start = keyIdx + key.length + 2
val end = body.indexOf('"', start)
if (end < 0) return null
return body.substring(start, end)
}
/**
* 窗口配额(1e-8 美元整数):窗口 limit(token 数)÷ multiplier。
* 与网页端各窗口"配额"列口径一致(实测:5h=12 亿÷2=$6、周=30 亿÷2=$15、月=60 亿÷2=$30;hy3 月=60 亿÷0.125=$480)。
* multiplier 缺失或非正时安全降级为月度基础 $60(与网页默认一致)。
*/
internal fun windowQuotaRaw(windowLimit: Long, multiplier: Double): Long {
if (multiplier <= 0.0) return 60L * 100_000_000L
return (windowLimit.toDouble() / multiplier).toLong()
}
}
/**
* 拉取三个窗口(rolling/weekly/monthly)的模型级用量。
* 并行发起 + 每个请求独立短超时(MODEL_REQUEST_TIMEOUT_MS),
* 任一窗口失败不影响其他窗口;整体失败返回空 Map(主窗口数据不受影响)。
*/
private suspend fun fetchModelWindows(workspaceId: String, authCookie: String): Map<String, WindowModelUsage> =
coroutineScope {
listOf("rolling", "weekly", "monthly").map { window ->
async {
try {
requestModelWindow(workspaceId, authCookie, window)?.let { parseModelRows(it) }
} catch (e: Exception) {
DebugLog.e(TAG, "fetchModelWindows($window) 异常: ${e.message}")
null
}
}
}.awaitAll().let { results ->
listOf("rolling", "weekly", "monthly").zip(results).mapNotNull { (window, usage) ->
usage?.let { window to it }
}.toMap()
}
}
/** 请求 _server 的窗口模型用量接口,返回 flight 文本;HTTP 失败/超时返回 null。 */
private fun requestModelWindow(workspaceId: String, authCookie: String, window: String): String? {
val args = """{"t":{"t":9,"i":0,"l":2,"a":[{"t":1,"s":"$workspaceId"},{"t":1,"s":"$window"}],"o":0},"f":31,"m":[]}"""
val url = "$SERVER_ENDPOINT?id=$MODEL_USAGE_SERVER_ID&args=${URLEncoder.encode(args, "UTF-8")}"
val request = Request.Builder()
.url(url)
.header("Accept", "*/*")
.header("Cookie", "auth=$authCookie")
.header("x-server-id", MODEL_USAGE_SERVER_ID)
.header("x-server-instance", "server-fn:2")
.header("Referer", "https://opencode.ai/workspace/$workspaceId/go")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/152.0.0.0 Safari/537.36")
.get().build()
val call = okHttpClient.newCall(request)
call.timeout().timeout(MODEL_REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
return try {
call.execute().use { resp ->
if (!resp.isSuccessful) {
DebugLog.e(TAG, "requestModelWindow($window): HTTP ${resp.code}")
null
} else {
resp.body?.string()
}
}
} catch (e: IOException) {
DebugLog.e(TAG, "requestModelWindow($window) 网络异常: ${e.message}")
null
} catch (e: Throwable) {
DebugLog.e(TAG, "requestModelWindow($window) 异常: ${e.message}")
null
}
}
}
/**
* 窗口模型级用量(_server 接口 payload)。
* cost/quotaCost 单位为 1e-8 美元;multiplier 为计费倍率(quotaCost = cost × multiplier)。
*/
@Serializable
internal data class WindowModelUsage(
val usage: Long? = null,
val limit: Long? = null,
val usagePercent: Float? = null,
val rows: List<ModelUsageRow> = emptyList()
)
/** 单模型用量行。contributionPercent 为该模型占窗口用量的百分比(如 30.5 = 30.5%)。 */
@Serializable
internal data class ModelUsageRow(
val model: String = "",
val name: String = "",
val cost: Long = 0L,
val quotaCost: Long = 0L,
val multiplier: Double = 1.0,
val estimated: Boolean = false,
val contributionPercent: Double = 0.0
)
/**
* 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。
*/
internal fun parseChatResponse(responseBody: String, model: String): TriggerSummary {
val json = Json { ignoreUnknownKeys = true }
return try {
val root = json.parseToJsonElement(responseBody) as? JsonObject
val choices = root?.get("choices") as? kotlinx.serialization.json.JsonArray
val firstChoice = choices?.firstOrNull() as? JsonObject
val message = firstChoice?.get("message") as? JsonObject
val content = (message?.get("content") as? JsonPrimitive)?.contentOrNull
val usage = root?.get("usage") as? JsonObject
val promptTokens = (usage?.get("prompt_tokens") as? JsonPrimitive)?.contentOrNull
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
TriggerSummary(
model = model,
reply = content,
inputTokens = promptTokens,
outputTokens = completionTokens,
totalTokens = totalTokens
)
} catch (_: Exception) {
TriggerSummary(model = model, reply = null, parseFailed = true)
}
}

View File

@ -88,7 +88,7 @@ class OpenCodeUsageRepository(
}
val body = resp.body?.string() ?: return@withContext Result.failure(
RepositoryError.ParseError("响应体为空")
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val records = parseUsageRecords(body)
@ -211,15 +211,16 @@ class OpenCodeUsageRepository(
else num
}
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private fun extractDate(obj: String, key: String): Long? {
// 匹配模式: key:new Date("2026-06-15T20:46:31.000Z")
val pattern = Regex("""["']?${Regex.escape(key)}["']?\s*:\s*(?:\$\w+\[?\d*\]?=\s*)?new Date\("([^"]+)"\)""")
return pattern.find(obj)?.groupValues?.get(1)?.let {
runCatching { dateFormat.parse(it)?.time }.getOrNull()
// SimpleDateFormat 非线程安全,每次创建新实例
runCatching {
java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.parse(it)?.time
}.getOrNull()
}
}
}

View File

@ -0,0 +1,81 @@
package com.rainy.token.data.repository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
/**
* 单次余额/用量请求的延迟写入会话。
*
* Repository 仍可按原有顺序调用 CredentialRepository.save() 与 BalanceCache.put(),
* 但在该上下文中写入只会暂存。请求结束后由 CredentialRepository 在同一互斥区内
* 校验凭据快照并一次性提交,避免旧请求覆盖刚保存或删除的新凭据。
*/
internal class RefreshWriteSession(
val snapshot: CredentialRepository.CredentialSnapshot
) : AbstractCoroutineContextElement(Key) {
companion object Key : CoroutineContext.Key<RefreshWriteSession>
/** 快照对应的账号 ID;单账号服务为 null。 */
val accountId: String? get() = snapshot.accountId
private val stateLock = Any()
private var pendingCredential: Credential? = null
private var pendingBalance: ServiceBalance? = null
fun stageCredential(credential: Credential) {
require(credential.service == snapshot.service) {
"刷新会话服务不匹配:expected=${snapshot.service}, actual=${credential.service}"
}
synchronized(stateLock) {
pendingCredential = mergeCredential(pendingCredential, credential)
}
}
fun stageBalance(service: ServiceType, balance: ServiceBalance) {
require(service == snapshot.service && balance.service == snapshot.service) {
"刷新缓存服务不匹配:expected=${snapshot.service}, key=$service, balance=${balance.service}"
}
synchronized(stateLock) {
pendingBalance = balance
}
}
/** 同一刷新会话内始终读取快照或已暂存的新凭据,保证重试不会退回旧 Token。 */
fun credentialForRead(): Credential = synchronized(stateLock) {
pendingCredential ?: snapshot.credential
}
fun stagedCredential(): Credential? = synchronized(stateLock) { pendingCredential }
fun stagedBalance(): ServiceBalance? = synchronized(stateLock) { pendingBalance }
private fun mergeCredential(current: Credential?, candidate: Credential): Credential {
if (current == null) return candidate
val baseFingerprint = snapshot.fingerprint
val currentChanged = CredentialRepository.credentialFingerprint(current) != baseFingerprint
val candidateChanged = CredentialRepository.credentialFingerprint(candidate) != baseFingerprint
// Codex 可能先暂存轮换后的 token,随后又用请求开始时的旧 credential
// 仅更新 lastVerifiedAt。此时必须保留已轮换的认证字段,不能退回旧 token。
val selected = when {
currentChanged && !candidateChanged -> current
!currentChanged && candidateChanged -> candidate
else -> candidate
}
return selected.withLastVerifiedAt(maxOf(current.lastVerifiedAt, candidate.lastVerifiedAt))
}
}
private fun Credential.withLastVerifiedAt(value: Long): Credential = when (this) {
is Credential.ApiKeyCredential -> copy(lastVerifiedAt = value)
is Credential.SessionCredential -> copy(lastVerifiedAt = value)
is Credential.CodexCredential -> copy(lastVerifiedAt = value)
is Credential.TraeCredential -> copy(lastVerifiedAt = value)
is Credential.WorkBuddyCredential -> copy(lastVerifiedAt = value)
is Credential.Sub2ApiCredential -> copy(lastVerifiedAt = value)
}

View File

@ -5,8 +5,15 @@ package com.rainy.token.data.repository
*/
sealed class RepositoryError(message: String, cause: Throwable? = null) : Exception(message, cause) {
/** 凭据无效(如 401 Unauthorized) */
class InvalidCredential(cause: Throwable? = null) : RepositoryError("凭据无效", cause)
/** 凭据无效(如 401 Unauthorized),detail 为具体原因 */
class InvalidCredential(detail: String? = null, cause: Throwable? = null) :
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
/** 服务端 CheckLogin 确认登录态已失效(未登录或 JWT 过期),需重新登录 */
class SessionExpired : RepositoryError("登录已过期,请重新登录")
/** 请求期间凭据被保存、替换或删除;旧结果已安全丢弃。 */
class CredentialChanged : RepositoryError("凭据已变更,本次请求结果已丢弃")
/** 限流(429 Too Many Requests) */
class RateLimited(val retryAfterSeconds: Long? = null) :
@ -19,13 +26,29 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except
class ServerError(val code: Int, cause: Throwable? = null) :
RepositoryError("服务端异常 (HTTP $code)", cause)
/** 解析失败(HTML 解析结果为空 / JSON 反序列化失败) */
class ParseError(message: String, cause: Throwable? = null) :
RepositoryError("解析失败: $message", cause)
/** 解析失败的具体原因(结构化错误码,UI 层据此映射本地化文案,detail 仅作日志/调试) */
enum class ParseErrorReason {
/** 响应体为空 */
EMPTY_BODY,
/** 响应根节点不是 JSON 对象 */
NOT_JSON_OBJECT,
/** 未找到用量窗口数据 */
NO_WINDOWS,
/** 未找到模型列表 */
NO_MODELS,
/** 模型列表为空 */
MODELS_EMPTY,
/** 响应格式异常(detail 含异常信息,仅供日志) */
MALFORMED_RESPONSE
}
/** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因(日志用,不直接透传 UI) */
class ParseError(val reason: ParseErrorReason, val detail: String, cause: Throwable? = null) :
RepositoryError("解析失败: $detail", cause)
/** 未知错误 */
class Unknown(cause: Throwable? = null) : RepositoryError(
"未知错误" + (cause?.let { ": ${it::class.simpleName}: ${it.message ?: "(no message)"}" } ?: ""),
cause
)
}
}

View File

@ -0,0 +1,39 @@
package com.rainy.token.data.repository
import kotlinx.coroutines.delay
import kotlin.math.pow
/**
* 通用重试包装。
*
* 对 [RepositoryError.Network] 和 [RepositoryError.ServerError] 执行指数退避重试。
* 401/403(InvalidCredential)、429(RateLimited)、ParseError 等不重试。
*
* @param maxRetries 最大重试次数(不含首次),默认 2
* @param baseDelayMs 退避基数,默认 1000ms(第二次 = 2000ms)
* @param block 实际请求逻辑
*/
suspend fun <T> retryOnTransientError(
maxRetries: Int = 2,
baseDelayMs: Long = 1000L,
block: suspend () -> Result<T>
): Result<T> {
var lastResult = block()
if (lastResult.isSuccess) return lastResult
repeat(maxRetries) { attempt ->
val error = lastResult.exceptionOrNull()
val shouldRetry = when (error) {
is RepositoryError.Network -> true
is RepositoryError.ServerError -> error.code in 500..599
else -> false
}
if (!shouldRetry) return lastResult
val backoffMs = baseDelayMs * (2.0.pow(attempt).toLong())
delay(backoffMs)
lastResult = block()
if (lastResult.isSuccess) return lastResult
}
return lastResult
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,738 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import java.io.IOException
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Trae(CN / Intl)余额仓库。
*
* 认证:官方 IDE/Web 用的 `Authorization: Cloud-IDE-JWT <jwt>`(RS256,~14 天)。区域只决定域名:
* - CN :POST https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage
* - INTL:POST https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage
* 2026-09 用真实登录态实测(api.trae.cn):**仅带 Authorization + Content-Type 即成功**,
* X-User-Region / X-Device-Id 均非必需(官方 Web 不带),故不再发送,避免设备绑定误伤。
*
* 真实响应(实测结构):
* ```json
* {
* "is_credits_billing": true,
* "usage_summary": {"consumed_amount":3341.17,"consumption_ratio":0.613,"total_amount":5450},
* "user_entitlement_pack_list": [ {
* "display_desc":"签到奖励",
* "entitlement_base_info":{"quota":{"credits_limit":150}, ...},
* "usage":{"credits_amount":142.18}, ...
* } ]
* }
* ```
* 余额权威口径 = `usage_summary.total_amount - consumed_amount`(>=0);usage_summary 缺失时
* 才退回包级求和(credits_limit - credits_amount,限流/无限包不计)。字段名防御式扫描。
*/
@Singleton
class TraeRepository constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
val url = when (regionKey) {
"INTL" -> "https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage"
else -> "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage"
}
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Cloud-IDE-JWT ${credential.jwt.trim()}")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody("application/json".toMediaType()))
.build()
val body = try {
okHttpClient.newCall(request).execute().use { resp ->
val text = resp.body?.string().orEmpty()
DebugLog.i(TAG, "fetchBalance: http=${resp.code} body=${text.take(300)}")
if (!resp.isSuccessful) {
return@withContext Result.failure(
if (resp.code == 401 || resp.code == 403) {
authErrorFor(credential.jwt.trim())
} else {
mapHttpError(resp.code)
}
)
}
text
}
} catch (e: IOException) {
return@withContext Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
return@withContext Result.failure(RepositoryError.Unknown(e))
}
val parsed = runCatching { parseBody(body) }
.getOrElse { e ->
return@withContext Result.failure(
RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "trae 解析失败: ${e.message}")
)
}
val balance = ServiceBalance(
service = ServiceType.TRAE,
amount = parsed.amount,
unit = "Credits",
totalQuota = parsed.totalQuota,
extras = parsed.extras
)
balanceCache.put(ServiceType.TRAE, balance, accountId)
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()), accountId)
Result.success(balance)
}
/** 解析:usage_summary 权威优先;缺失时回退权益包求和。字段缺失跳过,不抛异常。 */
private fun parseBody(body: String): ParsedTraeUsage {
val root = json.parseToJsonElement(body).jsonObject
val extras = linkedMapOf<String, String>()
root["is_credits_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_credits_billing"] = it }
root["is_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_dollar_usage_billing"] = it }
root["is_pay_freshman"]?.jsonPrimitive?.contentOrNull?.let { extras["is_new_user"] = it }
// 1) 权威汇总
val summary = root["usage_summary"]?.jsonObject
val total = summary?.numberOr("total_amount")
val consumed = summary?.numberOr("consumed_amount")
summary?.let { s ->
s.numberOr("consumed_amount")?.let { extras["consumed_credits"] = trimNum(it) }
s.numberOr("consumption_ratio")?.let { extras["consumption_ratio"] = trimNum(it) }
s.numberOr("total_amount")?.let { extras["total_credits"] = trimNum(it) }
}
// 2) 权益包明细(前若干条 + 计数),同时累计兜底求和
// 用固定前缀键 entitlement.$i.* 输出,供 TraePacksCard 拆开渲染(每包的
// 总额/已用/剩余)。旧「中文名.limit/.used」键形式存在歧义且被
// GenericExtrasCard 的过滤规则吞掉,导致权益明细从未展示(2026-10 用户反馈)。
var fallbackSum = 0.0
var generalSum = 0.0 // available_endpoint=0:通用积分(TraeCode/TraeWork 均可用)
var workSum = 0.0 // available_endpoint=1:Work 专属积分(仅 TraeWork 可用)
var packShown = 0
var packCount = 0
val packs = root["user_entitlement_pack_list"]?.jsonArray
packs?.forEachIndexed { index, packEl ->
val pack = packEl.jsonObject
val base = pack["entitlement_base_info"]?.jsonObject
val quota = base?.get("quota")?.jsonObject
val name = pack.getString("display_desc")
?: pack.getString("group_name")
?: base?.getString("name")
?: base?.get("product_extra")?.jsonObject
?.get("package_extra")?.jsonObject?.getString("package_name")
?: "pack[$index]"
val limit = quota?.getNumber("credits_limit")?.toLong()
val used = pack.getNumber("usage", "credits_amount")
?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble()
// available_endpoint 是通用 / Work 专属的唯一判别位(实测 2026-09 对照官方用量页:
// 0=通用积分,TraeCode/TraeWork 均可用;1=Work 专属,仅 TraeWork)。缺失按 0(通用)。
val isWork = (base?.get("available_endpoint")?.safeDouble() ?: 0.0) >= 1.0
val endpointKey = if (isWork) "1" else "0"
packCount++
when {
limit == null -> { /* 结构变化:跳过 */ }
limit < 0 -> {
extras["entitlement.$index.name"] = name
extras["entitlement.$index.unlimited"] = "true"
extras["entitlement.$index.endpoint"] = endpointKey
}
else -> {
val remaining = (limit - (used ?: 0.0)).coerceAtLeast(0.0)
fallbackSum += remaining
if (isWork) workSum += remaining else generalSum += remaining
if (packShown < 8) {
extras["entitlement.$index.name"] = name
extras["entitlement.$index.limit"] = limit.toString()
extras["entitlement.$index.used"] = used?.let { trimNum(it) } ?: "0"
extras["entitlement.$index.remaining"] = trimNum(remaining)
extras["entitlement.$index.endpoint"] = endpointKey
packShown++
}
}
}
}
if (packCount > 0) {
extras["pack_count"] = packCount.toString()
extras["general_credits"] = trimNum(generalSum)
extras["work_credits"] = trimNum(workSum)
}
// 3) 金额:汇总优先,缺省回退包级
val amount = if (total != null && consumed != null) {
(total - consumed).coerceAtLeast(0.0)
} else fallbackSum
val totalQuota = total?.takeIf { it >= 0 }
return ParsedTraeUsage(amount = amount, totalQuota = totalQuota, extras = extras)
}
private fun JsonObject.getString(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull
private fun JsonElement.safeDouble(): Double? =
runCatching { jsonPrimitive.content.toDoubleOrNull() }.getOrNull()
/** 按路径逐层取数:如 getNumber("usage","credits_amount") 取 this.usage.credits_amount。 */
private fun JsonObject.getNumber(vararg keys: String): Double? {
if (keys.isEmpty()) return null
var node: JsonObject? = this
for (i in keys.indices) {
val el = node?.get(keys[i]) ?: return null
if (i == keys.lastIndex) return el.safeDouble()
node = runCatching { el.jsonObject }.getOrNull()
}
return null
}
/** 平铺多候选数值扫描。 */
private fun JsonObject.numberOr(vararg keys: String): Double? {
for (key in keys) {
this[key]?.let { el ->
(el as? JsonPrimitive)?.doubleOrNull?.let { return it }
(el as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.let { return it }
}
}
return null
}
// ---------- 每日签到(checkin_credits)----------
/**
* 轻量查询「今日是否已签到」(只读,不发起 claim)。
*
* 供详情页进入时预取状态用:先查本地记录(credential.lastCheckinDate /
* [CheckinStatusStore]),未命中再调 status 接口(服务端权威,跨设备已签也能识别)。
* 返回 true=今日已签;false=未签/活动未开放;失败=网络/协议异常(调用方按未知处理)。
*/
suspend fun fetchCheckinStatus(accountId: String? = null): Result<Boolean> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
if (credential.region.uppercase() == "INTL") {
// INTL 区签到接口未实测,status 同样不可用
return@withContext Result.success(false)
}
val jwt = credential.jwt.trim()
if (credential.lastCheckinDate == java.time.LocalDate.now().toString()) {
return@withContext Result.success(true)
}
val deviceId = credential.checkinDeviceId?.trim()?.takeIf { it.isNotEmpty() }
?: ACCOUNT_BOUND_DEVICE_ID
val body = checkinRequest(jwt, deviceId, "bound", CHECKIN_STATUS_PATH)
body.fold(
onSuccess = { text ->
val info = parseStatus(text).getOrNull()
Result.success(info?.checkedIn == true)
},
onFailure = { Result.failure(it) }
)
}
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
enum class CheckinOutcome {
/** 签到成功 */
SUCCESS,
/** 今日已签到(账号级,幂等) */
ALREADY,
/** 设备级拦截(提示而非报错) */
DEVICE_BLOCKED,
/** 服务端临时限流(如「参与用户太多,请稍后重试」):提示而非报错,可稍后重试 */
RETRYABLE,
/** INTL 区签到接口未实测,暂不支持 */
INTL_UNSUPPORTED,
/** 其他失败(服务端给了原因/错误码) */
FAILED
}
/** 签到结果(来源:trae-check 实测字段与语义)。 */
data class CheckinResult(
val success: Boolean,
/** 今日已签(账号级,非设备拦截) */
val alreadyChecked: Boolean,
val points: Long,
/** 服务端原始提示文案(仅作 [outcome] 之外的补充透传) */
val message: String,
/** 动作语义结果:UI 按此本地化,不依赖 [message] 语言 */
val outcome: CheckinOutcome,
/** 服务端错误码(outcome=FAILED 时可能有;-1 表示未提供) */
val code: Long = -1L
)
/**
* 执行 Trae 每日签到(CN 区),流程对齐社区实测工具(trae-daily-checkin /
* trae-checkin-helper 逆向口径):
* 1. POST /trae/api/v2/ug/checkin_credits/status(body {})查状态:
* - enable=false → 签到功能暂未开放(中性提示,非错误)
* - checked_in=true → 今日已签(幂等短路,不重复 claim)
* 2. POST /trae/api/v2/ug/checkin_credits/claim(body {})执行签到:
* code 0/200 成功;code 10001 或文案含「已签到/already」=已签。
* 头:Cloud-IDE-JWT <jwt> + x-device-id(登录会话绑定的 App 持久化设备 ID;
* 不可用 userJwt.ClientID——固定客户端标识,所有用户相同,会被按设备风控拒绝)。
* INTL 区签到接口未实测,暂不支持(返回明确提示)。
*/
suspend fun checkIn(accountId: String? = null): Result<CheckinResult> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
if (regionKey == "INTL") {
return@withContext Result.success(
CheckinResult(
success = false,
alreadyChecked = false,
points = 0,
message = "",
outcome = CheckinOutcome.INTL_UNSUPPORTED
)
)
}
val jwt = credential.jwt.trim()
// 本地签到状态短路:今天已成功签到则不再发起任何网络请求(防重复签到/重复 claim)
val today = java.time.LocalDate.now().toString()
if (credential.lastCheckinDate == today) {
DebugLog.i(TAG, "checkin: 本地已记录今日签到,短路跳过网络请求")
return@withContext Result.success(
CheckinResult(
success = true,
alreadyChecked = true,
points = 0,
message = "",
outcome = CheckinOutcome.ALREADY
)
)
}
// 签到设备(x-device-id):必须使用账号绑定的官方客户端设备——服务端按设备
// 做签到风控/去重,App 自生成的随机设备 ID 会稳定触发 9074「参与用户太多」。
// 实测(2026-09-13):改用 PC 客户端真实 did 后 claim 成功。优先级:
// 用户设置页填写的 did(换设备后可自行更新)> 内置默认(同账号客户端 did)。
val deviceId = credential.checkinDeviceId?.trim()?.takeIf { it.isNotEmpty() }
?: ACCOUNT_BOUND_DEVICE_ID
val deviceSrc = "bound"
// Step 1:先查签到状态(enable 活动开关 + checked_in 防重复短路,
// 对齐 trae-checkin-helper 实测口径:enable/checked_in 必须是布尔才算协议正常)
val statusBody = checkinRequest(jwt, deviceId, deviceSrc, CHECKIN_STATUS_PATH)
if (statusBody.isFailure) {
return@withContext Result.failure(
statusBody.exceptionOrNull() ?: RepositoryError.Unknown()
)
}
// status 解析失败(协议异常/被风控设备无状态可读)记录日志后继续走 claim 兜底
val statusParsed = parseStatus(statusBody.getOrThrow())
if (statusParsed.isFailure) {
DebugLog.w(
TAG,
"checkin: status 解析失败,走 claim 兜底: ${statusParsed.exceptionOrNull()?.message}"
)
}
statusParsed.getOrNull()?.let { status ->
when {
status.enable == false -> {
// 签到活动未开放/间歇期:中性提示,非错误(enable=false 属正常现象)
credentialRepository.save(
credential.copy(lastVerifiedAt = System.currentTimeMillis()),
accountId
)
return@withContext Result.success(
CheckinResult(
success = false,
alreadyChecked = false,
points = 0,
message = status.message.ifBlank { "签到功能暂未开放,请稍后再试" },
outcome = CheckinOutcome.RETRYABLE
)
)
}
status.checkedIn == true -> {
// 今日已签:幂等短路,不再发起 claim;落本地签到日期防重复
credentialRepository.save(
credential.copy(
lastVerifiedAt = System.currentTimeMillis(),
lastCheckinDate = today
),
accountId
)
return@withContext Result.success(
CheckinResult(
success = true,
alreadyChecked = true,
points = status.credits?.toLong() ?: 0L,
message = "",
outcome = CheckinOutcome.ALREADY
)
)
}
}
}
// status 解析失败(协议变化等情况)继续走 claim——claim 的 code 判定仍权威
// Step 2:执行签到。claim body 按官方契约探测(对齐 cpa-multi-plugins v0.12.41/43:
// 09-04 上游收紧活动校验后,body 必须与 token 谱系匹配;官方契约空 {} 实测到账,
// SOLO/TraeWork 官方同款 req_source=2,TraeCode 用 req_source=1;9074 时顺次换 body)。
val claimBodies = listOf("{}", """{"req_source":2}""", """{"req_source":1}""")
var claimResult: Result<CheckinResult>? = null
for (body in claimBodies) {
val cl = checkinRequest(jwt, deviceId, deviceSrc, CHECKIN_CLAIM_PATH, body)
if (cl.isFailure) {
claimResult = Result.failure(
cl.exceptionOrNull() ?: RepositoryError.Unknown()
)
break
}
val parsed = parseCheckin(cl.getOrThrow())
claimResult = parsed
// 9074 已解析为 RETRYABLE;换 body 再试,其它语义(成功/已签/设备拦截/硬错误)直接定稿
if (parsed.getOrNull()?.outcome != CheckinOutcome.RETRYABLE) break
}
val parsed = claimResult
?: return@withContext Result.failure(RepositoryError.Unknown())
// 仅「凭据被服务端接受」时才算已验证:SUCCESS/ALREADY/DEVICE_BLOCKED/RETRYABLE 都说明
// JWT 有效(401/403 已在 checkinRequest 提前失败);FAILED(含 HTTP 400 业务失败)不标记,
// 避免把无效凭据误标为已验证。SUCCESS/ALREADY 同时落本地签到日期(防当日重复签到)。
val outcome = parsed.getOrNull()?.outcome
if (outcome != null && outcome != CheckinOutcome.FAILED) {
val checkedToday =
outcome == CheckinOutcome.SUCCESS || outcome == CheckinOutcome.ALREADY
credentialRepository.save(
credential.copy(
lastVerifiedAt = System.currentTimeMillis(),
lastCheckinDate = if (checkedToday) today else credential.lastCheckinDate
),
accountId
)
}
parsed
}
/**
* 签到系 POST(status/claim),请求头逐头对齐 cpa-multi-plugins v0.12.45
* 抓包指纹(2026-09-03 成功签到请求复刻,用户账号实测:旧头多日 9074,
* 新头当日 claim code=0):
* - UA 必须是 VSCode 插件进程身份 "VSCode 1.107.1 (TRAE SOLO CN)"——签到走
* VSCode 插件进程,与 IDE 主进程 Trae/0.1.x / web 均不同,混用身份 = 风控画像对不上;
* - X-User-Region / X-Request-Id / X-TT-Trace-Id / Package-Type / App-Version
* 等均随真实客户端;Authorization: Cloud-IDE-JWT <jwt>;X-Device-Id 为签到设备。
* body 固定 `{}`(官方口径,加任何业务参数都会导致业务失败)。
* 401/403 → [RepositoryError.InvalidCredential];其它非 200/400 → [mapHttpError];
* 400 或 2xx → 返回 body 交给业务解析(400 也可能是已签/设备拦截)。
*/
private fun checkinRequest(
jwt: String,
deviceId: String,
deviceSrc: String,
path: String,
bodyJson: String = "{}"
): Result<String> {
val requestId = java.util.UUID.randomUUID().toString()
val request = Request.Builder()
.url("https://api.trae.cn" + path)
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "*/*")
.addHeader("User-Agent", "VSCode 1.107.1 (TRAE SOLO CN)")
.addHeader("Authorization", "Cloud-IDE-JWT " + jwt)
.addHeader("X-User-Region", "CN")
.addHeader("Accept-Language", "zh-CN")
.addHeader("Package-Type", "stable_cn")
.addHeader("X-Lgw-Req-Sdk-Type", "3")
.addHeader("X-Market-Client-Id", "VSCode 1.107.1")
.addHeader("X-Device-Brand", "83DG")
.addHeader("X-Device-Type", "windows")
.addHeader("X-OS-Version", "Windows 11 Pro")
.addHeader("App-Version", "0.1.61")
.addHeader("X-Request-Id", requestId)
.addHeader("X-TT-Trace-Id", ttTraceId(requestId))
.addHeader("X-Device-Id", deviceId)
.addHeader("Sec-Fetch-Dest", "empty")
.addHeader("Sec-Fetch-Mode", "no-cors")
.addHeader("Sec-Fetch-Site", "none")
.post(bodyJson.toRequestBody("application/json".toMediaType()))
.build()
return try {
okHttpClient.newCall(request).execute().use { resp ->
val text = resp.body?.string().orEmpty()
// 本地调试日志:HTTP 码、路径与 deviceId 来源(bound=服务端绑定/real=持久化/fallback=伪ID)。
// 不记录 Authorization 与 deviceId 明文(敏感)。
DebugLog.i(
TAG,
"checkin: path=$path body=$bodyJson device=$deviceSrc http=${resp.code} body=${text.take(200)}"
)
when {
resp.code == 401 || resp.code == 403 ->
Result.failure(authErrorFor(jwt))
!resp.isSuccessful && resp.code != 400 ->
Result.failure(mapHttpError(resp.code))
else -> Result.success(text)
}
}
} catch (e: IOException) {
Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
Result.failure(RepositoryError.Unknown(e))
}
}
/** 生成抓包格式 tt-trace-id:00-<32hex>-<16hex>-01,16hex 取自请求 ID。 */
private fun ttTraceId(requestId: String): String {
val bytes = ByteArray(16)
java.security.SecureRandom().nextBytes(bytes)
val hex32 = bytes.joinToString("") { "%02x".format(it) }
return "00-$hex32-${requestId.replace("-", "").take(16)}-01"
}
private fun mapHttpError(code: Int): RepositoryError = when (code) {
401, 403 -> RepositoryError.InvalidCredential()
429 -> RepositoryError.RateLimited()
in 500..599 -> RepositoryError.ServerError(code)
else -> RepositoryError.Unknown()
}
/**
* 401/403 时用官方 CheckLogin 鉴别真实原因(服务端权威,非启发式猜测):
* - IsLogin=false 或 ExpiredAt 已过 → [RepositoryError.SessionExpired](提示重新登录)
* - 其它情况(含 CheckLogin 自身失败)→ [RepositoryError.InvalidCredential](保持原语义)
*/
private fun authErrorFor(jwt: String): RepositoryError {
val state = runCatching {
val request = Request.Builder()
.url("https://api.trae.cn/cloudide/api/v3/trae/CheckLogin")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "application/json")
.addHeader("User-Agent", "Trae/0.1.43")
.addHeader("X-Cloudide-Token", jwt)
.post("""{"IDEVersion":"0.1.43"}""".toRequestBody("application/json".toMediaType()))
.build()
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return@runCatching null
parseCheckLogin(resp.body?.string().orEmpty())
}
}.getOrNull()
val expired = state != null &&
(state.isLogin == false || (state.expiredAt in 1 until System.currentTimeMillis()))
return if (expired) RepositoryError.SessionExpired() else RepositoryError.InvalidCredential()
}
/** CheckLogin 结果:isLogin 为服务端权威登录态;expiredAt 为 JWT 过期时间戳(ms)。 */
internal data class CheckLoginState(val isLogin: Boolean?, val expiredAt: Long?)
/** 解析 CheckLogin 响应(Result.IsLogin / Result.ExpiredAt),协议异常返回 null。 */
internal fun parseCheckLogin(body: String): CheckLoginState? = runCatching {
val root = parseJson.parseToJsonElement(body).jsonObject
val result = root["Result"] as? JsonObject ?: return null
CheckLoginState(
isLogin = (result["IsLogin"] as? JsonPrimitive)?.booleanOrNull,
expiredAt = (result["ExpiredAt"] as? JsonPrimitive)?.longOrNull
)
}.getOrNull()
/**
* 数值转简短显示串(去掉多余的 0 与小数点)。
* 注意不能用 v.toString().trimEnd('0','.'):trimEnd 会连续剥离集合内任意字符,
* "50.0" 会被剪成 "5"、"100.0" 剪成 "1"(余额/签到金额会显示错一位)。
*/
private fun trimNum(v: Double): String =
java.math.BigDecimal.valueOf(v).stripTrailingZeros().toPlainString()
private data class ParsedTraeUsage(
val amount: Double,
val totalQuota: Double?,
val extras: Map<String, String>
)
companion object {
const val TAG = "Trae"
/** 官方客户端固定 ClientID(所有用户相同,不可作 x-device-id,会被按设备风控拒绝)。 */
const val TRAE_CLIENT_ID = "en1oxy7wnw8j9n"
/**
* 账号绑定的官方 PC 客户端设备 ID(x-device-id)。
* 实测根因(2026-09-13):签到 claim 的 9074「当前参与用户太多」是服务端设备
* 风控——App 自生成设备 ID 必被拒,改用官方客户端真实 did 后 claim 成功。
* 来源:客户端请求日志(api.trae.com.cn icube config/query did=…)。
* ⚠️ 账号级:换账号或官方端重置设备标识后需更新此值。
*/
const val ACCOUNT_BOUND_DEVICE_ID = "895482260673817"
/** 解析专用 Json(companion 内独立持有,使 parseCheckin 可静态调用/单测)。 */
private val parseJson = Json { ignoreUnknownKeys = true }
/**
* 解析 claim 响应(code/message/points 多候选)。
*
* 语义判定(实测口径):
* - code 0/200 → SUCCESS;code 1001 或文案含「已签到/already/claimed」→ ALREADY;
* - 文案含「设备/device/machine」→ DEVICE_BLOCKED(设备级拦截,优先级高于 already,避免误报已签);
* - 其余 → FAILED 并回传 code。
*
* 放在 companion 内以便单测直接调用(不依赖 OkHttp/凭据)。
*/
internal fun parseCheckin(body: String): Result<CheckinResult> = runCatching {
val root = parseJson.parseToJsonElement(body).jsonObject
// data 可能为 null/非对象(如 {"code":0}),安全转换后退回根层
val data = root["data"] as? JsonObject ?: root
val code = data.numberOr("code")?.toLong() ?: root.numberOr("code")?.toLong() ?: -1L
val msg = data.scalarOr("message", "msg") ?: root.scalarOr("message", "msg") ?: ""
// points 与 credit 双候选(社区实测:claim 成功响应可能用 credit 字段)
val points = (data.numberOr("points", "credit")
?: root.numberOr("points", "credit"))?.toLong() ?: 0L
val succeeded = code == 0L || code == 200L
val lowerMsg = msg.lowercase()
val deviceBlock = msg.contains("设备") || lowerMsg.contains("device") || lowerMsg.contains("machine")
// 权威口径幂等兜底码是 10001(trae-checkin-helper 逆向);历史实现用 1001,
// 两者都认,避免把已签误判为 FAILED 硬错误。
val already = (code == 1001L || code == 10001L) || (!deviceBlock && (
msg.contains("已签到") || msg.contains("已经签到") || msg.contains("明日再来") ||
msg.contains("今日已完成") || msg.contains("已领取") ||
lowerMsg.contains("already") || lowerMsg.contains("checked") || lowerMsg.contains("claimed")
))
// 服务端临时限流/排队(账号、设备或活动人数限制):提示可稍后重试,不算硬错误。
val retryable = !already && !deviceBlock && (
msg.contains("参与") || msg.contains("稍后重试") || msg.contains("火爆") ||
msg.contains("繁忙") || msg.contains("限流") || msg.contains("排队") ||
lowerMsg.contains("too many") || lowerMsg.contains("retry") || lowerMsg.contains("busy") ||
lowerMsg.contains("popular") || lowerMsg.contains("later")
)
DebugLog.i(
TAG,
"checkIn 解析: code=$code msg=$msg points=$points -> " +
when {
succeeded -> "SUCCESS"
already -> "ALREADY"
deviceBlock -> "DEVICE_BLOCKED"
retryable -> "RETRYABLE"
else -> "FAILED"
}
)
when {
succeeded -> Result.success(
CheckinResult(
success = true,
alreadyChecked = false,
points = points.takeIf { it > 0 } ?: 200L,
message = msg,
outcome = CheckinOutcome.SUCCESS
)
)
already -> Result.success(
CheckinResult(true, true, 0L, msg, CheckinOutcome.ALREADY)
)
deviceBlock -> Result.success(
CheckinResult(false, false, 0L, msg, CheckinOutcome.DEVICE_BLOCKED)
)
retryable -> Result.success(
CheckinResult(false, false, 0L, msg, CheckinOutcome.RETRYABLE)
)
else -> Result.success(
CheckinResult(false, false, 0L, msg, CheckinOutcome.FAILED, code)
)
}
}.getOrElse { Result.failure(RepositoryError.Unknown(it)) }
/** 官方签到接口路径(trae-checkin-helper 逆向口径)。 */
const val CHECKIN_STATUS_PATH = "/trae/api/v2/ug/checkin_credits/status"
const val CHECKIN_CLAIM_PATH = "/trae/api/v2/ug/checkin_credits/claim"
/** status 接口解析结果(字段缺失为 null;enable/checked_in 以布尔为权威)。 */
internal data class TraeStatusInfo(
val enable: Boolean?,
val checkedIn: Boolean?,
val credits: Double?,
val message: String
)
/**
* 解析 status 响应(对齐 trae-checkin-helper 实测口径):
* 响应为平铺结构(code/enable/checked_in/credits 同层),个别版本包一层 data,兼容两种;
* 业务码非 0/200 或 enable/checked_in 非布尔 → 解析失败(调用方继续走 claim 兜底)。
*/
internal fun parseStatus(body: String): Result<TraeStatusInfo> = runCatching {
val root = parseJson.parseToJsonElement(body).jsonObject
val data = root["data"] as? JsonObject ?: root
val code = data.numberOr("code")?.toLong()
?: root.numberOr("code")?.toLong()
if (code != null && code != 0L && code != 200L) {
error("status 业务失败 code=$code")
}
val enable = data.boolOr("enable") ?: root.boolOr("enable")
val checkedIn = data.boolOr("checked_in") ?: root.boolOr("checked_in")
if (enable == null || checkedIn == null) {
error("status 响应缺 enable/checked_in(协议可能已变更)")
}
TraeStatusInfo(
enable = enable,
checkedIn = checkedIn,
credits = data.numberOr("credits") ?: root.numberOr("credits"),
message = data.scalarOr("message", "msg")
?: root.scalarOr("message", "msg") ?: ""
)
}
/** 布尔字段解析(兼容原生 JSON boolean 与 "true"/"false" 字符串,非布尔返回 null)。 */
private fun JsonObject.boolOr(key: String): Boolean? {
val prim = this[key] as? JsonPrimitive ?: return null
return when (prim.content.lowercase()) {
"true" -> true
"false" -> false
else -> null
}
}
private fun JsonObject.scalarOr(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
/** 平铺多候选数值扫描。 */
private fun JsonObject.numberOr(vararg keys: String): Double? {
for (key in keys) {
this[key]?.let { el ->
(el as? JsonPrimitive)?.doubleOrNull?.let { return it }
(el as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.let { return it }
}
}
return null
}
}
}

View File

@ -0,0 +1,112 @@
package com.rainy.token.data.repository
import android.content.Context
import com.rainy.token.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* 应用更新仓库:从 Gitea 仓库的 update.json 读取版本清单,
* 下载 APK 到应用缓存目录供 FileProvider 安装。
*/
@Singleton
class UpdateRepository @Inject constructor(
private val okHttpClient: OkHttpClient,
@ApplicationContext context: Context
) {
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
/** 检查是否有新版本。null = 已是最新;非空 = 远端有更新。 */
suspend fun checkForUpdate(): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url(UPDATE_MANIFEST_URL)
.get()
.build()
val text = okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
return@withContext Result.failure(IOException("HTTP " + resp.code))
}
resp.body?.string().orEmpty()
}
val manifest = json.decodeFromString<UpdateManifest>(text)
val hasUpdate = manifest.versionCode > BuildConfig.VERSION_CODE
Result.success(if (hasUpdate) {
UpdateInfo(
versionCode = manifest.versionCode,
versionName = manifest.versionName,
apkUrl = manifest.apkUrl,
notes = manifest.notes
)
} else {
null
})
} catch (e: Exception) {
Result.failure(e)
}
}
/** 下载 APK 到缓存目录。onProgress 回调下载字节数/总字节数(total 可能为 -1)。 */
suspend fun downloadApk(
info: UpdateInfo,
onProgress: (downloaded: Long, total: Long) -> Unit
): Result<File> = withContext(Dispatchers.IO) {
try {
val dir = File(appContext.cacheDir, "apk_downloads").apply { mkdirs() }
val file = File(dir, "rainytoken-" + info.versionName + ".apk")
val request = Request.Builder().url(info.apkUrl).get().build()
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
return@withContext Result.failure(IOException("HTTP " + resp.code))
}
val body = resp.body ?: return@withContext Result.failure(IOException("empty body"))
val total = body.contentLength()
file.outputStream().buffered().use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var downloaded = 0L
var read: Int
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
downloaded += read
onProgress(downloaded, total)
}
}
}
}
Result.success(file)
} catch (e: Exception) {
Result.failure(e)
}
}
@Serializable
data class UpdateManifest(
val versionCode: Int,
val versionName: String,
val apkUrl: String,
val notes: String = ""
)
data class UpdateInfo(
val versionCode: Int,
val versionName: String,
val apkUrl: String,
val notes: String
)
companion object {
const val UPDATE_MANIFEST_URL =
"http://124.222.97.212:8443/maya/Rainytoken/raw/branch/dev/update.json"
}
}

View File

@ -1,6 +1,7 @@
package com.rainy.token.data.repository
import android.webkit.CookieManager
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import com.rainy.token.domain.model.CookieEntry
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
@ -21,10 +22,16 @@ class WebViewSessionSaver @Inject constructor(
private val credentialRepository: CredentialRepository
) {
/** Sub2API 面板登录保存结果:凭据 + 实际写入的账号 ID(新建账号时返回新 ID)。 */
data class SavedSub2PanelSession(
val credential: Credential.Sub2ApiCredential,
val accountId: String
)
/**
* 提取并保存 Cookie 列表。返回保存后的凭据。
*/
fun saveFromCookieManager(
suspend fun saveFromCookieManager(
service: ServiceType,
url: String,
token: String? = null,
@ -41,13 +48,97 @@ class WebViewSessionSaver @Inject constructor(
expiresAt = expiresAt,
lastVerifiedAt = System.currentTimeMillis()
)
// 用 runBlocking 写凭据 —— 调用方在 Composable 中,可以接受
kotlinx.coroutines.runBlocking {
credentialRepository.save(session)
}
// 挂起写凭据:调用方在 viewModelScope 中,避免在主线程做 Keystore 加解密 + DataStore 写盘
credentialRepository.save(session)
return session
}
/**
* 面板登录成功后自动写入 Sub2API 凭据。
*
* 面板把登录态放在 localStorage.auth_token(JWT),所以 WebView 登录完成后
* 注入 JS 读取该键,连同站点基址一起落库,用户无需再手工粘贴。
*
* @param baseUrl 面板基址(登录页所在 origin,自动补 https 前缀)
* @param authToken localStorage.auth_token 的值;为空表示没抓到,返回 null 不覆盖旧凭据
* @param siteIcon 站点 Logo(可选,登录页 favicon/og:image)
* @param email 登录邮箱(可选,仅用于展示与回退登录)
* @param accountId 目标账号(多账号服务;null=默认账号/新建账号)
*/
suspend fun saveSub2ApiPanelSession(
baseUrl: String,
authToken: String,
siteIcon: String? = null,
email: String? = null,
accountId: String? = null
): SavedSub2PanelSession? {
val token = authToken.trim()
if (token.isEmpty()) return null
val base = normalizeBaseUrl(baseUrl) ?: return null
val existing = credentialRepository.get(ServiceType.SUB2API, accountId) as? Credential.Sub2ApiCredential
val credential = Credential.Sub2ApiCredential(
service = ServiceType.SUB2API,
baseUrl = base,
email = email?.takeIf { it.isNotBlank() } ?: existing?.email.orEmpty(),
password = existing?.password.orEmpty(),
apiKey = existing?.apiKey,
authToken = token,
siteIcon = siteIcon?.takeIf { it.isNotBlank() } ?: existing?.siteIcon,
lastVerifiedAt = System.currentTimeMillis()
)
// 返回实际写入的账号 ID:新建账号 / 默认账号统一回填,避免 UI 停留在「新建」空表单
val effectiveId = credentialRepository.save(credential, accountId)
return SavedSub2PanelSession(credential = credential, accountId = effectiveId)
}
/**
* OpenCode Go 应用内登录成功后自动落库。
*
* WebView 里完成 GitHub/Google OAuth 后落到 dashboard,登录态表现为
* `auth` cookie + URL 中的 workspaceId——正是 [OpenCodeGoRepository] 需要的
* 两个凭据字段,用户不再需要手动开 DevTools 抄 cookie。
*
* @param authCookie dashboard 的 `auth` cookie 值(CookieManager 抓取)
* @param workspaceId dashboard URL `/workspace/{id}/go` 中的 id
*/
suspend fun saveOpenCodeGoSession(
authCookie: String,
workspaceId: String
): Credential.SessionCredential? {
val auth = authCookie.trim()
val ws = workspaceId.trim()
if (auth.isEmpty() || ws.isEmpty()) return null
// 与手动保存一致:基于既有凭据合并,保留 cookies/token/apiKey 等字段不丢
val existing = credentialRepository.get(ServiceType.OPENCODE_GO) as? Credential.SessionCredential
val session = (existing ?: Credential.SessionCredential(service = ServiceType.OPENCODE_GO)).copy(
authCookie = auth,
workspaceId = ws,
lastVerifiedAt = System.currentTimeMillis()
)
credentialRepository.save(session)
return session
}
/**
* 归一化面板基址:补 scheme、去尾斜杠,只保留 scheme://host[:port]。
*
* 用 [okhttp3.HttpUrl] 真正解析而不是字符串长度判断——否则 "not a url" 这类垃圾
* 会被拼成 "https://not a url" 通过校验并落库,后续拼请求 URL 时 OkHttp 直接抛异常。
*/
private fun normalizeBaseUrl(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return null
val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"https://" + trimmed
}
val parsed = withScheme.toHttpUrlOrNull() ?: return null
// 只保留 origin,丢掉 path/query(面板基址就是 origin)
return parsed.newBuilder().encodedPath("/").query(null).fragment(null).build()
.toString().trimEnd('/')
}
/**
* 把 `name1=value1; name2=value2` 解析为 [CookieEntry] 列表。
* 完整属性(domain/path/expires/secure/httpOnly)暂不可用,因为 Android CookieManager

View File

@ -0,0 +1,745 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import java.io.IOException
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* 腾讯 WorkBuddy 余额仓库。
*
* 认证:`Authorization: Bearer <accessToken>`;accessToken 过期时用 refreshToken 轮换一次并重试
* (同 Codex 语义):POST https://copilot.tencent.com/v2/plugin/auth/token/refresh(X-Refresh-Token 头)。
*
* 资源余额(2026-09 用真实登录态在官网个人中心实测,workbuddy.cn):
* - 主端点:POST https://www.workbuddy.cn/billing/meter/get-user-resource-summary body `{}`
* 真实响应:{"code":0,"data":{"Packages":[{"PackageCode":"TCACA_...","CycleTotalCapacity":"7826",
* "CycleRemainCapacity":"2671.96","CycleUsedCapacity":"5154.04","CycleFrozenCapacity":"0",
* "CapacityUnit":"credits"}, ...], "IsPaidUser":false}}
* - 兜底:POST https://www.codebuddy.cn/v2/billing/meter/get-user-resource(官方 CLI 插件模型,
* ProductCode p_tcaca / Status 0,3),汇总 Packages/Accounts[] 同族字段。
* 余额 = 各包 CycleRemainCapacity 之和;单位取 CapacityUnit(credits)。
* 字段名随官方迭代可能变化,解析保持防御式(多候选扫描)。
*/
@Singleton
class WorkBuddyRepository constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
private val jsonMedia = "application/json".toMediaType()
private val codebuddyBody = buildJsonObject {
put("PageNumber", 1)
put("PageSize", 100)
put("ProductCode", "p_tcaca")
put("Status", buildJsonArray { add(JsonPrimitive(0)); add(JsonPrimitive(3)) })
put("Channel", "P")
put("Unsubmitted", false)
put("Unit", "")
put("QueryResourceId", buildJsonArray { })
}.toString()
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
var access = credential.accessToken
var refresh = credential.refreshToken
// hostMode 0 = workbuddy.cn summary(实测主路径);1 = codebuddy.cn 兜底
var hostMode = 0
var attempts = 0
var lastBody = ""
while (attempts < 3) {
attempts++
val request = if (hostMode == 0) summaryRequest(access) else codebuddyRequest(access)
val resp = try {
okHttpClient.newCall(request).execute()
} catch (e: IOException) {
return@withContext Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
return@withContext Result.failure(RepositoryError.Unknown(e))
}
val code = resp.code
val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("")
resp.close()
if (code in 200..299) {
lastBody = body
break
}
when (code) {
404 -> {
if (hostMode == 0) { hostMode = 1; continue }
return@withContext Result.failure(mapHttpError(code, body))
}
401, 403 -> {
val refreshed = tryRefesh(refresh) ?: return@withContext Result.failure(
RepositoryError.InvalidCredential()
)
// 暂存轮换后的凭据,随本次成功提交(RefreshWriteSession 机制)
credentialRepository.save(
credential.copy(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
expiresAt = if (refreshed.expiresIn > 0) {
System.currentTimeMillis() + refreshed.expiresIn * 1000
} else {
credential.expiresAt.coerceAtLeast(System.currentTimeMillis())
}
),
accountId
)
access = refreshed.accessToken
refresh = refreshed.refreshToken
continue
}
else -> return@withContext Result.failure(mapHttpError(code, body))
}
}
if (lastBody.isBlank()) {
return@withContext Result.failure(
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "workbuddy 响应为空")
)
}
val parsed = runCatching { parseResource(lastBody) }.getOrElse { e ->
return@withContext Result.failure(
RepositoryError.ParseError(
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE,
"workbuddy 资源解析失败: ${e.message}"
)
)
}
// 细粒度账户明细(官网个人中心同源数据):每个资源包的已用/总量/剩余/到期时间。
// 独立请求,失败仅降级(保留 summary 的简化 extras),不影响余额主流程。
val detailExtras = runCatching { fetchResourceDetailExtras(access) }.getOrNull()
val mergedExtras = if (detailExtras != null) {
parsed.extras + detailExtras
} else {
parsed.extras
}
val balance = ServiceBalance(
service = ServiceType.WORKBUDDY,
amount = parsed.amount,
unit = parsed.unit ?: "Credits",
monthlySpent = parsed.used.takeIf { it > 0 },
totalQuota = parsed.size.takeIf { it > 0 },
isAvailable = parsed.amount > 0 || parsed.size > 0,
extras = mergedExtras
)
balanceCache.put(ServiceType.WORKBUDDY, balance, accountId)
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()), accountId)
Result.success(balance)
}
/**
* 拉取细粒度账户明细并转成 extras 增量([KEY_DETAIL_RAW] / [KEY_PLAN_CODE])。
* 请求失败或解析失败返回 null(调用方降级到 summary 的简化 extras)。
*/
private fun fetchResourceDetailExtras(accessToken: String): Map<String, String>? {
val request = Request.Builder()
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
.post(codebuddyBody.toRequestBody(jsonMedia))
.build()
val body = try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return null
resp.body?.string().orEmpty()
}
} catch (_: Throwable) {
return null
}
if (body.isBlank()) return null
return parseResourceDetail(body)
}
/** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */
private fun summaryRequest(accessToken: String): Request =
Request.Builder()
.url("https://www.workbuddy.cn/billing/meter/get-user-resource-summary")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody(jsonMedia))
.build()
/** codebuddy.cn 官方 CLI 插件模型(桌面/CLI accessToken 兜底)。 */
private fun codebuddyRequest(accessToken: String): Request =
Request.Builder()
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Content-Type", "application/json")
.post(codebuddyBody.toRequestBody(jsonMedia))
.build()
/**
* refresh_token 单次轮换:POST token/refresh(X-Refresh-Token 头)。
* 只读取并返回新凭据;轮换后的提交交给主流程的 save(同一成功才落库)。
*/
private fun tryRefesh(refreshToken: String): RefreshedWorkBuddy? {
val request = Request.Builder()
.url("https://copilot.tencent.com/v2/plugin/auth/token/refresh")
.addHeader("X-Refresh-Token", refreshToken)
.post("{}".toRequestBody(jsonMedia))
.build()
return try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return@use null
val text = resp.body?.string().orEmpty()
val obj = json.parseToJsonElement(text).jsonObject
val access = obj.readToken("access_token", "accessToken")
?: obj["data"]?.jsonObject?.readToken("access_token", "accessToken")
?: return@use null
val refresh = obj.readToken("refresh_token", "refreshToken")
?: obj["data"]?.jsonObject?.readToken("refresh_token", "refreshToken")
?: refreshToken
val expiresIn = obj["expires_in"]?.jsonPrimitive?.longOrNull ?: 0L
RefreshedWorkBuddy(access, refresh, expiresIn)
}
} catch (_: Throwable) {
null
}
}
private fun JsonObject.readToken(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
/**
* 解析 codebuddy.cn `/v2/billing/meter/get-user-resource` 的细粒度账户明细。
*
* 响应:`data.Response.Data.Accounts[]`,每个账户是一个独立资源包,字段含
* PackageCode / PackageName / CycleCapacityRemainPrecise / CycleCapacityUsedPrecise /
* CycleCapacitySizePrecise / CycleStartTime / CycleEndTime / Status / ExpiredTime。
*
* 按 PackageCode 分组:n==1 且非运营裂变包 → 套餐积分;其余(n>1 的裂变包)→ 平台奖励积分。
* 返回 extras 增量(键见 [KEY_DETAIL_RAW])+ 是否解析成功。失败时调用方保留 summary 的 extras。
*/
private fun parseResourceDetail(body: String): Map<String, String>? {
val root = runCatching { json.parseToJsonElement(body).jsonObject }.getOrNull() ?: return null
val data = root["data"]?.jsonObject?.let { d ->
(d["Response"] as? JsonObject)?.let { r -> (r["Data"] as? JsonObject) ?: d } ?: d
} ?: root
val accounts = data["Accounts"]?.jsonArray ?: return null
val list = accounts.mapNotNull { it.jsonObject.takeIf { o -> o.isNotEmpty() } }
if (list.isEmpty()) return null
val out = linkedMapOf<String, String>()
val detail = buildJsonArray {
for (a in list) {
val code = a.scalar("PackageCode") ?: continue
val name = a.scalar("PackageName") ?: code
val remain = a.scalar("CycleCapacityRemainPrecise")
?: a.number("CycleCapacityRemain", "CapacityRemainPrecise", "CapacityRemain")?.let { trimNum(it) }
?: continue
val used = a.scalar("CycleCapacityUsedPrecise")
?: a.number("CycleCapacityUsed", "CapacityUsedPrecise", "CapacityUsed")?.let { trimNum(it) }
?: "0"
val total = a.scalar("CycleCapacitySizePrecise")
?: a.number("CycleCapacitySize", "CapacitySizePrecise", "CapacitySize")?.let { trimNum(it) }
?: "0"
val cycleEnd = a.scalar("CycleEndTime")?.takeIf { it.isNotBlank() }
val expiredAt = a.scalar("ExpiredTime")?.takeIf { it.isNotBlank() }
val status = a.number("Status")?.toInt()
add(buildJsonObject {
put("code", JsonPrimitive(code))
put("name", JsonPrimitive(name))
put("remain", JsonPrimitive(remain))
put("used", JsonPrimitive(used))
put("total", JsonPrimitive(total))
put("cycleEnd", JsonPrimitive(cycleEnd ?: ""))
put("expiredAt", JsonPrimitive(expiredAt ?: ""))
put("status", JsonPrimitive(status ?: -1))
})
}
}
if (detail.isEmpty()) return null
out[KEY_DETAIL_RAW] = detail.toString()
// 分组:每个 PackageCode 的账户数 + 包名
val groups = list.groupBy { it.scalar("PackageCode").orEmpty() }
.filterKeys { it.isNotBlank() }
// 套餐候选:账户数最少且不是「裂变/奖励」字样的包;奖励池:账户数多或名字带裂变/奖励
val planCode = groups.entries
.filter { (_, v) -> v.size == 1 }
.filter { (k, _) -> list.firstOrNull { it.scalar("PackageCode") == k }
?.scalar("PackageName")?.let { n -> !n.contains("裂变") && !n.contains("奖励") } == true }
.minByOrNull { it.key }?.key
if (planCode != null) out[KEY_PLAN_CODE] = planCode
return out
}
/** 解析 Packages/Accounts:CycleRemain/Used/TotalCapacity(多候选)。无任何包/数据时抛异常。 */
private fun parseResource(body: String): ParsedWorkBuddy {
val root = json.parseToJsonElement(body).jsonObject
val data = root["data"]?.jsonObject ?: root
val packages = data["Packages"]?.jsonArray ?: data["Accounts"]?.jsonArray ?: root["Packages"]?.jsonArray
val list = packages?.mapNotNull { it.jsonObject.takeIf { o -> o.isNotEmpty() } } ?: emptyList()
if (list.isEmpty()) {
throw IllegalArgumentException("workbuddy 响应无 Packages/Accounts 数据")
}
var amount = 0.0
var used = 0.0
var size = 0.0
var frozen = 0.0
var unitRaw: String? = null
val extras = linkedMapOf<String, String>()
// 结构化包明细(名称/编码/已用/总量/剩余/到期/周期重置),详情页按官方语义分组展示。
// 键名带 raw 以复用通用卡的「技术键过滤」,不被用量页/通用明细卡当普通键展示。
val detail = buildJsonArray {
for (item in list.take(20)) {
val remain = item.number("CycleRemainCapacity", "CycleCapacityRemain", "Remain", "balance") ?: 0.0
val u = item.number("CycleUsedCapacity", "CycleCapacityUsed", "Used") ?: 0.0
val s = item.number("CycleTotalCapacity", "CycleCapacitySize", "Size", "Total") ?: 0.0
val f = item.number("CycleFrozenCapacity", "Frozen") ?: 0.0
amount += remain
used += u
size += s
frozen += f
unitRaw = unitRaw ?: item.scalar("CapacityUnit", "unit", "currency", "Unit")
val name = item.scalar("ProductName", "PackageCode", "ResourceName", "name", "ResourceId")
if (!name.isNullOrBlank() && extras.size < 3) {
extras[name] = "${trimNum(remain)} ${normalizeUnit(unitRaw) ?: "Credits"}"
}
add(buildJsonObject {
put("name", JsonPrimitive(name ?: ""))
put("code", JsonPrimitive(item.scalar("PackageCode", "ResourceCode", "Code") ?: ""))
put("remain", JsonPrimitive(trimNum(remain)))
put("used", JsonPrimitive(trimNum(u)))
put("total", JsonPrimitive(trimNum(s)))
put(
"expireAt",
JsonPrimitive(
item.timeField("ExpireTime", "ExpireAt", "ExpiredAt", "InvalidTime", "ExpireDate") ?: ""
)
)
put(
"cycleReset",
JsonPrimitive(
item.timeField("NextResetTime", "CycleResetTime", "NextCycleTime", "ResetTime", "ResetAt") ?: ""
)
)
})
}
}
if (list.size > 0) extras["packages"] = list.size.toString()
if (frozen > 0) extras["frozen"] = trimNum(frozen)
extras["wb_detail_raw"] = detail.toString()
data["IsPaidUser"]?.jsonPrimitive?.contentOrNull?.let { extras["is_paid_user"] = it }
data["SubscriptionPackageCode"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
?.let {
extras["subscription"] = "true"
// 订阅套餐的包编码:详情页据此把该包归入「套餐积分」区,其余归「平台奖励积分」
extras["subscription_code"] = it
}
return ParsedWorkBuddy(
amount = amount,
used = used,
size = size,
unit = normalizeUnit(unitRaw),
extras = extras
)
}
/** 官方字段 credits → 展示单位 Credits;其它原样透传。 */
private fun normalizeUnit(raw: String?): String? {
if (raw.isNullOrBlank()) return null
return if (raw.equals("credits", ignoreCase = true)) "Credits" else raw
}
/**
* 数值转简短显示串(去掉多余的 0 与小数点)。
* 注意不能用 v.toString().trimEnd('0','.'):trimEnd 会连续剥离集合内任意字符,
* "50.0" 会被剪成 "5"、"100.0" 剪成 "1"(余额/签到金额会显示错一位)。
*/
private fun trimNum(v: Double): String =
java.math.BigDecimal.valueOf(v).stripTrailingZeros().toPlainString()
private fun JsonObject.number(vararg keys: String): Double? {
for (key in keys) {
this[key]?.jsonPrimitive?.doubleOrNull?.let { return it }
this[key]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()?.let { return it }
}
return null
}
private fun JsonObject.scalar(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
/**
* 时间字段读取:兼容字符串日期(原样返回)与秒/毫秒时间戳(格式化为本地 yyyy-MM-dd HH:mm:ss)。
* 找不到或解析失败返回 null(展示层隐藏该行)。
*/
private fun JsonObject.timeField(vararg keys: String): String? {
for (key in keys) {
val prim = this[key]?.jsonPrimitive ?: continue
val text = prim.contentOrNull?.takeIf { it.isNotBlank() } ?: continue
prim.doubleOrNull?.takeIf { it > 0 }?.let { epoch ->
val millis = if (epoch > 1e12) epoch.toLong() else (epoch * 1000).toLong()
return runCatching {
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
.format(java.util.Date(millis))
}.getOrNull()
}
// 纯字符串日期(如 "2026-10-01 16:32:41")直接返回
return text
}
return null
}
// ---------- 每日签到(Buddy 加油站 / codebuddy.cn)----------
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
enum class CheckinOutcome {
/** 查询模式:只读状态,无动作结果 */
QUERY,
/** 签到成功 */
SUCCESS,
/** 今日已签到(幂等) */
ALREADY,
/** 签到失败(服务端给了原因,见 message) */
FAILED
}
/**
* 签到状态摘要(来源:cpa-multi-plugins workbuddy 插件实测字段)。
* active=活动进行中;todayCheckedIn=今日已签;streakDays=连签;daily/todayCredit=每日/今日积分。
*/
data class CheckinStatus(
val active: Boolean,
val todayCheckedIn: Boolean,
val streakDays: Long,
val dailyCredit: Long,
val todayCredit: Long,
val activityName: String,
/** 服务端原始提示文案(可能是中文/英文;仅作 [outcome] 之外的补充透传) */
val message: String = "",
/** 动作语义结果:UI 按此本地化,不依赖 [message] 语言 */
val outcome: CheckinOutcome = CheckinOutcome.QUERY
)
/** 查询今日签到状态。uid 未知时省略 X-User-Id 头(源码确认该头可选)。 */
suspend fun fetchCheckinStatus(accountId: String? = null): Result<CheckinStatus> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
// 两条候选路径 fallback(cpa-plugin 实测:activity-status 优先,旧路径 checkin-status 兜底)
// 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。
val body = authedPostFirstSuccessful(
credential,
accountId,
"/v2/billing/meter/checkin-activity-status",
"/v2/billing/meter/checkin-status"
) ?: return@withContext Result.failure(RepositoryError.Unknown())
runCatching { parseCheckin(body) }.fold(
onSuccess = { Result.success(it) },
onFailure = {
Result.failure(
RepositoryError.ParseError(
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE,
"workbuddy checkin status 解析失败: " + it.message
)
)
}
)
}
/**
* 执行今日签到(幂等:code=0 成功 / code=10001 已签,两种都算成功;源码确认 HTTP 400 也可能是已签)。
* 成功/已签后刷新一次状态并附带 message 提示。
*/
suspend fun checkIn(accountId: String? = null): Result<CheckinStatus> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
is AuthedPostResult.Ok -> {
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
r.body
}
is AuthedPostResult.Failed -> {
// 即便签到请求失败,也必须先落库轮换出的新 token,否则账号卡在死 token 上;
// 但未发生轮换(纯网络失败)时不写入——避免把失败请求误标为「刚验证通过」。
if (r.rotatedCredential != null) {
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
}
// HTTP 400 也可能是业务失败且 body 携带签到语义(如已签),透传交给下方解析。
if (r.body.isBlank()) {
return@withContext Result.failure(RepositoryError.Unknown())
}
r.body
}
}
// 签到响应为官方 envelope:{"code":0,"msg":"...","data":{...}},code/msg 在根层,
// data 为可选明细。必须根层优先 + data 层兜底——否则 data 存在时根层 code 被漏读
// 成 -1,导致「实际签到成功却显示失败」(2026-10 实测回归)。
// 整体包 runCatching 兜底非法 JSON/"data":null 等边界,避免强转异常冒泡。
val code = runCatching {
val r = json.parseToJsonElement(body).jsonObject
r.scalar("code", "Code")?.toLongOrNull()
?: (r["data"] as? JsonObject)?.scalar("code", "Code")?.toLongOrNull()
}.getOrNull() ?: -1L
val msg = runCatching {
val r = json.parseToJsonElement(body).jsonObject
r.scalar("msg", "message", "Message")
?: (r["data"] as? JsonObject)?.scalar("msg", "message", "Message")
}.getOrNull().orEmpty()
val status = fetchCheckinStatus(accountId).getOrNull()
// 已签到语义与上游 cpa-multi-plugins 对齐:code=10001 或文案含「已签/今日/already」。
val lowerMsg = msg.lowercase()
val already = code == 10001L || msg.contains("已签") || msg.contains("已经签到") ||
msg.contains("今日") || lowerMsg.contains("already") || lowerMsg.contains("checked") ||
lowerMsg.contains("claimed")
val statusInfo = if (status != null) " checked=${status.todayCheckedIn}/streak=${status.streakDays}" else ""
DebugLog.i(
TAG,
"checkIn 判定: code=$code msg=$msg already=$already statusOk=${status != null}$statusInfo"
)
when {
code == 0L -> Result.success(
(status ?: CheckinStatus(true, true, 1, 0, 0, "")).copy(
outcome = CheckinOutcome.SUCCESS
)
)
already -> Result.success(
(status ?: CheckinStatus(true, true, 0, 0, 0, "")).copy(
outcome = CheckinOutcome.ALREADY
)
)
else -> {
if (status != null) {
Result.success(status.copy(message = msg, outcome = CheckinOutcome.FAILED))
} else {
Result.failure(RepositoryError.Unknown())
}
}
}
}
/**
* [authedPost] 的结果。
*
* [rotatedCredential] 一旦非空,表示服务端已作废旧 accessToken(新的 access/refresh
* 已轮换),调用方**必须**先落库再决定后续动作——即使本次请求最终失败,也不能丢弃它,
* 否则旧 refreshToken 单次消费后失效,账号会卡在死 token 上(永久 401)。
*/
private sealed interface AuthedPostResult {
val rotatedCredential: Credential.WorkBuddyCredential?
/** 请求成功(2xx),[body] 为响应体。 */
data class Ok(
val body: String,
override val rotatedCredential: Credential.WorkBuddyCredential?
) : AuthedPostResult
/** 请求失败,但过程中发生过 token 轮换(新凭据必须落库)。[body] 携带业务失败响应体
* (如 HTTP 400 也返回签到语义),可能为空。 */
data class Failed(
override val rotatedCredential: Credential.WorkBuddyCredential?,
val body: String = ""
) : AuthedPostResult
}
/**
* 带自动 refresh 一次的 POST(Bearer)。
* 失败时若已发生轮换,会以 [AuthedPostResult.Failed] 回传新凭据(不丢轮换结果)。
*/
private fun authedPost(credential: Credential.WorkBuddyCredential, path: String): AuthedPostResult {
var access = credential.accessToken
var refresh = credential.refreshToken
var rotated: Credential.WorkBuddyCredential? = null
repeat(2) { attempt ->
val request = Request.Builder()
.url("https://www.codebuddy.cn" + path)
.addHeader("Authorization", "Bearer " + access)
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody(jsonMedia))
.build()
val resp = try {
okHttpClient.newCall(request).execute()
} catch (e: Throwable) {
DebugLog.w(TAG, "authedPost 异常: path=$path err=${e::class.simpleName}: ${e.message}")
return AuthedPostResult.Failed(rotated)
}
val code = resp.code
val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("")
resp.close()
// 本地调试日志:记录请求路径/HTTP 码/响应摘要,不记录 Authorization(敏感)。
DebugLog.i(TAG, "authedPost: path=$path attempt=$attempt http=$code body=${body.take(200)}")
when {
code in 200..299 -> return AuthedPostResult.Ok(body, rotated)
(code == 401 || code == 403) && attempt == 0 -> {
val refreshed = tryRefesh(refresh) ?: return AuthedPostResult.Failed(rotated)
val expiresAt = if (refreshed.expiresIn > 0) {
System.currentTimeMillis() + refreshed.expiresIn * 1000
} else {
credential.expiresAt.coerceAtLeast(System.currentTimeMillis())
}
rotated = credential.copy(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
expiresAt = expiresAt
)
access = refreshed.accessToken
refresh = refreshed.refreshToken
}
else -> return AuthedPostResult.Failed(rotated, body)
}
}
return AuthedPostResult.Failed(rotated)
}
/**
* 候选路径 fallback 专用:依次尝试各路径,但**同一次查询内共享轮换状态**——
* 若前一条路径已轮换 token,后续路径必须用新 accessToken 请求,并把轮换结果
* 落库后再返回,避免旧 token 重试直接失败。
*/
private suspend fun authedPostFirstSuccessful(
credential: Credential.WorkBuddyCredential,
accountId: String?,
vararg paths: String
): String? {
var current = credential
for (path in paths) {
when (val r = authedPost(current, path)) {
is AuthedPostResult.Ok -> {
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
return r.body
}
is AuthedPostResult.Failed -> {
// 即便本路径失败,也要先把轮换出的新 token 落库并继续用新 token 试下一条
if (r.rotatedCredential != null) {
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
current = r.rotatedCredential
}
}
}
}
return null
}
/**
* 落库本次签到/状态查询产出:轮换后的 token(若有)+ lastVerifiedAt。
* 与 [fetchBalance] 同款语义——一次成功请求即视为凭据已验证。
*/
private suspend fun persistCheckinOutcome(
credential: Credential.WorkBuddyCredential,
accountId: String?,
rotated: Credential.WorkBuddyCredential?
) {
val base = rotated ?: credential
credentialRepository.save(base.copy(lastVerifiedAt = System.currentTimeMillis()), accountId)
}
private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) {
401, 403 -> RepositoryError.InvalidCredential()
429 -> RepositoryError.RateLimited()
423 -> RepositoryError.ServerError(code)
in 500..599 -> RepositoryError.ServerError(code)
else -> RepositoryError.Unknown()
}
private data class RefreshedWorkBuddy(
val accessToken: String,
val refreshToken: String,
val expiresIn: Long
)
private data class ParsedWorkBuddy(
val amount: Double,
val used: Double,
val size: Double,
val unit: String?,
val extras: Map<String, String>
)
companion object {
const val TAG = "WorkBuddy"
/** extras 键:细粒度账户明细 JSON(结构见 [parseResourceDetail]),详情页消费。 */
const val KEY_DETAIL_RAW = "wb_detail_raw"
/** extras 键:套餐积分的 PackageCode(详情页据此区分套餐 vs 奖励积分)。 */
const val KEY_PLAN_CODE = "wb_plan_code"
/** 解析专用 Json(companion 内独立持有,使 parseCheckin 可静态调用/单测)。 */
private val parseJson = Json { ignoreUnknownKeys = true }
/**
* 解析 checkin 响应(data 平铺或 data 包裹,防御式多候选)。
* 放在 companion 内以便单测直接调用(不依赖 OkHttp/凭据)。
*/
internal fun parseCheckin(body: String): CheckinStatus {
val root = parseJson.parseToJsonElement(body).jsonObject
val obj = root["data"]?.jsonObject ?: root
return CheckinStatus(
active = obj.scalar("active", "Active")?.toBoolean() ?: true,
todayCheckedIn = obj.scalar("today_checked_in", "todayCheckedIn")?.toBoolean() ?: false,
streakDays = obj.longOr("streak_days", "streakDays") ?: 0,
dailyCredit = obj.longOr("daily_credit", "dailyCredit") ?: 0,
todayCredit = obj.longOr("today_credit", "todayCredit") ?: 0,
activityName = obj.scalar("activity_name", "activityName", "theme_name") ?: ""
)
}
private fun JsonObject.scalar(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
private fun JsonObject.longOr(vararg keys: String): Long? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.toLongOrNull()?.let { return it }
}
return null
}
}
}

View File

@ -6,11 +6,21 @@ import com.rainy.token.data.cache.balanceCacheDataStore
import com.rainy.token.data.remote.DeepSeekApi
import com.rainy.token.data.local.SecureStorage
import com.rainy.token.data.local.UsageCache
import com.rainy.token.data.local.UsageDao
import com.rainy.token.data.local.UsageDatabase
import com.rainy.token.data.local.usageCacheDataStore
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.DeepSeekRepository
import com.rainy.token.data.repository.CodexRepository
import com.rainy.token.data.repository.CommandCodeGoRepository
import com.rainy.token.data.repository.CommandCodeUsageRepository
import com.rainy.token.data.repository.OpenCodeGoRepository
import com.rainy.token.data.repository.OpenCodeUsageRepository
import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.UpdateRepository
import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -74,12 +84,26 @@ object NetworkModule {
// ---- Room ----
@Provides
@Singleton
fun provideUsageDatabase(
@ApplicationContext context: Context
): UsageDatabase = UsageDatabase.getInstance(context)
@Provides
@Singleton
fun provideUsageDao(
database: UsageDatabase
): UsageDao = database.usageDao()
@Provides
@Singleton
fun provideUsageCache(
@ApplicationContext context: Context,
dao: UsageDao,
@Named(DataStoreQualifiers.USAGE_CACHE) dataStore: DataStore<Preferences>,
json: Json
): UsageCache = UsageCache(dataStore, json)
): UsageCache = UsageCache(context, dao, dataStore, json)
// ---- Repositories ----
@ -101,6 +125,27 @@ object NetworkModule {
balanceCache: BalanceCache
): OpenCodeGoRepository = OpenCodeGoRepository(okHttpClient, credentialRepository, balanceCache)
/**
* CommandCode 用量仓库。
*/
@Provides
@Singleton
fun provideCommandCodeUsageRepository(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): CommandCodeUsageRepository = CommandCodeUsageRepository(okHttpClient, credentialRepository)
/**
* CommandCode 仓库:API Key 认证,调 JSON API。
*/
@Provides
@Singleton
fun provideCommandCodeGoRepository(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
balanceCache: BalanceCache
): CommandCodeGoRepository = CommandCodeGoRepository(okHttpClient, credentialRepository, balanceCache)
/**
* DeepSeek 仓库:同上。注意即使 [DeepSeekRepository] 自己有 @Inject constructor,
* 在 3+ Repository 同模式时 KSP 2.x 仍会误报,这里统一改 @Provides 杜绝隐患。
@ -113,6 +158,71 @@ object NetworkModule {
balanceCache: BalanceCache
): DeepSeekRepository = DeepSeekRepository(deepSeekApi, credentialRepository, balanceCache)
/**
* Codex / ChatGPT 额度仓库:用 OkHttp 调 chatgpt.com 后台 API。
*/
@Provides
@Singleton
fun provideCodexRepository(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
balanceCache: BalanceCache
): CodexRepository = CodexRepository(okHttpClient, credentialRepository, balanceCache)
/**
* Ollama Cloud 用量仓库:Cookie 认证 + OkHttp 抓 settings HTML 解析。
*/
@Provides
@Singleton
fun provideOllamaRepository(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
balanceCache: BalanceCache
): OllamaRepository = OllamaRepository(okHttpClient, credentialRepository, balanceCache)
/**
* Trae 仓库:Cloud-IDE-JWT 认证 + REST。
*/
@Provides
@Singleton
fun provideTraeRepository(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
balanceCache: BalanceCache
): TraeRepository = TraeRepository(okHttpClient, credentialRepository, balanceCache)
/**
* WorkBuddy 仓库:OAuth Bearer + get-user-resource。
*/
@Provides
@Singleton
fun provideWorkBuddyRepository(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
balanceCache: BalanceCache
): WorkBuddyRepository = WorkBuddyRepository(okHttpClient, credentialRepository, balanceCache)
/**
* Sub2API 仓库:自托管实例(凭据里的 baseUrl)+ 管理面板 API。
*/
/**
* 更新仓库:读取 Gitea 上的 update.json 并下载 APK。
*/
@Provides
@Singleton
fun provideUpdateRepository(
okHttpClient: OkHttpClient,
@ApplicationContext context: Context
): UpdateRepository = UpdateRepository(okHttpClient, context)
@Provides
@Singleton
fun provideSub2ApiRepository(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
balanceCache: BalanceCache
): Sub2ApiRepository = Sub2ApiRepository(okHttpClient, credentialRepository, balanceCache)
/** 余额缓存 DataStore(计划 7.1) */
@Provides
@Singleton

View File

@ -3,6 +3,8 @@ package com.rainy.token.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.rainy.token.data.local.ChartSettingsStore
import com.rainy.token.data.local.chartSettingsStore
import com.rainy.token.data.local.secureStorageDataStore
import com.rainy.token.data.local.usageCacheDataStore
import dagger.Module
@ -48,4 +50,10 @@ object StorageModule {
fun provideUsageCacheDataStore(
@ApplicationContext context: Context
): DataStore<Preferences> = context.usageCacheDataStore
@Provides
@Singleton
fun provideChartSettingsStore(
@ApplicationContext context: Context
): ChartSettingsStore = context.chartSettingsStore
}

View File

@ -8,6 +8,7 @@ import kotlinx.serialization.Serializable
*
* - [ApiKeyCredential] 用于 DeepSeek(API Key 形式)
* - [SessionCredential] 用于 OpenCode Zen/Go(用户登录 dashboard 后粘贴的值)
* - [CodexCredential] 用于 Codex / ChatGPT(完整 OAuth 凭据,含自动刷新)
*/
@Serializable
sealed class Credential {
@ -40,6 +41,102 @@ sealed class Credential {
val authCookie: String? = null,
/** OpenCode Go 专用:workspace ID */
val workspaceId: String? = null,
/** Ollama 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */
val ollamaCookie: String? = null,
/** OpenCode Go / Ollama 专用:API Key,用于一键激活用量 */
val apiKey: String? = null,
override val lastVerifiedAt: Long = 0L
) : Credential()
/**
* 用于 Codex / ChatGPT:完整 OAuth 凭据,支持自动刷新。
* 用户从 auth.json 粘贴整个 tokens 对象。
*/
@Serializable
data class CodexCredential(
override val service: ServiceType,
val accessToken: String,
val refreshToken: String,
val accountId: String,
/** 过期时间(epoch millis) */
val expiresAt: Long,
override val lastVerifiedAt: Long = 0L
) : Credential()
/**
* 用于 Trae(CN/Intl):官方 IDE 扩展使用的 Cloud-IDE-JWT。
* 单个 JWT(~14 天 RS256),配 [region] 决定域名与请求头。
*
* [deviceId] 为本 App 持久化生成的每机唯一设备 ID(与登录授权 URL 中
* device_id 一致,即 OAuth 会话绑定的设备)。Trae claim 签到接口按 x-device-id
* 做设备级每日去重/风控,必须用与登录会话一致的设备 ID;不可用 userJwt.ClientID
* (那是固定客户端标识,所有用户相同,会被服务端按设备风控拒绝)。
* [userId] 为 OAuth 用户 ID(回调 userInfo),签到按 trae-check 口径拼
* `${deviceId}-${userId}`。旧凭据无这些字段时退回由 JWT 派生的稳定伪 ID。
*/
@Serializable
data class TraeCredential(
override val service: ServiceType,
/** `Authorization: Cloud-IDE-JWT <jwt>` 的 JWT 值 */
val jwt: String,
/** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */
val region: String = "CN",
/** 登录会话绑定的设备 ID(App 持久化每机唯一),null=旧凭据未保存 */
val deviceId: String? = null,
/** OAuth 用户 ID(回调 userInfo.userId),null=旧凭据未保存 */
val userId: String? = null,
/** 最近一次成功签到日期(yyyy-MM-dd,本地时区);当天命中则本地短路,不重复发起签到请求 */
val lastCheckinDate: String? = null,
/**
* 用户在设置页手动填写的签到设备 ID(官方客户端绑定的 did)。
* 服务端按设备风控签到(9074 根因),官方客户端真实 did 才能通过;
* null/空白 = 使用内置默认(ACCOUNT_BOUND_DEVICE_ID)。
* 获取路径见设置页提示(客户端日志 main.log 搜索 did=)。
*/
val checkinDeviceId: String? = null,
override val lastVerifiedAt: Long = 0L
) : Credential()
/**
* 用于腾讯 WorkBuddy(AI 办公 Agent,积分与 CodeBuddy 同体系)。
* 复用 Codex 的 OAuth Bearer 模式:accessToken + refreshToken(单次轮换)。
*/
@Serializable
data class WorkBuddyCredential(
override val service: ServiceType,
val accessToken: String,
val refreshToken: String,
/** 过期时间(epoch millis),0=未知 */
val expiresAt: Long = 0L,
override val lastVerifiedAt: Long = 0L
) : Credential()
/**
* 用于 Sub2API(自建 mid 层/聚合面板,任意用户填自己的实例地址)。
* 走【用户级】查询:普通用户填实例分配给他的 sk- API Key 即可查余额,无需管理员。
* 实测(xxcsn.site):GET {base}/v1/usage + Authorization: Bearer <apiKey>
* 返回 remaining/balance/unit/usage.total.cost/model_stats 等。
* 无 API Key 时可用面板邮箱+密码登录换取 Bearer token 作为备选。
*/
@Serializable
data class Sub2ApiCredential(
override val service: ServiceType,
/** 自托管实例基址,如 https://api.example.com(自动去尾斜杠) */
val baseUrl: String,
/** 面板登录邮箱(无 API Key 的备选认证) */
val email: String,
/** 面板登录密码(无 API Key 的备选认证) */
val password: String,
/** 用户级 API Key(sk-…),存在时优先于 email/password 认证 */
val apiKey: String? = null,
/**
* 面板登录 Token(App 内 WebView 登录后自动从 localStorage.auth_token 提取)。
* 存在时走【面板模式】:全账号 usage/stats + dashboard/models + snapshot-v2
* (数据与网页「使用记录」页一致,actual_cost 口径)。
*/
val authToken: String? = null,
/** 站点 Logo URL(登录/探测时抓取;主页卡片显示,替换内置图标)。 */
val siteIcon: String? = null,
override val lastVerifiedAt: Long = 0L
) : Credential()
}

View File

@ -0,0 +1,32 @@
package com.rainy.token.domain.model
import com.rainy.token.domain.service.ServiceType
import kotlinx.serialization.Serializable
/**
* 单个服务的账号元信息(多账号服务专用,如 Trae / WorkBuddy)。
*
* 凭据本身按 [id] 独立加密存储,这里只保存展示与排序所需的元数据:
* - [id]:稳定账号标识(迁移出的首个账号固定为 "default";新建账号为随机 ID)
* - [label]:用户可见名称;null/空白时 UI 按“账号 N”兜底显示
* - [isDefault]:是否为默认账号(小组件 / 每日自动签到仅作用于默认账号)
*/
@Serializable
data class ServiceAccountInfo(
val id: String,
val label: String? = null,
val isDefault: Boolean = false,
val createdAt: Long = 0L
)
/**
* 单个服务的账号元数据集合,经 [com.rainy.token.data.local.SecureStorage] 加密持久化,
* 存储键为 `accounts_{service.storageKey}`。
*
* 单账号服务不使用本结构,仍沿用旧的单凭据存储键。
*/
@Serializable
data class AccountStore(
val service: ServiceType,
val accounts: List<ServiceAccountInfo> = emptyList()
)

View File

@ -22,5 +22,7 @@ data class ServiceBalance(
/** 配额周期下次重置时间(订阅型服务,epoch millis) */
val nextResetAt: Long? = null,
/** 服务特定的附加信息(如 Zen 充值 $20 起,Go 5h 配额) */
val extras: Map<String, String> = emptyMap()
val extras: Map<String, String> = emptyMap(),
/** Sub2API 专有:网关 /v1/usage 返回的结构化用量明细(模型统计 + 每日明细)。其他服务为 null。 */
val sub2UsageDetail: Sub2UsageDetail? = null
)

View File

@ -0,0 +1,39 @@
package com.rainy.token.domain.model
import kotlinx.serialization.Serializable
/**
* Sub2API 网关 /v1/usage 返回的结构化用量明细。
*
* 不同网关版本字段名/是否返回都不同,因此全部字段防御式解析:
* - [modelStats]:按模型聚合的统计(原来只取前 3 个塞进 extras 字符串,现全量结构化)
* - [dailyUsage]:按日聚合的明细(date 形如 "2026-09-04",时区由请求的 timezone 参数决定)
*/
@Serializable
data class Sub2UsageDetail(
val modelStats: List<Sub2ModelStat> = emptyList(),
val dailyUsage: List<Sub2DailyUsage> = emptyList()
) {
val isEmpty: Boolean get() = modelStats.isEmpty() && dailyUsage.isEmpty()
val isNotEmpty: Boolean get() = !isEmpty
}
/** 按模型聚合的用量统计。字段名来自官方前端 /v1/usage model_stats[]。 */
@Serializable
data class Sub2ModelStat(
val model: String,
val requests: Long = 0,
val inputTokens: Long = 0,
val outputTokens: Long = 0,
val totalTokens: Long = 0,
val cost: Double = 0.0
)
/** 按日聚合的用量明细。 */
@Serializable
data class Sub2DailyUsage(
val date: String,
val requests: Long = 0,
val totalTokens: Long = 0,
val cost: Double = 0.0
)

View File

@ -0,0 +1,24 @@
package com.rainy.token.domain.model
/**
* 一键激活用量的结构化响应摘要。
*
* Repository 只负责从响应中提取数据,不拼接展示文案;
* UI 层按当前语言环境用 stringResource 组装本地化文本。
*/
data class TriggerSummary(
/** 发起请求时使用的模型 */
val model: String,
/** 模型回复文本;null 表示响应中不存在回复内容 */
val reply: String?,
/** 输入 token 数(OCGO/Ollama 为 prompt_tokens,Codex 为 input_tokens) */
val inputTokens: String? = null,
/** 输出 token 数(OCGO/Ollama 为 completion_tokens,Codex 为 output_tokens) */
val outputTokens: String? = null,
/** 总 token 数(OCGO/Ollama 的 total_tokens;Codex 无此字段) */
val totalTokens: String? = null,
/** Codex 响应 ID */
val responseId: String? = null,
/** 响应解析失败,但请求已成功发出 */
val parseFailed: Boolean = false
)

View File

@ -10,6 +10,9 @@ enum class FetchMethod {
/** WebView 登录后抓取 HTML/内部 JSON API */
WEBVIEW_SCRAPER,
/** 用户自托管实例的 REST API(地址在凭据里,如 Sub2API) */
SELF_HOSTED_REST,
/** 完全手动输入(已降级) */
MANUAL
}
@ -24,6 +27,8 @@ data class ServiceConfig(
val loginUrl: String,
/** 余额/配额的展示单位(如 "¥" / "$" / "Credits" / "requests") */
val displayUnit: String,
/** 外部操作页 URL(如 DeepSeek 充值页),App 内 WebView 打开,可空 */
val externalUrl: String? = null,
/** 触发降级的连续失败次数阈值(计划 5.4 / 6.3) */
val degradationFailureThreshold: Int = 3,
/** Cookie 有效期 < 该值则触发降级(小时) */
@ -40,7 +45,9 @@ object ServiceConfigProvider {
type = ServiceType.DEEPSEEK,
method = FetchMethod.REST_API,
loginUrl = "", // REST 模式不需要登录 URL
displayUnit = "¥"
displayUnit = "¥",
// App 内 WebView 打开的官方充值页
externalUrl = "https://platform.deepseek.com/top_up"
),
ServiceType.OPENCODE_GO to ServiceConfig(
type = ServiceType.OPENCODE_GO,
@ -48,6 +55,47 @@ object ServiceConfigProvider {
method = FetchMethod.WEBVIEW_SCRAPER,
loginUrl = "https://opencode.ai/auth",
displayUnit = "%"
),
ServiceType.COMMANDCODE_GO to ServiceConfig(
type = ServiceType.COMMANDCODE_GO,
method = FetchMethod.REST_API,
loginUrl = "", // API Key 模式不需要登录 URL
displayUnit = "$"
),
ServiceType.CODEX to ServiceConfig(
type = ServiceType.CODEX,
method = FetchMethod.REST_API,
loginUrl = "",
displayUnit = "%"
),
ServiceType.OLLAMA to ServiceConfig(
type = ServiceType.OLLAMA,
method = FetchMethod.WEBVIEW_SCRAPER,
loginUrl = "https://ollama.com/settings",
displayUnit = "%"
),
ServiceType.TRAE to ServiceConfig(
type = ServiceType.TRAE,
method = FetchMethod.REST_API,
loginUrl = "",
displayUnit = "Credits",
// 官网定价页(docs.trae.cn 订阅管理:购买会员套餐/增购积分入口)
externalUrl = "https://www.trae.cn/pricing"
),
ServiceType.WORKBUDDY to ServiceConfig(
type = ServiceType.WORKBUDDY,
method = FetchMethod.REST_API,
loginUrl = "",
displayUnit = "Credits",
// 官网订阅定价页
externalUrl = "https://www.workbuddy.cn/pricing/"
),
ServiceType.SUB2API to ServiceConfig(
type = ServiceType.SUB2API,
// 地址在用户填写的凭据里,按自托管实例调用
method = FetchMethod.SELF_HOSTED_REST,
loginUrl = "",
displayUnit = "$"
)
)

View File

@ -15,10 +15,27 @@ enum class ServiceType(
val storageKey: String
) {
OPENCODE_GO("OpenCode Go", "opencode_go"),
DEEPSEEK("DeepSeek", "deepseek");
COMMANDCODE_GO("CommandCode", "commandcode_go"),
DEEPSEEK("DeepSeek", "deepseek"),
CODEX("Codex / ChatGPT", "codex"),
OLLAMA("Ollama", "ollama"),
TRAE("Trae", "trae"),
WORKBUDDY("WorkBuddy", "workbuddy"),
SUB2API("Sub2API", "sub2api");
companion object {
fun fromStorageKey(key: String): ServiceType? =
entries.firstOrNull { it.storageKey == key }
/** 支持多账号的服务(Trae / WorkBuddy / Sub2API 已启用,见需求范围)。 */
val MULTI_ACCOUNT_SERVICES: Set<ServiceType> = setOf(
ServiceType.TRAE,
ServiceType.WORKBUDDY,
ServiceType.SUB2API
)
/** 该服务是否启用多账号。 */
fun isMultiAccount(service: ServiceType): Boolean =
service in MULTI_ACCOUNT_SERVICES
}
}

View File

@ -1,27 +1,225 @@
package com.rainy.token.domain.usecase
import com.rainy.token.data.repository.CodexRepository
import com.rainy.token.data.repository.CommandCodeGoRepository
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.CheckinStatusStore
import com.rainy.token.data.repository.DeepSeekRepository
import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.OpenCodeGoRepository
import com.rainy.token.data.repository.RefreshWriteSession
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.WorkBuddyRepository
import com.rainy.token.data.repository.retryOnTransientError
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceType
import javax.inject.Inject
import javax.inject.Provider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
/**
* 唯一的 UseCase。计划架构补充说明:
* - 内部按 ServiceType 分发到对应 Repository
* - 现阶段实现:DeepSeek(REST API) + OpenCode Go(OkHttp 抓 dashboard)
* - **使用 Provider 注入**——规避 KSP 2.x 在多个 @Inject constructor Repository
* 注入同一 UseCase 时的"could not be resolved"误报(KSP 已知 issue)。
* Provider 让 Hilt 推迟创建 Repository 实例到第一次 .get() 时,KSP 不需要在
* 编译期解析所有构造器签名。
* 唯一的余额刷新 UseCase。
*
* Repository 中的凭据/缓存写入先暂存;请求结束后校验起始凭据快照仍为当前版本才提交,
* 避免旧请求覆盖用户刚保存或删除的新凭据。
*/
class RefreshBalanceUseCase @Inject constructor(
private val credentialRepository: CredentialRepository,
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
private val deepSeekRepositoryProvider: Provider<DeepSeekRepository>,
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
private val codexRepositoryProvider: Provider<CodexRepository>,
private val ollamaRepositoryProvider: Provider<OllamaRepository>,
private val traeRepositoryProvider: Provider<TraeRepository>,
private val workBuddyRepositoryProvider: Provider<WorkBuddyRepository>,
private val sub2ApiRepositoryProvider: Provider<Sub2ApiRepository>
) {
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> = when (service) {
ServiceType.DEEPSEEK -> deepSeekRepositoryProvider.get().fetchBalance()
ServiceType.OPENCODE_GO -> openCodeGoRepositoryProvider.get().fetchBalance()
suspend operator fun invoke(
service: ServiceType,
accountId: String? = null
): Result<ServiceBalance> =
withCredentialSession(service, accountId) {
when (service) {
ServiceType.DEEPSEEK -> retryOnTransientError {
deepSeekRepositoryProvider.get().fetchBalance()
}
ServiceType.OPENCODE_GO -> retryOnTransientError {
openCodeGoRepositoryProvider.get().fetchBalance()
}
ServiceType.COMMANDCODE_GO -> retryOnTransientError {
commandCodeGoRepositoryProvider.get().fetchBalance()
}
ServiceType.CODEX -> retryOnTransientError {
codexRepositoryProvider.get().fetchBalance()
}
ServiceType.OLLAMA -> retryOnTransientError {
ollamaRepositoryProvider.get().fetchBalance()
}
ServiceType.TRAE -> retryOnTransientError {
traeRepositoryProvider.get().fetchBalance(accountId)
}
ServiceType.WORKBUDDY -> retryOnTransientError {
workBuddyRepositoryProvider.get().fetchBalance(accountId)
}
ServiceType.SUB2API -> retryOnTransientError {
sub2ApiRepositoryProvider.get().fetchBalance(accountId)
}
}
}
suspend fun fetchCodexModels(): Result<List<String>> =
codexRepositoryProvider.get().fetchModels()
suspend fun triggerCodexUsage(model: String): Result<TriggerSummary> =
withCredentialSession(ServiceType.CODEX) {
codexRepositoryProvider.get().triggerUsage(model)
}
suspend fun fetchOpenCodeGoModels(): Result<List<String>> =
openCodeGoRepositoryProvider.get().fetchModels()
suspend fun triggerOpenCodeGoUsage(model: String): Result<TriggerSummary> =
withCredentialSession(ServiceType.OPENCODE_GO) {
openCodeGoRepositoryProvider.get().triggerUsage(model)
}
suspend fun fetchOllamaModels(): Result<List<String>> =
ollamaRepositoryProvider.get().fetchModels()
suspend fun triggerOllamaUsage(model: String): Result<TriggerSummary> =
withCredentialSession(ServiceType.OLLAMA) {
ollamaRepositoryProvider.get().triggerUsage(model)
}
/**
* 执行每日签到(TRAE / WORKBUDDY),返回各自的语义结果类型。
*
* 必须走 [withCredentialSession]:签到会触发 OAuth 轮换(WorkBuddy 401/403 时刷新
* access/refresh),轮换结果需按凭据快照校验后提交,避免并发改凭据被旧快照覆盖;
* 同时会话内重试才能读到已轮换的新 token(否则旧 refreshToken 单次消费后直接失效)。
*/
suspend fun checkInWorkBuddy(accountId: String? = null): Result<WorkBuddyRepository.CheckinStatus> =
withCredentialSession(ServiceType.WORKBUDDY, accountId) {
workBuddyRepositoryProvider.get().checkIn(accountId).also { result ->
result.getOrNull()?.let { s ->
val isChecked = s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS ||
s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY ||
s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY
if (isChecked) {
checkinStatusStore.record(
service = ServiceType.WORKBUDDY,
accountId = accountId,
status = if (s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY) {
CheckinStatusStore.STATUS_ALREADY
} else {
s.outcome.name
},
checkinId = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) {
java.util.UUID.randomUUID().toString()
} else {
null
},
credits = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) {
s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit
} else {
0
}
)
}
}
}
}
suspend fun checkInTrae(accountId: String? = null): Result<TraeRepository.CheckinResult> =
withCredentialSession(ServiceType.TRAE, accountId) {
traeRepositoryProvider.get().checkIn(accountId).also { result ->
result.getOrNull()?.let { r ->
if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS ||
r.outcome == TraeRepository.CheckinOutcome.ALREADY
) {
checkinStatusStore.record(
service = ServiceType.TRAE,
accountId = accountId,
status = r.outcome.name,
checkinId = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) {
java.util.UUID.randomUUID().toString()
} else {
null
},
credits = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) r.points else 0
)
}
}
}
}
/** 查询今日签到状态(仅 WORKBUDDY 有该接口)。走会话以保证状态查询的凭据写入不覆盖并发编辑。 */
suspend fun fetchCheckinStatus(accountId: String? = null): Result<WorkBuddyRepository.CheckinStatus> =
withCredentialSession(ServiceType.WORKBUDDY, accountId) {
workBuddyRepositoryProvider.get().fetchCheckinStatus(accountId)
}
/** TRAE 今日签到状态(只读 status 接口,供详情页进入时预取「已签到」展示)。 */
suspend fun fetchTraeCheckinStatus(accountId: String? = null): Result<Boolean> =
withCredentialSession(ServiceType.TRAE, accountId) {
traeRepositoryProvider.get().fetchCheckinStatus(accountId)
}
private suspend fun <T> withCredentialSession(
service: ServiceType,
accountId: String? = null,
block: suspend () -> Result<T>
): Result<T> {
val snapshot = credentialRepository.snapshot(service, accountId)
?: return Result.failure(RepositoryError.InvalidCredential())
val session = RefreshWriteSession(snapshot)
val result = try {
withContext(session) { block() }
} catch (cancelled: CancellationException) {
// Codex refresh_token 是单次轮换的:服务端可能已经作废旧 token,而新 token
// 已暂存在 session。即使 Widget 超时取消,也必须先在不可取消区提交凭据;
// 余额不提交,因为原业务请求没有正常完成。
commitIgnoringCancellation(session, includeBalance = false)
throw cancelled
} catch (error: Throwable) {
Result.failure(RepositoryError.Unknown(error))
}
val committed = try {
withContext(NonCancellable) {
credentialRepository.commit(
session = session,
includeBalance = result.isSuccess
)
}
} catch (error: Throwable) {
return Result.failure(RepositoryError.Unknown(error))
}
return if (committed) {
result
} else {
Result.failure(RepositoryError.CredentialChanged())
}
}
}
private suspend fun commitIgnoringCancellation(
session: RefreshWriteSession,
includeBalance: Boolean
) {
withContext(NonCancellable) {
try {
credentialRepository.commit(session, includeBalance)
} catch (_: Throwable) {
// 保持原始 CancellationException 语义;提交失败不会伪装成业务成功。
}
}
}
}

View File

@ -0,0 +1,94 @@
package com.rainy.token.domain.usecase
import com.rainy.token.data.local.UsageCache
import com.rainy.token.data.repository.CommandCodeUsageRepository
import javax.inject.Inject
import javax.inject.Provider
/**
* CommandCode 用量同步 UseCase。
*
* 游标协议:每页返回 (记录列表, 下一页游标)。
* - cursor=null → 最新页
* - 返回的列表长度 < PAGE_SIZE → 到底
*
* ## 全量同步
* 从 cursor=null 逐页抓取直至最后一页。
*
* ## 增量同步
* 从 cursor=null 逐页抓取,每页比对本地已有 ID,
* 当某页全部记录都已存在时停止。
*/
class SyncCommandCodeUsageUseCase @Inject constructor(
private val usageRepoProvider: Provider<CommandCodeUsageRepository>,
private val cacheProvider: Provider<UsageCache>
) {
/** 防御性页数上限:正常窗口(1 天 ≤ 数十页)远不会触及,防止游标异常导致死循环 */
private companion object {
const val MAX_PAGES = 500
}
suspend fun fullSync(): Result<SyncResult> {
val repo = usageRepoProvider.get()
val cache = cacheProvider.get()
var cursor: String? = null
var totalInserted = 0
val errors = mutableListOf<String>()
var pages = 0
while (true) {
val pageResult = repo.fetchPage(cursor)
if (pageResult.isFailure) {
errors.add("cursor=${cursor?.take(20)}: ${pageResult.exceptionOrNull()?.message}")
break
}
val (records, nextCursor) = pageResult.getOrThrow()
if (records.isEmpty()) break
if (nextCursor == cursor) break // 游标未前进,防死循环
val before = cache.count()
cache.insertAll(records)
totalInserted += (cache.count() - before)
if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break
cursor = nextCursor
if (++pages >= MAX_PAGES) break // 防御性上限,正常窗口不会触及
}
return if (errors.isEmpty()) Result.success(SyncResult(inserted = totalInserted))
else Result.failure(SyncError.PartialSync(totalInserted, errors))
}
suspend fun incrementalSync(): Result<SyncResult> {
val repo = usageRepoProvider.get()
val cache = cacheProvider.get()
var cursor: String? = null
var totalInserted = 0
var pages = 0
while (true) {
val pageResult = repo.fetchPage(cursor)
if (pageResult.isFailure) return Result.failure(pageResult.exceptionOrNull()!!)
val (records, nextCursor) = pageResult.getOrThrow()
if (records.isEmpty()) break
if (nextCursor == cursor) break // 游标未前进,防死循环
// 按 workspace 过滤本地已有 ID,避免跨 workspace 碰撞
val workspaceId = records.firstOrNull()?.workspaceId ?: CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val existingIds = cache.getIdsByWorkspace(workspaceId)
val newRecords = records.filter { it.id !in existingIds }
if (newRecords.isEmpty()) break
cache.insertAll(newRecords)
totalInserted += newRecords.size
if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break
cursor = nextCursor
if (++pages >= MAX_PAGES) break // 防御性上限
}
return Result.success(SyncResult(inserted = totalInserted))
}
}

View File

@ -61,7 +61,9 @@ class SyncUsageUseCase @Inject constructor(
val records = pageResult.getOrThrow()
if (records.isEmpty()) break
val existingIds = cache.getAllIds() // 本地已有 ID 集合
// 用第一条记录的 workspaceId 过滤本地已有 ID,避免跨 workspace 碰撞
val workspaceId = records.firstOrNull()?.workspaceId ?: ""
val existingIds = if (workspaceId.isNotEmpty()) cache.getIdsByWorkspace(workspaceId) else cache.getAllIds()
val newRecords = records.filter { it.id !in existingIds }
if (newRecords.isEmpty()) break // 整页都已存在 → 接到旧数据
@ -83,7 +85,10 @@ data class SyncResult(
)
sealed class SyncError : Exception() {
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
override val message: String = "部分同步完成:插入 $inserted 条,${errors.size} 页失败"
}
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
override val message: String = buildString {
append("部分同步完成:插入 ${inserted}条,${errors.size}页失败")
if (errors.isNotEmpty()) append("。${errors.first().take(200)}")
}
}
}

View File

@ -1,38 +1,119 @@
package com.rainy.token.ui
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.rainy.token.R
import com.rainy.token.data.repository.CommandCodeUsageRepository
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.rememberWindowSizeClass
import com.rainy.token.ui.components.DebugLogScreen
import com.rainy.token.ui.components.TipsScreen
import com.rainy.token.ui.dashboard.DashboardScreen
import com.rainy.token.ui.dashboard.DashboardViewModel
import com.rainy.token.ui.dashboard.UsageChartViewModel
import com.rainy.token.ui.dashboard.UsageDataScreen
import com.rainy.token.ui.dashboard.UsageDataViewModel
import com.rainy.token.ui.dashboard.UsageDetailScreen
import com.rainy.token.ui.dashboard.UsageOverviewScreen
import com.rainy.token.ui.dashboard.UsageViewModel
import com.rainy.token.ui.heatmap.HeatmapScreen
import com.rainy.token.ui.account.ApiManagementScreen
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
import com.rainy.token.ui.settings.AccountManagementScreen
import com.rainy.token.ui.settings.CredentialEditScreen
import com.rainy.token.ui.settings.CredentialEditViewModel
import com.rainy.token.ui.settings.SettingsScreen
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.ui.webview.CodexOAuthScreen
import com.rainy.token.ui.webview.WebViewLoginScreen
import com.rainy.token.ui.webview.WebViewPageScreen
import android.net.Uri
/**
* 应用导航图。当前阶段 4 接入了完整路径:
* Dashboard → Settings → CredentialEdit → WebViewLogin
* Dashboard → ServiceDetail
* 应用导航图。
*
* 自适应策略:
* - **Compact**(< 600dp,手机):单栈 NavHost。guardedPop 时间戳围栏(200ms)防连点栈损坏,
* enter/exit/popEnter/popExit 显式使用同一套滑动过渡,避免默认淡入淡出与返回动画混用。
* - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard + 右侧 when 分支原子切换,
* 用量子路由用局部 NavHost。
*/
object Routes {
const val DASHBOARD = "dashboard"
const val SETTINGS = "settings"
const val CREDENTIAL_EDIT = "credential_edit/{type}"
fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}"
const val WEBVIEW_LOGIN = "webview_login/{type}"
fun webviewLogin(type: ServiceType) = "webview_login/${type.name}"
const val SERVICE_DETAIL = "service/{type}"
fun serviceDetail(type: ServiceType) = "service/${type.name}"
const val API_MANAGEMENT = "api_management"
const val TIPS = "tips"
const val DEBUG_LOG = "debug_log"
const val CODEX_OAUTH = "codex_oauth"
const val CREDENTIAL_EDIT = "credential_edit/{type}?account={account}"
fun credentialEdit(type: ServiceType, accountId: String? = null): String =
if (accountId.isNullOrBlank()) {
"credential_edit/${type.name}"
} else {
"credential_edit/${type.name}?account=${Uri.encode(accountId)}"
}
const val ACCOUNTS = "accounts/{type}"
fun accounts(type: ServiceType) = "accounts/${type.name}"
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}"
fun webviewLogin(type: ServiceType, accountId: String? = null): String =
if (accountId.isNullOrBlank()) {
"webview_login/${type.name}"
} else {
"webview_login/${type.name}?account=${Uri.encode(accountId)}"
}
const val WEBVIEW_PAGE = "webview_page?url={url}&title={title}"
fun webviewPage(url: String, title: String) =
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
const val SERVICE_DETAIL = "service/{type}?account={account}"
fun serviceDetail(type: ServiceType, accountId: String? = null): String =
if (accountId.isNullOrBlank()) {
"service/${type.name}"
} else {
"service/${type.name}?account=${Uri.encode(accountId)}"
}
const val USAGE_DETAIL = "usage_detail"
const val USAGE_OVERVIEW = "usage_overview"
const val USAGE_DATA = "usage_data"
const val CCGO_USAGE_DETAIL = "ccgo_usage_detail"
const val CCGO_USAGE_OVERVIEW = "ccgo_usage_overview"
const val CCGO_USAGE_DATA = "ccgo_usage_data"
const val HEATMAP = "heatmap"
}
private fun parseServiceType(typeName: String?): ServiceType =
@ -40,77 +121,737 @@ private fun parseServiceType(typeName: String?): ServiceType =
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
} ?: ServiceType.DEEPSEEK
/** 凭据入口:多账号服务先进账号管理页,其余直达凭据编辑页。 */
private fun credentialEntry(type: ServiceType): String =
if (ServiceType.isMultiAccount(type)) Routes.accounts(type) else Routes.credentialEdit(type)
private sealed class DetailPane {
object Empty : DetailPane()
data class ServiceDetail(val type: ServiceType, val accountId: String? = null) : DetailPane()
object OCGOUsage : DetailPane()
object CCGOUsage : DetailPane()
object Settings : DetailPane()
object ApiManagement : DetailPane()
object Heatmap : DetailPane()
/** 通用外部页面(充值/订阅页),App 内 WebView 打开。 */
data class WebPage(val url: String, val title: String) : DetailPane()
}
@Composable
fun RainyTokenNavHost() {
val windowSize = rememberWindowSizeClass()
val isExpanded = windowSize.widthSizeClass ==
androidx.compose.material3.windowsizeclass.WindowWidthSizeClass.Expanded
if (isExpanded) {
ExpandedLayout()
} else {
CompactNavHost()
}
}
// ═══════════════════════════════════════════════
// Compact:单栈 NavHost —— guardedPop 围栏 + popExit/popEnter=None
// ═══════════════════════════════════════════════
/**
* 非 Compose State 的时间戳围栏。
*
* 为什么不用 mutableStateOf:
* mutableStateOf 写入会触发 CompactNavHost → NavHost 重组。若重组发生在
* AnimatedContent 过渡动画期间,会干扰内部 MutableTransitionState 状态机,
* 导致旧 composable 已移除、新 composable 进入动画被中断后未正确启动 → 空白页。
*
* 连点保护:cooldownMs 内只放行一次 pop。popBackStack() 返回 false 时 reset。
*/
private class PopGuard(
private val cooldownMs: Long = 200
) {
private var lastPopTime: Long = 0L
fun tryAcquire(): Boolean {
val now = System.currentTimeMillis()
if (now - lastPopTime < cooldownMs) return false
lastPopTime = now
return true
}
fun reset() {
lastPopTime = 0L
}
}
@Composable
private fun CompactNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Routes.DASHBOARD) {
// 围栏:200ms 内只允许一次 pop;普通对象持有时间戳,不触发 NavHost 重组
val popGuard = remember { PopGuard() }
val guardedPop: () -> Unit = {
// 起始页无 previousBackStackEntry,直接跳过,避免无意义的 popBackStack() 调用
if (navController.previousBackStackEntry != null && popGuard.tryAcquire()) {
// popBackStack() 返回 false = 已在起始页,reset 围栏不浪费冷却
if (!navController.popBackStack()) {
popGuard.reset()
}
}
}
NavHost(
navController = navController,
startDestination = Routes.DASHBOARD,
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(220)
) + fadeIn(animationSpec = tween(90))
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(220)
) + fadeOut(animationSpec = tween(90))
},
// Navigation Compose 会把 pop 过渡绑定到 Android 13+ predictive back 手势进度,
// 因此这里使用同一套滑动动画,避免默认淡入淡出与返回动画混用。
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(220)
) + fadeOut(animationSpec = tween(90))
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(220)
) + fadeIn(animationSpec = tween(90))
}
) {
composable(Routes.DASHBOARD) {
val rechargeLabel = stringResource(R.string.action_recharge)
DashboardScreen(
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) }
onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) },
onOpenService = { type, accountId ->
navController.navigate(Routes.serviceDetail(type, accountId))
},
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) },
onOpenRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(Routes.API_MANAGEMENT) {
val rechargeLabel = stringResource(R.string.action_recharge)
ApiManagementScreen(
onBack = guardedPop,
onConfigure = { type -> navController.navigate(credentialEntry(type)) },
onRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(Routes.USAGE_DETAIL) {
UsageDetailScreen(
onBack = { navController.popBackStack() },
onBack = guardedPop,
onOpenOverview = { navController.navigate(Routes.USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.USAGE_DATA) }
)
}
composable(Routes.USAGE_OVERVIEW) {
UsageOverviewScreen(
onBack = { navController.popBackStack() }
onBack = guardedPop
)
}
composable(Routes.USAGE_DATA) {
UsageDataScreen(
onBack = { navController.popBackStack() }
onBack = guardedPop
)
}
composable(Routes.CCGO_USAGE_DETAIL) {
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
LaunchedEffect(Unit) {
usageVm.setWorkspace(wid)
chartVm.setWorkspace(wid)
}
UsageDetailScreen(
onBack = guardedPop,
onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) },
viewModel = chartVm,
clearViewModel = usageVm
)
}
composable(Routes.CCGO_USAGE_OVERVIEW) {
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
val ovVm: UsageViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { ovVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
UsageOverviewScreen(
onBack = guardedPop,
viewModel = ovVm,
autoLoad = false
)
}
composable(Routes.CCGO_USAGE_DATA) {
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { dataVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
UsageDataScreen(
onBack = guardedPop,
viewModel = dataVm,
autoLoad = false
)
}
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = { navController.popBackStack() },
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) }
onBack = guardedPop,
onEditCredential = { type -> navController.navigate(credentialEntry(type)) },
onManageAccounts = { type -> navController.navigate(Routes.accounts(type)) },
onOpenTips = { navController.navigate(Routes.TIPS) },
onOpenDebugLog = { navController.navigate(Routes.DEBUG_LOG) }
)
}
composable(Routes.ACCOUNTS) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
AccountManagementScreen(
service = type,
onBack = guardedPop,
onEditAccount = { svc, accountId ->
navController.navigate(Routes.credentialEdit(svc, accountId))
}
)
}
composable(Routes.TIPS) {
TipsScreen(onBack = guardedPop)
}
composable(Routes.DEBUG_LOG) {
DebugLogScreen(onBack = guardedPop)
}
composable(Routes.CODEX_OAUTH) {
CodexOAuthScreen(
onBack = guardedPop,
onSuccess = guardedPop
)
}
composable(
route = Routes.CREDENTIAL_EDIT,
arguments = listOf(navArgument("type") { type = NavType.StringType })
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
val accountId = backStackEntry.arguments?.getString("account")
CredentialEditScreen(
service = type,
onBack = { navController.popBackStack() },
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
onWebViewLoginSuccess = { /* 登录后回到编辑页,由 LaunchedEffect 触发刷新 */ }
accountId = accountId,
onBack = guardedPop,
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
onWebViewLoginSuccess = { },
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
)
}
composable(
route = Routes.WEBVIEW_LOGIN,
arguments = listOf(navArgument("type") { type = NavType.StringType })
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
val accountId = backStackEntry.arguments?.getString("account")
WebViewLoginScreen(
service = type,
onBack = { navController.popBackStack() },
onLoginSucceeded = {
// 登录成功直接回退到 CredentialEdit 页
navController.popBackStack()
accountId = accountId,
onBack = guardedPop,
onLoginSucceeded = { _, effectiveId ->
// 把实际写入的账号 ID 回传给上一层(凭据编辑页),新建账号模式用于回填
if (!effectiveId.isNullOrBlank()) {
navController.previousBackStackEntry?.savedStateHandle?.set(
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
)
}
// 登录成功后返回上一层(详情页或凭据编辑页),由 ON_RESUME 刷新内容
if (!navController.popBackStack(Routes.SERVICE_DETAIL, inclusive = false)) {
guardedPop()
}
}
)
}
composable(
route = Routes.SERVICE_DETAIL,
arguments = listOf(navArgument("type") { type = NavType.StringType })
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
val accountId = backStackEntry.arguments?.getString("account")
val rechargeLabel = stringResource(R.string.action_recharge)
ServiceDetailScreen(
service = type,
onBack = { navController.popBackStack() },
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }
accountId = accountId,
onBack = guardedPop,
onSwitchAccount = { svc, acc ->
// 切换账号时替换当前详情页(避免同路由栈堆积)
navController.navigate(Routes.serviceDetail(svc, acc)) {
popUpTo(Routes.SERVICE_DETAIL) { inclusive = true }
}
},
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
onOpenRecharge = { svc ->
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(
route = Routes.WEBVIEW_PAGE,
arguments = listOf(
navArgument("url") { type = NavType.StringType },
navArgument("title") { type = NavType.StringType }
)
) { backStackEntry ->
val url = backStackEntry.arguments?.getString("url").orEmpty()
val title = backStackEntry.arguments?.getString("title").orEmpty()
WebViewPageScreen(url = url, title = title, onBack = guardedPop)
}
composable(Routes.HEATMAP) {
HeatmapScreen(onBack = guardedPop)
}
}
}
// ═══════════════════════════════════════════════
// Expanded:手动双窗格
// ═══════════════════════════════════════════════
@Composable
private fun ExpandedLayout() {
var detailPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
val dashboardVm: DashboardViewModel = hiltViewModel()
// 宽屏布局下 DashboardScreen 永久挂载,不会收到 ON_RESUME。
// 当详情面板关闭时手动触发 Dashboard 刷新凭据状态。
var prevPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
LaunchedEffect(detailPane) {
if (prevPane != DetailPane.Empty && detailPane == DetailPane.Empty) {
dashboardVm.reloadLocalState()
}
prevPane = detailPane
}
val rechargeLabel = stringResource(R.string.action_recharge)
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(0.35f)
.fillMaxHeight()
) {
DashboardScreen(
onOpenSettings = { detailPane = DetailPane.Settings },
onOpenApiManagement = { detailPane = DetailPane.ApiManagement },
onOpenService = { type, accountId ->
detailPane = DetailPane.ServiceDetail(type, accountId)
},
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
onOpenRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
}
},
viewModel = dashboardVm
)
}
VerticalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
Box(
modifier = Modifier
.weight(0.65f)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.background)
) {
ExpandedDetailPane(
pane = detailPane,
onClose = { detailPane = DetailPane.Empty },
onSwitchAccount = { svc, acc ->
detailPane = DetailPane.ServiceDetail(svc, acc)
},
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
)
}
}
}
@Composable
private fun ExpandedDetailPane(
pane: DetailPane,
onClose: () -> Unit,
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
onCredentialEditClosed: () -> Unit = {}
) {
when (pane) {
is DetailPane.Empty -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(R.string.nav_empty_title), style = MaterialTheme.typography.titleMedium, color = inkMuted())
Text(stringResource(R.string.nav_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
}
is DetailPane.ServiceDetail -> {
// key(pane.type + accountId):切换服务/账号时重建嵌套导航栈,避免停留在上一个页面
key("${pane.type.name}:${pane.accountId.orEmpty()}") {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "detail",
popExitTransition = { ExitTransition.None }
) {
composable("detail") {
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
ServiceDetailScreen(
service = pane.type,
accountId = pane.accountId,
onBack = onClose,
onSwitchAccount = onSwitchAccount,
onConfigureCredential = { onClose() },
onStartWebViewLogin = { _, _ -> },
onOpenRecharge = { svc ->
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(
route = Routes.WEBVIEW_PAGE,
arguments = listOf(
navArgument("url") { type = NavType.StringType },
navArgument("title") { type = NavType.StringType }
)
) { entry ->
WebViewPageScreen(
url = entry.arguments?.getString("url").orEmpty(),
title = entry.arguments?.getString("title").orEmpty(),
onBack = { navController.popBackStack() }
)
}
}
}
}
is DetailPane.OCGOUsage -> {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "chart",
popExitTransition = { ExitTransition.None }
) {
composable("chart") {
UsageDetailScreen(
onBack = onClose,
onOpenOverview = { navController.navigate("overview") },
onOpenData = { navController.navigate("data") }
)
}
composable("overview") {
UsageOverviewScreen(onBack = { navController.popBackStack() }, autoLoad = true)
}
composable("data") {
UsageDataScreen(onBack = { navController.popBackStack() }, autoLoad = true)
}
}
}
is DetailPane.CCGOUsage -> {
val navController = rememberNavController()
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
LaunchedEffect(Unit) {
usageVm.setWorkspace(wid)
chartVm.setWorkspace(wid)
}
NavHost(
navController = navController,
startDestination = "chart",
popExitTransition = { ExitTransition.None }
) {
composable("chart") {
UsageDetailScreen(
onBack = onClose,
onOpenOverview = { navController.navigate("overview") },
onOpenData = { navController.navigate("data") },
viewModel = chartVm,
clearViewModel = usageVm
)
}
composable("overview") {
val key = "ccgo_$wid"
val ovVm: UsageViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { ovVm.setWorkspace(wid) }
UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false)
}
composable("data") {
val key = "ccgo_$wid"
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { dataVm.setWorkspace(wid) }
UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false)
}
}
}
is DetailPane.Heatmap -> {
HeatmapScreen(onBack = onClose)
}
is DetailPane.WebPage -> {
WebViewPageScreen(
url = pane.url,
title = pane.title,
onBack = onClose
)
}
is DetailPane.ApiManagement -> {
val apiNavController = rememberNavController()
// 从凭据编辑/WebView 返回到 api_mgmt_main 时刷新 Dashboard 凭据状态
val routeStack by apiNavController.currentBackStackEntryAsState()
val currentRoute = routeStack?.destination?.route
var prevRoute by remember { mutableStateOf("api_mgmt_main") }
LaunchedEffect(currentRoute) {
if (prevRoute != currentRoute && currentRoute == "api_mgmt_main") {
onCredentialEditClosed()
}
prevRoute = currentRoute ?: "api_mgmt_main"
}
NavHost(
navController = apiNavController,
startDestination = "api_mgmt_main",
popExitTransition = { ExitTransition.None }
) {
composable("api_mgmt_main") {
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
ApiManagementScreen(
onBack = onClose,
onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) },
onRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(Routes.ACCOUNTS) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
AccountManagementScreen(
service = type,
onBack = { apiNavController.popBackStack() },
onEditAccount = { svc, accountId ->
apiNavController.navigate(Routes.credentialEdit(svc, accountId))
}
)
}
composable(
route = Routes.CREDENTIAL_EDIT,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
CredentialEditScreen(
service = type,
accountId = accountId,
onBack = { apiNavController.popBackStack() },
onStartWebViewLogin = { svc, acc ->
apiNavController.navigate(Routes.webviewLogin(svc, acc))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
)
}
composable(
route = Routes.WEBVIEW_LOGIN,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
WebViewLoginScreen(
service = type,
accountId = accountId,
onBack = { apiNavController.popBackStack() },
onLoginSucceeded = { _, effectiveId ->
if (!effectiveId.isNullOrBlank()) {
apiNavController.previousBackStackEntry?.savedStateHandle?.set(
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
)
}
apiNavController.popBackStack()
}
)
}
composable(Routes.CODEX_OAUTH) {
CodexOAuthScreen(
onBack = { apiNavController.popBackStack() },
onSuccess = { apiNavController.popBackStack() }
)
}
composable(
route = Routes.WEBVIEW_PAGE,
arguments = listOf(
navArgument("url") { type = NavType.StringType },
navArgument("title") { type = NavType.StringType }
)
) { entry ->
WebViewPageScreen(
url = entry.arguments?.getString("url").orEmpty(),
title = entry.arguments?.getString("title").orEmpty(),
onBack = { apiNavController.popBackStack() }
)
}
}
}
is DetailPane.Settings -> {
val settingsNavController = rememberNavController()
// 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时
// 通知 Dashboard 刷新凭据状态(宽屏布局 Dashboard 永久挂载不产生 ON_RESUME)
val routeStack by settingsNavController.currentBackStackEntryAsState()
val currentRoute = routeStack?.destination?.route
var prevRoute by remember { mutableStateOf("settings_main") }
LaunchedEffect(currentRoute) {
if (prevRoute != currentRoute && currentRoute == "settings_main") {
onCredentialEditClosed()
}
prevRoute = currentRoute ?: "settings_main"
}
NavHost(
navController = settingsNavController,
startDestination = "settings_main",
popExitTransition = { ExitTransition.None }
) {
composable("settings_main") {
SettingsScreen(
onBack = onClose,
onEditCredential = { type ->
settingsNavController.navigate(credentialEntry(type))
},
onManageAccounts = { type ->
settingsNavController.navigate(Routes.accounts(type))
},
onOpenTips = { settingsNavController.navigate("tips") },
onOpenDebugLog = { settingsNavController.navigate("debug_log") }
)
}
composable(Routes.ACCOUNTS) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
AccountManagementScreen(
service = type,
onBack = { settingsNavController.popBackStack() },
onEditAccount = { svc, accountId ->
settingsNavController.navigate(Routes.credentialEdit(svc, accountId))
}
)
}
composable("tips") {
TipsScreen(onBack = { settingsNavController.popBackStack() })
}
composable("debug_log") {
DebugLogScreen(onBack = { settingsNavController.popBackStack() })
}
composable(Routes.CODEX_OAUTH) {
CodexOAuthScreen(
onBack = { settingsNavController.popBackStack() },
onSuccess = { settingsNavController.popBackStack() }
)
}
composable(
route = Routes.CREDENTIAL_EDIT,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
CredentialEditScreen(
service = type,
accountId = accountId,
onBack = { settingsNavController.popBackStack() },
onStartWebViewLogin = { svc, acc ->
settingsNavController.navigate(Routes.webviewLogin(svc, acc))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
)
}
composable(
route = Routes.WEBVIEW_LOGIN,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
WebViewLoginScreen(
service = type,
accountId = accountId,
onBack = { settingsNavController.popBackStack() },
onLoginSucceeded = { _, effectiveId ->
if (!effectiveId.isNullOrBlank()) {
settingsNavController.previousBackStackEntry?.savedStateHandle?.set(
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
)
}
settingsNavController.popBackStack()
}
)
}
}
}
}
}

View File

@ -0,0 +1,442 @@
package com.rainy.token.ui.account
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.formatAmount
import com.rainy.token.ui.components.resolve
import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.ui.theme.StatusGreen
import com.rainy.token.ui.theme.StatusOrange
import com.rainy.token.ui.theme.inkMuted
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* API 管理页(记事本式)。
*
* 心智模型像记事本:正文只有「已添加」的服务条目,右上角 **+** 从剩余服务里选一个
* 添加(进入对应配置流程);未配置的服务不占卡片。条目点击即编辑,行内提供
* 测试连接 / 删除(需确认),有官方充值/订阅页的服务额外给入口。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ApiManagementScreen(
onBack: () -> Unit,
onConfigure: (ServiceType) -> Unit,
onRecharge: (ServiceType) -> Unit,
viewModel: ApiManagementViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val message by viewModel.message.collectAsStateWithLifecycle()
val testing by viewModel.testing.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
var pendingDelete by remember { mutableStateOf<ServiceType?>(null) }
var showAddPicker by remember { mutableStateOf(false) }
LaunchedEffect(message) {
message?.let {
snackbarHostState.showSnackbar(it.resolve(context))
viewModel.clearMessage()
}
}
// 从凭据编辑页/WebView/充值页返回时刷新列表(新增/编辑/删除即时可见,覆盖 Compact 与 Expanded)
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.load()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.api_mgmt_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back)
)
}
},
actions = {
IconButton(onClick = { showAddPicker = true }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.api_mgmt_add),
tint = StrawberryPink
)
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) { data -> Snackbar(snackbarData = data) } }
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (uiState.rows.isEmpty()) {
item { EmptyNotesHint(onAdd = { showAddPicker = true }) }
}
items(uiState.rows, key = { it.service.storageKey }) { row ->
ApiRowCard(
row = row,
testing = row.service in testing,
onEdit = { onConfigure(row.service) },
onTest = { viewModel.test(row.service) },
onDelete = { pendingDelete = row.service },
onRecharge = { onRecharge(row.service) }
)
}
}
}
if (showAddPicker) {
AddServicePickerDialog(
addable = uiState.addable,
onPick = { service ->
showAddPicker = false
onConfigure(service)
},
onDismiss = { showAddPicker = false }
)
}
pendingDelete?.let { service ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.api_mgmt_delete_title)) },
text = {
Text(stringResource(R.string.api_mgmt_delete_body, service.displayName))
},
confirmButton = {
TextButton(
onClick = {
viewModel.delete(service)
pendingDelete = null
}
) {
Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
}
/** 空态引导:还没有添加任何条目时展示,给出「+ 添加」动作。 */
@Composable
private fun EmptyNotesHint(onAdd: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.api_mgmt_empty_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.api_mgmt_empty_body),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onAdd,
colors = ButtonDefaults.buttonColors(
containerColor = StrawberryPink,
contentColor = Color.White
)
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.api_mgmt_add))
}
}
}
}
/** 「+ 添加」服务选择弹窗:列出尚未添加的服务,点选即进入配置流程。 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddServicePickerDialog(
addable: List<ServiceType>,
onPick: (ServiceType) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.api_mgmt_add_picker_title)) },
text = {
if (addable.isEmpty()) {
Text(
text = stringResource(R.string.api_mgmt_all_added),
style = MaterialTheme.typography.bodyMedium,
color = inkMuted()
)
} else {
LazyColumn(
modifier = Modifier.heightIn(max = 320.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(addable, key = { it.storageKey }) { service ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable { onPick(service) }
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
ServiceIcon(service = service, size = 36)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = service.displayName,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = stringResource(serviceDescriptionRes(service)),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
/** 添加弹窗里的服务一句话说明(与首页卡片副标题同源,保持口径一致)。 */
@Composable
private fun serviceDescriptionRes(service: ServiceType): Int = when (service) {
ServiceType.DEEPSEEK -> R.string.service_desc_deepseek
ServiceType.OPENCODE_GO -> R.string.service_desc_opencode_go
ServiceType.COMMANDCODE_GO -> R.string.service_desc_commandcode_go
ServiceType.CODEX -> R.string.service_desc_codex
ServiceType.OLLAMA -> R.string.service_desc_ollama
ServiceType.TRAE -> R.string.service_desc_trae
ServiceType.WORKBUDDY -> R.string.service_desc_workbuddy
ServiceType.SUB2API -> R.string.service_desc_sub2api
}
/** 已添加条目卡:整卡点击进编辑,行内给测试 / 删除,有官方充值页的服务附充值按钮。 */
@Composable
private fun ApiRowCard(
row: ApiRowUi,
testing: Boolean,
onEdit: () -> Unit,
onTest: () -> Unit,
onDelete: () -> Unit,
onRecharge: () -> Unit
) {
// 有官方充值/订阅页的服务(ServiceConfig.externalUrl)才展示充值入口
val rechargeUrl = remember(row.service) {
runCatching { ServiceConfigProvider.get(row.service).externalUrl }.getOrNull()
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onEdit() },
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
ServiceIcon(service = row.service, size = 44)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.service.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (row.isMultiAccountRow) {
stringResource(R.string.api_mgmt_accounts_subtitle, row.accountCount)
} else {
row.maskedCredential ?: stringResource(R.string.api_mgmt_unconfigured)
},
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
Spacer(modifier = Modifier.width(8.dp))
StatusDot(state = row.state)
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 最后验证时间 / 缓存余额
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.api_mgmt_last_verified, formatVerified(row.lastVerifiedAt)),
style = MaterialTheme.typography.labelSmall,
color = inkMuted()
)
row.cachedBalance?.let { cached ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(
R.string.api_mgmt_cached_balance,
formatAmount(cached.balance.amount),
cached.balance.unit
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
Spacer(modifier = Modifier.width(8.dp))
// 测试连接(进行中转圈)+ 删除;多账号服务行内不提供(避免只对默认账号操作 / 误删全部账号)
if (!row.isMultiAccountRow) {
if (testing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = StrawberryPink,
strokeWidth = 2.dp
)
} else {
TextButton(onClick = onTest) {
Text(
stringResource(R.string.api_mgmt_test),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error
)
}
}
if (rechargeUrl != null) {
TextButton(onClick = onRecharge) {
Text(
stringResource(R.string.action_recharge),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
@Composable
private fun StatusDot(state: CredentialStatus.State) {
val color = when (state) {
CredentialStatus.State.OK -> StatusGreen
CredentialStatus.State.WARNING -> StatusOrange
CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error
CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f)
}
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(50))
.background(color = color)
)
}
private fun formatVerified(ts: Long): String {
if (ts <= 0L) return "—"
return runCatching {
SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(ts))
}.getOrDefault("—")
}

View File

@ -0,0 +1,174 @@
package com.rainy.token.ui.account
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.cache.CachedBalance
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import com.rainy.token.ui.components.UiText
import com.rainy.token.ui.components.formatAmount
import com.rainy.token.R
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* API 管理页 ViewModel(记事本式)。
*
* 列表只展示「已添加」的凭据条目(Key 掩码、最后验证、缓存余额),
* 未配置的服务不占位;右上角 + 从 [addable] 里选服务新增。
* 提供测试连接 / 删除操作,编辑走既有 credential_edit 页面。
*/
@HiltViewModel
class ApiManagementViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
private val refreshBalanceUseCase: RefreshBalanceUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(ApiManagementUiState())
val uiState: StateFlow<ApiManagementUiState> = _uiState.asStateFlow()
private val _message = MutableStateFlow<UiText?>(null)
val message: StateFlow<UiText?> = _message
/** 正在测试连接的服务集合(支持并发多测,各自独立转圈)。 */
private val _testing = MutableStateFlow<Set<ServiceType>>(emptySet())
val testing: StateFlow<Set<ServiceType>> = _testing
init {
load()
}
fun load() {
viewModelScope.launch {
val states = credentialRepository.readLocalStates()
val all = ServiceType.entries.map { type ->
val local = states.getValue(type)
val cred = credentialRepository.get(type)
ApiRowUi(
service = type,
state = local.status.state,
lastVerifiedAt = local.status.lastVerifiedAt,
cachedBalance = local.cachedBalance,
maskedCredential = maskCredential(cred),
accountCount = if (ServiceType.isMultiAccount(type)) {
credentialRepository.accountsFor(type).size
} else {
0
}
)
}
_uiState.update {
it.copy(
// 记事本式:列表只放已添加的条目,未配置的服务收进「+」选择列表
rows = all.filter { row -> row.isConfigured },
addable = all.filterNot { row -> row.isConfigured }.map { row -> row.service }
)
}
}
}
fun delete(service: ServiceType) {
viewModelScope.launch {
credentialRepository.remove(service)
_message.value = UiText.Resource(R.string.msg_credential_deleted)
load()
}
}
/**
* 测试连接:拉一次余额并回应用户;成功/失败只透传摘要(本地化),不改动凭据之外的东西。
*/
fun test(service: ServiceType) {
_testing.update { it + service }
viewModelScope.launch {
val text: UiText = refreshBalanceUseCase(service).fold(
onSuccess = { bal ->
UiText.Resource(
R.string.api_mgmt_test_success,
listOf(service.displayName, formatAmount(bal.amount), bal.unit)
)
},
onFailure = { err ->
// errorSummary 返回 UiText,resolve 会把嵌套 UiText 参数扁平化本地化
UiText.Resource(
R.string.api_mgmt_test_fail,
listOf(service.displayName, errorSummary(err))
)
}
)
_testing.update { it - service }
_message.value = text
load()
}
}
fun clearMessage() {
_message.value = null
}
/** 把凭据的核心值打成掩码(前1后1/中间星号);无凭据返回 null。 */
private fun maskCredential(credential: Credential?): String? = when (credential) {
null -> null
is Credential.ApiKeyCredential -> mask(credential.key)
is Credential.SessionCredential -> {
credential.apiKey?.let { mask(it) }
?: credential.token?.let { mask(it) }
?: credential.authCookie?.let { mask(it) }
?: credential.cookies.firstOrNull()?.let { mask("${it.name}=${it.value}") }
?: credential.ollamaCookie?.take(8)?.let { mask(it) }
}
is Credential.CodexCredential -> mask(credential.accessToken)
is Credential.TraeCredential -> mask(credential.jwt)
is Credential.WorkBuddyCredential -> mask(credential.accessToken)
// Sub2API:显示实例基址作为身份标识(非机密),便于区分多个自托管实例;
// 真正的机密(password/apiKey)不做掩码展示。
is Credential.Sub2ApiCredential -> credential.baseUrl
}
private fun mask(value: String): String {
if (value.length <= 8) return "••••"
return "${value.take(4)}••••${value.takeLast(4)}"
}
private fun errorSummary(error: Throwable?): UiText = when (error) {
is RepositoryError.SessionExpired -> UiText.Resource(R.string.error_credential_session_expired)
is RepositoryError.InvalidCredential -> UiText.Resource(R.string.api_mgmt_err_invalid)
is RepositoryError.CredentialChanged -> UiText.Resource(R.string.api_mgmt_err_changed)
is RepositoryError.RateLimited -> UiText.Resource(R.string.api_mgmt_err_ratelimited)
is RepositoryError.Network -> UiText.Resource(R.string.api_mgmt_err_network)
is RepositoryError.ServerError -> UiText.Resource(R.string.api_mgmt_err_server, listOf(error.code))
is RepositoryError.ParseError -> UiText.Resource(R.string.api_mgmt_err_parse)
else -> UiText.Resource(R.string.common_unknown)
}
}
data class ApiRowUi(
val service: ServiceType,
val state: CredentialStatus.State,
val lastVerifiedAt: Long,
val cachedBalance: CachedBalance?,
val maskedCredential: String?,
/** 多账号服务的账号个数;单账号服务为 0。 */
val accountCount: Int = 0
) {
val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED
/** 多账号服务且已有账号:行内不提供单服务的测试/删除,引导去账号管理页。 */
val isMultiAccountRow: Boolean get() = ServiceType.isMultiAccount(service) && accountCount > 0
}
data class ApiManagementUiState(
/** 已添加的凭据条目(记事本正文)。 */
val rows: List<ApiRowUi> = emptyList(),
/** 尚未添加的服务(右上角 + 的可选列表)。 */
val addable: List<ServiceType> = emptyList()
)

View File

@ -0,0 +1,151 @@
package com.rainy.token.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.annotation.StringRes
import com.rainy.token.R
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
/**
* APP 使用小技巧列表。
* Dashboard 卡片上方随机展示一条,设置页可查看全部。
*
* 文案全部改为字符串资源 ID(@StringRes),由 UI 层按当前语言环境解析,
* 保证英文系统下自动显示英文翻译。
*/
object AppTips {
data class Tip(
@StringRes val titleRes: Int,
@StringRes val hintRes: Int,
@StringRes val detailRes: Int
)
val tips: List<Tip> = listOf(
Tip(R.string.tip_1_title, R.string.tip_1_hint, R.string.tip_1_detail),
Tip(R.string.tip_2_title, R.string.tip_2_hint, R.string.tip_2_detail),
Tip(R.string.tip_3_title, R.string.tip_3_hint, R.string.tip_3_detail),
Tip(R.string.tip_4_title, R.string.tip_4_hint, R.string.tip_4_detail),
Tip(R.string.tip_5_title, R.string.tip_5_hint, R.string.tip_5_detail),
Tip(R.string.tip_6_title, R.string.tip_6_hint, R.string.tip_6_detail),
Tip(R.string.tip_7_title, R.string.tip_7_hint, R.string.tip_7_detail),
Tip(R.string.tip_8_title, R.string.tip_8_hint, R.string.tip_8_detail),
Tip(R.string.tip_9_title, R.string.tip_9_hint, R.string.tip_9_detail),
Tip(R.string.tip_10_title, R.string.tip_10_hint, R.string.tip_10_detail),
Tip(R.string.tip_11_title, R.string.tip_11_hint, R.string.tip_11_detail),
Tip(R.string.tip_12_title, R.string.tip_12_hint, R.string.tip_12_detail),
Tip(R.string.tip_13_title, R.string.tip_13_hint, R.string.tip_13_detail),
)
/** 首页轮换用的一句话文案(资源 ID 形式)。 */
val shortHintRes: List<Int> = tips.map { it.hintRes }
/** 随机取一条短文案的资源 ID。 */
fun randomHintRes(): Int = shortHintRes.random()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TipsScreen(onBack: () -> Unit) {
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = {
Text(
stringResource(R.string.title_tips),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
tint = StrawberryPink
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
itemsIndexed(AppTips.tips) { idx, tip ->
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${idx + 1}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = StrawberryPink
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(tip.titleRes),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(tip.detailRes),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
}
}
}
item {
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}

View File

@ -0,0 +1,154 @@
package com.rainy.token.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StatusOrange
import com.rainy.token.ui.theme.StatusRed
import com.rainy.token.ui.theme.StrawberryPink
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DebugLogScreen(onBack: () -> Unit) {
val entries by DebugLog.entries.collectAsStateWithLifecycle()
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = {
Text(
stringResource(R.string.title_debug_log),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
tint = StrawberryPink
)
}
},
actions = {
TextButton(onClick = { DebugLog.clear() }) {
Text(stringResource(R.string.action_clear), color = StrawberryPink)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { innerPadding ->
if (entries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.debug_log_empty),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted,
modifier = Modifier.padding(32.dp)
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry ->
val levelColor = when (entry.level) {
DebugLog.Level.INFO -> InkMuted
DebugLog.Level.WARN -> StatusOrange
DebugLog.Level.ERROR -> StatusRed
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = entry.level.label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = levelColor
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = entry.tag,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = InkMuted
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = entry.toString().substringBefore(' '),
style = MaterialTheme.typography.labelSmall,
color = InkMuted.copy(alpha = 0.6f)
)
}
Spacer(modifier = Modifier.padding(top = 4.dp))
Text(
text = entry.message,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
}
}

View File

@ -0,0 +1,84 @@
package com.rainy.token.ui.components
import java.util.Locale
/**
* 共享格式化工具函数。
*
* 从 DashboardScreen / ServiceDetailScreen / WidgetProvider 提取,消除三处重复。
*
* 本地化策略:函数保持纯 JVM(不依赖 Context / Resources,可被单元测试直接调用),
* 本地化标签通过 [DurationText] / 字符串参数注入。调用方(Compose / Widget)用
* `context.getString(...)` 取得当前语言标签后传入;测试继续使用默认中文标签。
*/
/** 时长标签(用于 "X 天 Y 小时" 这类拼接)。 */
data class DurationText(
val day: String,
val hour: String,
val minute: String,
/** 无有效时长时展示的占位符 */
val dash: String = "—"
)
/** 默认中文标签(保持与原实现输出完全一致,供单元测试与兜底使用)。 */
val ChineseDurationText = DurationText(day = "天", hour = "小时", minute = "分")
/** 格式化金额:整数无小数点,带小数保留 2 位(金额数值格式固定 Locale.US,避免小数点歧义) */
fun formatAmount(value: Double): String {
return if (value % 1.0 == 0.0) value.toInt().toString()
else String.format(Locale.US, "%.2f", value)
}
/** 格式化重置倒计时(秒 → "X 天 Y 小时" / "X 小时 Y 分" / "Y 分"),无效返回 "—" */
fun formatResetInSec(sec: Long, text: DurationText = ChineseDurationText): String {
if (sec <= 0) return text.dash
val days = sec / 86400
val hours = (sec % 86400) / 3600
val minutes = (sec % 3600) / 60
return when {
days > 0 -> "$days ${text.day} $hours ${text.hour}"
hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}"
else -> "$minutes ${text.minute}"
}
}
/** Widget 版本:逻辑相同但空值返回空串(非 "—"),适配 RemoteViews 布局 */
fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): String {
if (sec <= 0) return ""
val days = sec / 86400
val hours = (sec % 86400) / 3600
val minutes = (sec % 3600) / 60
return when {
days > 0 -> "$days ${text.day} $hours ${text.hour}"
hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}"
else -> "$minutes ${text.minute}"
}
}
/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,usage/用量 → 用量标签,其他原样返回 */
fun normalizeWindowLabel(
label: String,
weeklyLabel: String = "Weekly",
monthlyLabel: String = "Monthly",
usageLabel: String = "Usage"
): String = when (label.lowercase()) {
"weekly", "每周" -> weeklyLabel
"monthly", "每月" -> monthlyLabel
"usage", "用量" -> usageLabel
else -> label
}
/**
* 判断窗口标签是否为 5h 窗口(与语言无关):
* - 匹配英文缩写(5h/5H)
* - 匹配本地化标签(如 "5小时"/"5 小时"/"5小時")
* - 兼容历史中文数据(含 "5" 且含 "小时")
*/
fun isFiveHourLabel(label: String, fiveHourLabel: String, fiveHourShort: String): Boolean {
val normalized = label.trim()
return normalized.equals(fiveHourShort, ignoreCase = true) ||
normalized == fiveHourLabel ||
(normalized.contains("5") && normalized.contains("小时")) ||
(normalized.contains("5") && normalized.contains("小時"))
}

View File

@ -13,10 +13,10 @@ import com.rainy.token.ui.theme.DarkSurface
import androidx.compose.foundation.isSystemInDarkTheme
/**
* 雨晴风格全局背景。
* 雨晴风格全局背景(精致粉调)。
*
* Light 模式:樱粉渐变(#FFF0F5 → #FFD1DC,从左上到右下)
* Dark 模式:暖深渐变(#1F1419 → #2A1F25)
* Light 模式:低饱和粉白渐变(#FFF9FB → #FDEFF4,从左上到右下)
* Dark 模式:暖深渐变(#1A1417 → #251C21)
*
* 放在 Scaffold 容器层,所有页面共享同一层渐变。
*/

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@ -12,8 +13,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material3.Text
import com.rainy.token.R
import com.rainy.token.domain.service.ServiceType
@ -41,7 +44,7 @@ fun ServiceIcon(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
.background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
contentAlignment = Alignment.Center
) {
Image(
@ -51,20 +54,135 @@ fun ServiceIcon(
)
}
}
ServiceType.COMMANDCODE_GO -> {
Box(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.ic_opencode_go_logo),
contentDescription = "CommandCode",
modifier = Modifier.size((size * 0.85).dp, ((size * 0.85f * 30f / 54f)).dp)
)
}
}
ServiceType.DEEPSEEK -> {
// DeepSeek 官方无开放 logo 资源 —— 用蓝鲸 emoji 占位
Box(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.ic_deepseek_logo),
contentDescription = "DeepSeek",
modifier = Modifier.fillMaxSize()
)
}
}
ServiceType.CODEX -> {
// ChatGPT 官方 favicon logo
Box(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF4A6CF7)),
.background(if (isSystemInDarkTheme()) Color(0xFF2D3748) else Color(0xFF10A37F)),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Text(
text = "🐋",
fontSize = (size * 0.5).sp
Image(
painter = painterResource(id = R.drawable.ic_codex_logo),
contentDescription = "Codex / ChatGPT",
modifier = Modifier
.size((size * 0.6).dp),
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(
if (isSystemInDarkTheme()) Color.White else Color.White
)
)
}
}
ServiceType.OLLAMA -> {
Box(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isSystemInDarkTheme()) Color(0xFFF5F5F5) else Color.White),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.ic_ollama_logo),
contentDescription = "Ollama",
modifier = Modifier.fillMaxSize()
)
}
}
ServiceType.TRAE -> {
Box(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isSystemInDarkTheme()) Color(0xFF20242E) else Color.White),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.ic_trae_logo),
contentDescription = "Trae",
modifier = Modifier.fillMaxSize()
)
}
}
ServiceType.WORKBUDDY -> {
Box(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.ic_workbuddy_logo),
contentDescription = "WorkBuddy",
modifier = Modifier.fillMaxSize()
)
}
}
ServiceType.SUB2API -> {
LetterIcon(
letter = "S",
background = Color(0xFF6C5CE7),
modifier = modifier,
size = size
)
}
}
}
/**
* 品牌色字母占位图标。正式 logo(Trae / WorkBuddy / Sub2API)落地后
* 在此替换为 drawable 分支(同 DeepSeek 模式)。
*/
@Composable
private fun LetterIcon(
letter: String,
background: Color,
modifier: Modifier,
size: Int
) {
Box(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(10.dp))
.background(background),
contentAlignment = Alignment.Center
) {
Text(
text = letter,
color = Color.White,
fontSize = (size * 0.5f).sp,
fontWeight = FontWeight.Bold
)
}
}

View File

@ -0,0 +1,65 @@
package com.rainy.token.ui.components
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.unit.Dp
import com.caverock.androidsvg.SVG
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* 轻量站点图标加载(OkHttp 拉取,无三方图片库)。
* 支持 PNG/JPEG/WebP(BitmapFactory)与 SVG(androidsvg 渲染);失败静默不渲染。
* 用于 Sub2API 凭据页站点预览与主页卡片 Logo。
*/
@Composable
fun SiteIcon(url: String, size: Dp) {
val icon by produceState<ImageBitmap?>(initialValue = null, url) {
// 网络 + SVG 解码放到 IO 线程:produceState 默认在 UI 调度器执行,
// 直接在生产者里阻塞请求会造成主页卡片掉帧 / StrictMode 告警。
value = withContext(Dispatchers.IO) {
runCatching {
if (!url.startsWith("http")) return@runCatching null
val resp = OkHttpClient.Builder()
.build()
.newCall(Request.Builder().url(url).build())
.execute()
resp.use {
val bytes = it.body?.bytes() ?: return@runCatching null
decodeIcon(bytes)
}
}.getOrNull()
}
}
val bitmap = icon ?: return
Image(
painter = BitmapPainter(bitmap),
contentDescription = null,
modifier = Modifier.size(size)
)
}
/** 位图解码:先按 PNG/JPEG 解,失败再按 SVG 渲染(BitmapFactory 不支持 SVG)。 */
private fun decodeIcon(bytes: ByteArray): ImageBitmap? {
val raster = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
if (raster != null) return raster.asImageBitmap()
return runCatching {
val svg = SVG.getFromString(String(bytes, Charsets.UTF_8))
val w = svg.documentWidth.toInt().coerceIn(16, 1024)
val h = svg.documentHeight.toInt().coerceIn(16, 1024)
val bmp = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
val c = android.graphics.Canvas(bmp)
svg.renderToCanvas(c)
bmp.asImageBitmap()
}.getOrNull()
}

View File

@ -1,6 +1,7 @@
package com.rainy.token.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -16,6 +18,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.rainy.token.ui.theme.StatusBlue
import com.rainy.token.ui.theme.StatusGreen
@ -33,7 +36,7 @@ import com.rainy.token.ui.theme.StatusRed
enum class StatusLevel { OK, WARNING, ERROR, INFO }
data class StatusStyle(
val label: String,
@StringRes val labelRes: Int,
val level: StatusLevel
)
@ -42,11 +45,14 @@ fun StatusChip(
style: StatusStyle,
modifier: Modifier = Modifier
) {
// 暗色模式下胶囊底色是状态色 12% alpha 叠加暗色卡片,
// 深色文字几乎不可读,故暗色使用同色系亮色文字。
val dark = isSystemInDarkTheme()
val (dotColor, textColor) = when (style.level) {
StatusLevel.OK -> StatusGreen to Color(0xFF1B5E20)
StatusLevel.WARNING -> StatusOrange to Color(0xFF8A4A00)
StatusLevel.ERROR -> StatusRed to Color(0xFF8B0033)
StatusLevel.INFO -> StatusBlue to Color(0xFF0D47A1)
StatusLevel.OK -> StatusGreen to if (dark) Color(0xFF9CE8BC) else Color(0xFF1B5E20)
StatusLevel.WARNING -> StatusOrange to if (dark) Color(0xFFFFDBA6) else Color(0xFF8A4A00)
StatusLevel.ERROR -> StatusRed to if (dark) Color(0xFFFFB6CA) else Color(0xFF8A1F40)
StatusLevel.INFO -> StatusBlue to if (dark) Color(0xFFAED7FF) else Color(0xFF0D47A1)
}
val bgColor = dotColor.copy(alpha = 0.12f)
Row(
@ -64,7 +70,7 @@ fun StatusChip(
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = style.label,
text = stringResource(style.labelRes),
style = MaterialTheme.typography.labelSmall,
color = textColor
)

View File

@ -0,0 +1,44 @@
package com.rainy.token.ui.components
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
/**
* 可本地化的文本抽象。
*
* ViewModel / Repository 层不直接持有本地化字符串,而是返回 [UiText]:
* - [Resource]:引用字符串资源(可带格式化参数),由 UI 层按当前语言环境解析
* - [Dynamic]:服务端返回的动态文本(未知内容,无法预翻译),原样透传
*
* 调试日志保持使用原始 [Throwable.message](Repository 中文消息仅用于排查,不用于 UI 展示)。
*/
sealed interface UiText {
data class Resource(
@StringRes val resId: Int,
val args: List<Any> = emptyList()
) : UiText
data class Dynamic(val value: String) : UiText
}
/** 在非 Composable 上下文(协程/回调/事件)中把 [UiText] 解析为字符串(嵌套 [UiText] 参数会递归扁平化)。 */
fun UiText.resolve(context: Context): String = when (this) {
is UiText.Resource -> {
val flatArgs = args.map { arg -> if (arg is UiText) arg.resolve(context) else arg }
context.getString(resId, *flatArgs.toTypedArray())
}
is UiText.Dynamic -> value
}
/** 在 Composable 上下文中把 [UiText] 解析为当前语言环境的字符串(嵌套 [UiText] 参数会递归扁平化)。 */
@Composable
fun UiText.asString(): String = when (this) {
is UiText.Resource -> {
val flatArgs = args.map { arg -> if (arg is UiText) arg.asString() else arg }
stringResource(resId, *flatArgs.toTypedArray())
}
is UiText.Dynamic -> value
}

View File

@ -0,0 +1,28 @@
package com.rainy.token.ui.components
import android.app.Activity
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
* 获取当前窗口大小等级,用于自适应布局(手机/平板/折叠屏)。
*
* 在各 Screen Composable 顶部调用:
* ```
* val windowSize = rememberWindowSizeClass()
* when (windowSize.widthSizeClass) {
* WindowWidthSizeClass.Compact → // 手机竖屏(宽度 < 600dp)
* WindowWidthSizeClass.Medium → // 小平板/折叠屏展开(600-840dp)
* WindowWidthSizeClass.Expanded → // 大平板横屏(> 840dp)
* }
* ```
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun rememberWindowSizeClass(): WindowSizeClass {
val activity = LocalContext.current as Activity
return calculateWindowSizeClass(activity)
}

View File

@ -0,0 +1,211 @@
package com.rainy.token.ui.dashboard
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.ui.components.asString
import com.rainy.token.data.local.OverviewStats
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
import java.util.Locale
/**
* CommandCode 用量统计主卡片 —— 风格与 UsageStatsCard(OCGO)完全一致。
* 仅展示核心指标:输入 Token(含 Cache)、输出 Token、总花费,
* 外加同步按钮和"查看详情"入口。详细统计在 CCGO 专属详情页。
*/
@Composable
fun CommandCodeUsageStatsCard(
onOpenDetail: () -> Unit,
refreshTrigger: Int = 0
) {
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val key = "ccgo_$wid"
val viewModel: UsageViewModel = hiltViewModel(key = key)
// 初始化 workspace,后续刷新自动走 CCGO 的 sync use case
LaunchedEffect(Unit) {
viewModel.setWorkspace(wid)
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 全局刷新触发用量同步(跳过首次 0)
LaunchedEffect(refreshTrigger) {
if (refreshTrigger > 0) viewModel.sync()
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
// ─── 头部:标题 + 同步按钮 ───
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.usage_title_commandcode),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (uiState.recordCount > 0) stringResource(R.string.usage_record_count, uiState.recordCount) else stringResource(R.string.usage_click_to_sync),
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
}
if (uiState.syncing) {
CircularProgressIndicator(
modifier = Modifier.padding(4.dp),
strokeWidth = 2.dp,
color = StrawberryPink
)
} else {
IconButton(onClick = { viewModel.sync() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.action_sync_usage),
tint = StrawberryPink
)
}
}
}
// ─── 核心指标 ───
uiState.overview?.let { overview ->
Spacer(modifier = Modifier.height(12.dp))
// 输入 Token = input + cacheRead
val inputTotal = overview.inputTokens + overview.cacheReadTokens
MetricRow(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
// 缓存子行
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
val parts = mutableListOf<String>()
if (overview.cacheReadTokens > 0) parts.add(stringResource(R.string.usage_cache_read, formatTokenCount(overview.cacheReadTokens)))
if (overview.cacheWriteTokens > 0) parts.add(stringResource(R.string.usage_cache_write, formatTokenCount(overview.cacheWriteTokens)))
Text(
text = parts.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(start = 4.dp, top = 2.dp)
)
}
Spacer(modifier = Modifier.height(4.dp))
// 输出 Token + 总花费 同一排
Row(Modifier.fillMaxWidth()) {
MetricRow(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens), Modifier.weight(1f))
MetricRow(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
}
}
// ─── 空状态 ───
if (uiState.overview == null && !uiState.syncing) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (uiState.loading) stringResource(R.string.common_loading) else stringResource(R.string.usage_no_data_sync),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
}
// ─── 查看详情 ───
if (uiState.overview != null) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onOpenDetail) {
Text(stringResource(R.string.action_view_detail), color = StrawberryPink)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
tint = StrawberryPink,
modifier = Modifier.padding(top = 1.dp)
)
}
}
// ─── 同步结果反馈 ───
if (uiState.lastSyncResult > 0) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.usage_new_records, uiState.lastSyncResult),
style = MaterialTheme.typography.bodySmall,
color = StrawberryPink
)
}
uiState.lastSyncError?.let { err ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "✗ ${err.asString()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
// ─── 刷新时间(与其余服务卡片底部一致) ───
if (uiState.lastSyncAt > 0) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = usageUpdatedAtText(uiState.lastSyncAt),
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
}
}
}
}
@Composable
private fun MetricRow(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
Text(
text = value,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = StrawberryPink
)
}
}
private fun formatTokenCount(tokens: Long): String {
return when {
tokens >= 1_000_000 -> "${"%.1f".format(Locale.US, tokens / 1_000_000.0)}M"
tokens >= 1_000 -> "${"%.1f".format(Locale.US, tokens / 1_000.0)}K"
else -> "$tokens"
}
}

View File

@ -3,16 +3,22 @@ package com.rainy.token.ui.dashboard
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.cache.CachedBalance
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import com.rainy.token.R
import com.rainy.token.ui.components.UiText
import com.rainy.token.ui.dashboard.autocheckin.CHECKIN_SERVICES
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@ -21,86 +27,417 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlinx.coroutines.sync.Mutex
/**
* 仪表盘 ViewModel。
*
* 状态聚合:凭据状态 + 余额缓存 + 在线刷新
* - 启动时读缓存展示(无网时也能看)
* - 启动时读缓存展示(无网时也能看旧数据)
* - refresh() 并行拉取所有服务的最新余额(任一失败不影响其他)
* - 下拉刷新触发同一 refresh()
*/
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache,
private val refreshBalanceUseCase: RefreshBalanceUseCase,
@param:ApplicationContext private val appContext: Context
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
private val autoCheckinManager: com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager,
@param:ApplicationContext private val appContext: Context,
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(DashboardUiState())
private val _uiState = MutableStateFlow(DashboardUiState(loading = true))
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
init {
loadFromCache()
refresh()
}
/** 防止并发 refresh() 调用交错覆盖 UI。 */
private val refreshMutex = Mutex()
/** 从本地缓存快速填充一次(不阻塞) */
private fun loadFromCache() {
init {
viewModelScope.launch {
val cached = balanceCache.getAll()
val cards = ServiceType.entries.map { type ->
buildCard(type, cachedBalance = cached[type], lastFetchError = null)
// 1) 先从缓存快速填充 UI(不阻塞首帧,断网时也能看旧数据)
loadFromCache()
// 2) 每日自动签到:必须先于 refresh() 串行完成。两者并发会对同一服务取
// 相同凭据快照,后提交者因快照过期被丢弃(CredentialChanged),而签到
// 在服务端已成功、角标也已落库 → 弹窗报「凭据」错误但积分实际到账,
// 展示自相矛盾。串行后签到先落库,refresh 再拉到含新积分的余额。
// 用 try/catch 而非 runCatching:后者会吞掉 CancellationException,
// 导致 ViewModel 销毁取消后协程仍继续往下跑。
// 2.5) 打开 App 时把零点后台闹钟重排到下次零点(开机/升级/后台被杀后的兜底)
autoCheckinManager.refreshSchedule()
try {
autoCheckinManager.runDailyIfDue()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
// 签到失败不阻塞余额刷新
}
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
// 3) 发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值
refresh()
// 4) 同步签到状态到本地记录并重建角标(跨设备/旧版本签过的也能显示)
syncCheckinStatus()
}
}
/** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上 */
/**
* 首页自己查询签到状态(不依赖进详情页触发):
* 本地无当日记录的账号问一次服务端 status,已签的回写 [CheckinStatusStore] 后重建卡片角标。
*/
private suspend fun syncCheckinStatus() {
val accountStates = credentialRepository.readAccountStates()
val toQuery = accountStates.filter { entry ->
entry.service in CHECKIN_SERVICES &&
entry.credential != null &&
!checkinStatusStore.isCheckedToday(entry.service, entry.account?.id)
}
if (toQuery.isEmpty()) return
val newlyChecked = toQuery.filter { entry ->
runCatching {
when (entry.service) {
ServiceType.TRAE ->
refreshBalanceUseCase.fetchTraeCheckinStatus(entry.account?.id).getOrNull() == true
ServiceType.WORKBUDDY ->
refreshBalanceUseCase.fetchCheckinStatus(entry.account?.id).getOrNull()?.todayCheckedIn == true
else -> false
}
}.getOrDefault(false)
}
if (newlyChecked.isEmpty()) return
newlyChecked.forEach { checkinStatusStore.markChecked(it.service, it.account?.id) }
// 用最新记录更新卡片角标:按 store 真值重算(已有记录的账号不被误清)
_uiState.update { state ->
state.copy(cards = state.cards.map { card ->
card.copy(
checkedInToday = card.service in CHECKIN_SERVICES &&
checkinStatusStore.isCheckedToday(card.service, card.accountId)
)
})
}
}
/** 自动签到结果(非 null 时首页弹「xx 已签到」汇报弹窗;关闭后置 null)。 */
val autoCheckinReport: StateFlow<List<com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager.ServiceResult>?> =
autoCheckinManager.report
/** 自动签到开关状态(首页顶部提示行右侧 Switch)。 */
private val _autoCheckinEnabled = MutableStateFlow(autoCheckinManager.enabled)
val autoCheckinEnabled: StateFlow<Boolean> = _autoCheckinEnabled.asStateFlow()
fun setAutoCheckinEnabled(value: Boolean) {
autoCheckinManager.enabled = value
_autoCheckinEnabled.value = value
}
fun consumeAutoCheckinReport() = autoCheckinManager.consumeReport()
/**
* 重新读取本地凭据状态 + 余额缓存,不自动发起网络请求(除非发现已配置
* 但没有余额缓存的"新账号",此时拉一次余额让首页立刻有数据,不用等进详情页)。
*
* 卡片记录不含明文密钥的 SHA-256 指纹。更新时始终基于 _uiState 的最新卡片:
* - 以当前已配置服务(configuredTypes)为准重建,新增服务即时出现、删除服务即时消失;
* - 仍存在的服务复用旧卡片做增量合并:
* - 凭据变化时采用已清理过的当前账户缓存;
* - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显;
* - 不修改 refreshing,旧缓存快照也不能回滚新数据。
*/
fun reloadLocalState() {
viewModelScope.launch {
val accountStates = credentialRepository.readAccountStates()
_uiState.update { state ->
state.copy(cards = rebuildCardsPreservingOrder(state, accountStates))
}
// 登录/导入后刚出现的账号还没有余额缓存:立即拉一次,首页即刻看到
// 余额与角标,不用等用户点进详情页才触发刷新(刷新内部有 Mutex 防并发)。
val hasFreshAccountWithoutCache = accountStates.any { entry ->
entry.status.state != CredentialStatus.State.NOT_CONFIGURED &&
entry.cachedBalance == null
}
if (hasFreshAccountWithoutCache) refresh()
}
}
/** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */
private suspend fun loadFromCache() {
val accountStates = credentialRepository.readAccountStates()
val entries = orderAccountEntries(accountStates)
val cards = entries.map { entry ->
buildCard(entry, lastFetchError = null)
}
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
}
/** 拉取所有已配置账号的最新余额,更新缓存。失败保留旧数据并把错误信息带上。 */
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(refreshing = true) }
val results: Map<ServiceType, Result<ServiceBalance>?> = coroutineScope {
ServiceType.entries.map { type ->
async {
val status = credentialRepository.statusFor(type)
if (status.state == CredentialStatus.State.NOT_CONFIGURED) {
type to null // 未配置的服务不拉
} else {
type to refreshBalanceUseCase(type)
// Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过
if (!refreshMutex.tryLock()) return@launch
try {
_uiState.update { it.copy(refreshing = true) }
val results: Map<AccountKey, Result<ServiceBalance>?> = coroutineScope {
val entries = orderAccountEntries(credentialRepository.readAccountStates())
entries.map { entry ->
async {
val key = AccountKey(entry.service, entry.account?.id)
val result: Result<ServiceBalance>? =
if (entry.status.state == CredentialStatus.State.NOT_CONFIGURED) {
null // 未配置的账号不拉
} else {
refreshBalanceUseCase(entry.service, entry.account?.id)
}
key to result
}
}
}.awaitAll().toMap()
}
val newCache = balanceCache.getAll()
val cards = ServiceType.entries.map { type ->
val result = results[type]
val errMsg = result?.exceptionOrNull()?.message
buildCard(type, cachedBalance = newCache[type], lastFetchError = errMsg)
}
_uiState.update { it.copy(refreshing = false, cards = cards) }
// 刷新成功后更新桌面小组件
if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) {
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
}.awaitAll().associateBy({ it.first }, { it.second })
}
val accountStates = credentialRepository.readAccountStates()
val cards = orderAccountEntries(accountStates).map { entry ->
val key = AccountKey(entry.service, entry.account?.id)
val error = results[key]?.exceptionOrNull()
val errorUi = error
?.takeUnless { it is RepositoryError.CredentialChanged }
?.let { errorToUiText(it) }
buildCard(entry, lastFetchError = errorUi)
}
_uiState.update { it.copy(refreshing = false, cards = cards) }
// 刷新成功后更新桌面小组件
if (results[AccountKey(ServiceType.OPENCODE_GO, null)]?.isSuccess == true) {
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
}
} finally {
refreshMutex.unlock()
}
}
}
private suspend fun buildCard(
type: ServiceType,
cachedBalance: CachedBalance?,
lastFetchError: String?
): DashboardCardUi {
val status = credentialRepository.statusFor(type)
return DashboardCardUi(
service = type,
credentialState = status.state,
cachedBalance = cachedBalance,
lastFetchError = lastFetchError
/**
* 基于最新账号状态重建卡片列表,尽量保留旧顺序:
* - 同一服务内默认账号在前,其余按账号创建顺序;
* - 新增账号追加到同服务现有卡片之后;服务整体顺序沿用旧顺序,新服务按枚举追加。
*/
private fun rebuildCardsPreservingOrder(
state: DashboardUiState,
accountStates: List<CredentialRepository.AccountLocalState>
): List<DashboardCardUi> {
val entries = orderAccountEntries(accountStates)
val previousByKey = state.cards.associateBy { it.key }
return entries.mapIndexed { _, entry ->
val key = entry.service.storageKey + ":" + entry.account?.id.orEmpty()
val previous = previousByKey[key]
if (previous == null) {
buildCard(entry, lastFetchError = null)
} else {
val credentialChanged = entry.fingerprint != previous.credentialFingerprint
val cacheAdvanced = !credentialChanged && isNewer(
candidate = entry.cachedBalance,
current = previous.cachedBalance
)
previous.copy(
credentialState = entry.status.state,
credentialFingerprint = entry.fingerprint,
cachedBalance = if (credentialChanged) {
entry.cachedBalance
} else {
newerOf(previous.cachedBalance, entry.cachedBalance)
},
lastFetchError = if (credentialChanged || cacheAdvanced) {
null
} else {
previous.lastFetchError
},
// 账号默认状态在设置页改动后返回时也要同步
isDefaultAccount = entry.account?.isDefault == true,
// 签到角标每次 resume 重算:详情页手动签到后返回首页要立刻看到 ✓
checkedInToday = entry.service in CHECKIN_SERVICES &&
checkinStatusStore.isCheckedToday(entry.service, entry.account?.id)
)
}
}
}
/** 账号条目排序:服务按旧顺序/枚举,服务内默认账号优先、其余按存储顺序。 */
private fun orderAccountEntries(
accountStates: List<CredentialRepository.AccountLocalState>
): List<CredentialRepository.AccountLocalState> {
val existingOrder = _uiState.value.cards
.mapIndexed { index, card -> card.service to index }
.toMap()
return accountStates.sortedWith(
compareBy(
{ existingOrder[it.service] ?: Int.MAX_VALUE },
{ it.service.ordinal },
{ if (it.account?.isDefault == true) 0 else 1 },
{ it.account?.createdAt ?: 0L }
)
)
}
private fun buildCard(
local: CredentialRepository.AccountLocalState,
lastFetchError: UiText?
): DashboardCardUi = DashboardCardUi(
service = local.service,
accountId = local.account?.id,
accountLabel = local.account?.label,
accountIndex = 0, // 展示兜底名称时由 UI 根据同服务卡片顺序计算
isDefaultAccount = local.account?.isDefault == true,
credentialState = local.status.state,
credentialFingerprint = local.fingerprint,
cachedBalance = local.cachedBalance,
lastFetchError = lastFetchError,
siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon,
// 可签到服务(Trae/WorkBuddy)各账号的今日签到角标;其它服务恒 false 不显示
checkedInToday = local.service in CHECKIN_SERVICES &&
checkinStatusStore.isCheckedToday(local.service, local.account?.id)
)
private fun newerOf(
current: CachedBalance?,
candidate: CachedBalance?
): CachedBalance? = when {
current == null -> candidate
candidate == null -> current
candidate.fetchedAt > current.fetchedAt -> candidate
else -> current
}
private fun isNewer(
candidate: CachedBalance?,
current: CachedBalance?
): Boolean = candidate != null && (current == null || candidate.fetchedAt > current.fetchedAt)
// ---------- Sub2API 面板管理(Key 列表/新建/分组/兑换) ----------
/** Key 列表状态。 */
private val _panelKeysState = MutableStateFlow<com.rainy.token.ui.servicedetail.PanelKeysState>(
com.rainy.token.ui.servicedetail.PanelKeysState.Idle
)
val panelKeysState: StateFlow<com.rainy.token.ui.servicedetail.PanelKeysState> = _panelKeysState.asStateFlow()
/** 可用分组(新建 Key / 切分组下拉)。 */
private val _panelGroups = MutableStateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>>(emptyList())
val panelGroups: StateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>> = _panelGroups.asStateFlow()
/** 一次性操作结果消息(新建/切组/兑换)。 */
private val _panelActionMessage = MutableStateFlow<String?>(null)
val panelActionMessage: StateFlow<String?> = _panelActionMessage.asStateFlow()
fun consumePanelActionMessage() { _panelActionMessage.value = null }
/** 首页面板弹窗当前管理的 Sub2API 账号(null=默认账号)。 */
private val _sub2PanelAccountId = MutableStateFlow<String?>(null)
val sub2PanelAccountId: StateFlow<String?> = _sub2PanelAccountId.asStateFlow()
/** 打开首页面板弹窗并绑定目标账号,同时重置为未加载状态。 */
fun openSub2Panel(accountId: String?) {
_sub2PanelAccountId.value = accountId
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Idle
_panelGroups.value = emptyList()
_panelGroupsAccountId = null
_panelActionMessage.value = null
_recentlyCreatedKey.value = null
}
/** 最近一次新建成功的明文 Key(弹窗立即复制用;查看后清除)。 */
private val _recentlyCreatedKey = MutableStateFlow<String?>(null)
val recentlyCreatedKey: StateFlow<String?> = _recentlyCreatedKey.asStateFlow()
fun consumeRecentlyCreatedKey() { _recentlyCreatedKey.value = null }
/** 刷新面板 Key 列表(仅 SUB2API 且有面板 Token)。 */
fun loadPanelKeys() {
viewModelScope.launch {
if (_panelKeysState.value is com.rainy.token.ui.servicedetail.PanelKeysState.Loading) return@launch
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Loading
sub2ApiRepository.fetchPanelKeys(_sub2PanelAccountId.value).fold(
onSuccess = { list ->
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Success(list)
},
onFailure = { e ->
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Error(e.message ?: "加载 Key 列表失败")
}
)
}
}
/** 面板分组缓存所属账号(防止切换账号后仍显示上一个账号的分组)。 */
private var _panelGroupsAccountId: String? = null
/** 加载可用分组;同一账号加载过一次则跳过,切换账号后强制重拉。 */
fun loadPanelGroups() {
viewModelScope.launch {
val accountId = _sub2PanelAccountId.value
if (_panelGroupsAccountId == accountId && _panelGroups.value.isNotEmpty()) return@launch
sub2ApiRepository.fetchPanelGroups(accountId).fold(
onSuccess = {
_panelGroups.value = it
_panelGroupsAccountId = accountId
},
onFailure = {
// 分组加载失败不阻塞主流程(新建 Key 时可不选分组)
}
)
}
}
/** 新建 API Key:成功后展示掩码消息并刷新列表。 */
fun createPanelKey(name: String, groupId: Long?) {
viewModelScope.launch {
sub2ApiRepository.createPanelKey(name, groupId, _sub2PanelAccountId.value).fold(
onSuccess = { key ->
_recentlyCreatedKey.value = key
_panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)"
loadPanelKeys()
},
onFailure = { e -> _panelActionMessage.value = "创建失败:" + (e.message ?: "") }
)
}
}
/** 切换 Key 所属分组:成功后刷新列表。 */
fun switchPanelGroup(keyId: Long, groupId: Long) {
viewModelScope.launch {
sub2ApiRepository.updatePanelKey(keyId, groupId, accountId = _sub2PanelAccountId.value).fold(
onSuccess = {
_panelActionMessage.value = "分组已切换"
loadPanelKeys()
},
onFailure = { e -> _panelActionMessage.value = "切换失败:" + (e.message ?: "") }
)
}
}
/** 删除 Key:成功后刷新列表。 */
fun deletePanelKey(keyId: Long) {
viewModelScope.launch {
sub2ApiRepository.deletePanelKey(keyId, _sub2PanelAccountId.value).fold(
onSuccess = {
_panelActionMessage.value = "已删除 Key"
loadPanelKeys()
},
onFailure = { e -> _panelActionMessage.value = "删除失败:" + (e.message ?: "") }
)
}
}
/** 兑换卡密:成功后刷新余额与 Key 列表。 */
fun redeemPanelCode(code: String) {
viewModelScope.launch {
sub2ApiRepository.redeemPanelCode(code, _sub2PanelAccountId.value).fold(
onSuccess = { msg ->
_panelActionMessage.value = msg
refreshBalanceUseCase(ServiceType.SUB2API, _sub2PanelAccountId.value)
loadPanelKeys()
},
onFailure = { e -> _panelActionMessage.value = "兑换失败:" + (e.message ?: "") }
)
}
}
/** 面板 API Key 展示用掩码:只留前 8 + 后 4 位,避免页面显示过长。 */
private fun maskPanelKey(key: String): String = when {
key.length <= 16 -> key
else -> key.take(8) + "…" + key.takeLast(4)
}
}
data class DashboardUiState(
@ -111,18 +448,63 @@ data class DashboardUiState(
data class DashboardCardUi(
val service: ServiceType,
/** 账号 ID(多账号服务);null=单账号服务。 */
val accountId: String? = null,
/** 账号自定义名称(可能为 null,UI 按“账号 N”兜底)。 */
val accountLabel: String? = null,
/** 同服务内的账号序号(0 起,用于兜底名称;由 UI 层计算后回写展示)。 */
val accountIndex: Int = 0,
/** 是否为默认账号(多账号服务)。 */
val isDefaultAccount: Boolean = false,
val credentialState: CredentialStatus.State,
val credentialFingerprint: String?,
val cachedBalance: CachedBalance?,
val lastFetchError: String?
val lastFetchError: UiText?,
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
val siteIcon: String? = null,
/** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */
val checkedInToday: Boolean = false
) {
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示) */
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
/** 卡片顶部状态徽章 */
val statusBadge: String get() = when {
credentialState == CredentialStatus.State.NOT_CONFIGURED -> "未配置"
lastFetchError != null -> "刷新失败"
cachedBalance == null -> "未获取"
else -> "正常"
/** 卡片唯一键:`{storageKey}:{accountId}`,单账号服务为 `{storageKey}:`。 */
val key: String get() = "${service.storageKey}:${accountId.orEmpty()}"
}
/** 内部请求/结果键:账号粒度(多账号服务逐账号拉取)。 */
private data class AccountKey(
val service: ServiceType,
val accountId: String? = null
)
/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */
private fun errorToUiText(error: Throwable): UiText = when (error) {
is RepositoryError.SessionExpired ->
UiText.Resource(R.string.error_credential_session_expired)
is RepositoryError.InvalidCredential ->
UiText.Resource(R.string.error_credential_invalid_reconfigure)
is RepositoryError.RateLimited -> UiText.Resource(
R.string.error_rate_limited_retry,
listOf(
error.retryAfterSeconds?.let {
UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it))
} ?: ""
)
)
is RepositoryError.Network -> UiText.Resource(R.string.error_network_check)
is RepositoryError.ServerError ->
UiText.Resource(R.string.error_server_http, listOf(error.code))
is RepositoryError.ParseError -> when (error.reason) {
RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body)
RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json)
RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows)
RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models)
RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
}
}
// Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案
is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown)
else -> error.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown)
}

View File

@ -0,0 +1,79 @@
package com.rainy.token.ui.dashboard
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.rainy.token.R
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
/**
* 日期范围选择弹窗 —— 单弹窗内一次选出起止日期(连续高亮)。
*
* - 状态通过 "if (show) Dialog(...)" 条件组合创建:每次打开都会用最新预填值重新初始化;
* - 预填毫秒一律按 UTC 零点解释(与项目既有 UTC 语义一致);
* - 两端日期齐备前,确认按钮保持禁用(M3 范围选择:同一天点两次 = 单日区间)。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DateRangePickerDialog(
title: String,
initialStart: LocalDate?,
initialEnd: LocalDate?,
onConfirm: (LocalDate, LocalDate) -> Unit,
onDismiss: () -> Unit
) {
// 预填数据 sanitize:无起点时忽略终点;终点早于起点时忽略终点
val sanitizedEnd = initialEnd?.takeIf { initialStart != null && !it.isBefore(initialStart) }
val state = rememberDateRangePickerState(
initialSelectedStartDateMillis = initialStart?.toUtcMillis(),
initialSelectedEndDateMillis = sanitizedEnd?.toUtcMillis()
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
val complete = state.selectedStartDateMillis != null && state.selectedEndDateMillis != null
TextButton(
onClick = {
val startMillis = state.selectedStartDateMillis
val endMillis = state.selectedEndDateMillis
if (startMillis != null && endMillis != null) {
onConfirm(startMillis.toUtcLocalDate(), endMillis.toUtcLocalDate())
}
},
enabled = complete
) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
) {
DateRangePicker(
state = state,
modifier = Modifier.fillMaxWidth().height(500.dp).padding(16.dp),
title = { Text(title) },
showModeToggle = false
)
}
}
private fun LocalDate.toUtcMillis(): Long =
atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
private fun Long.toUtcLocalDate(): LocalDate =
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()

View File

@ -0,0 +1,894 @@
package com.rainy.token.ui.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import androidx.annotation.StringRes
import com.rainy.token.R
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.Sub2DailyUsage
import com.rainy.token.domain.model.Sub2UsageDetail
import java.time.LocalDate
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.StatusStyle
import com.rainy.token.ui.components.StatusLevel
import com.rainy.token.ui.components.DurationText
import com.rainy.token.ui.components.UiText
import com.rainy.token.ui.components.formatAmount
import com.rainy.token.ui.components.formatResetInSec
import com.rainy.token.ui.components.isFiveHourLabel
import com.rainy.token.ui.components.normalizeWindowLabel
import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.ui.theme.StatusOrange
import com.rainy.token.ui.theme.inkMuted
import java.text.SimpleDateFormat
import java.util.Date
import kotlin.math.roundToInt
import java.util.Locale
// ── Service-specific balance Composables ──
@Composable
internal fun BalanceMainArea(card: DashboardCardUi) {
val balance = card.displayBalance
when {
card.credentialState == CredentialStatus.State.NOT_CONFIGURED -> {
Text(
text = "—",
style = MaterialTheme.typography.displayMedium,
color = inkMuted()
)
Text(
text = stringResource(R.string.service_click_to_configure),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
balance == null -> {
Text(
text = "—",
style = MaterialTheme.typography.displayMedium,
color = inkMuted()
)
Text(
text = stringResource(R.string.service_pull_to_refresh),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
card.service == ServiceType.OPENCODE_GO -> {
OpenCodeGoMainBalance(balance)
Spacer(modifier = Modifier.height(12.dp))
OpenCodeGoUsageWindows(balance)
}
card.service == ServiceType.COMMANDCODE_GO -> {
CommandCodeGoMainBalance(balance)
Spacer(modifier = Modifier.height(12.dp))
CommandCodeGoUsageWindows(balance)
}
card.service == ServiceType.CODEX -> {
CodexMainBalance(balance)
Spacer(modifier = Modifier.height(12.dp))
CodexUsageWindows(balance)
}
card.service == ServiceType.OLLAMA -> {
OllamaMainBalance(balance)
Spacer(modifier = Modifier.height(12.dp))
OllamaUsageWindows(balance)
}
card.service == ServiceType.SUB2API -> {
Sub2ApiBalanceWithUsage(balance)
}
card.service == ServiceType.TRAE -> {
TraeSplitMainBalance(balance)
}
else -> {
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = formatAmount(balance.amount),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = balance.unit,
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
}
if (!balance.isAvailable) {
Text(
text = stringResource(R.string.service_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
balance.monthlySpent?.let { spent ->
Text(
text = stringResource(R.string.service_monthly_used, formatAmount(spent), balance.unit),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
}
}
/**
* Trae 首页卡片主区:不显示总积分,分开显示「通用积分」(TraeCode/TraeWork 均可用)
* 与「Work 专属积分」(仅 TraeWork),取 extras 中 general_credits/work_credits 小计
* (与详情页 TraePacksCard 同口径)。旧缓存缺小计键时回退总积分展示,刷新后恢复。
*/
@Composable
private fun TraeSplitMainBalance(balance: ServiceBalance) {
val extras = balance.extras
val general = extras?.get("general_credits")?.toDoubleOrNull()
val work = extras?.get("work_credits")?.toDoubleOrNull()
if (general == null && work == null) {
// 旧缓存无小计键:回退原总积分展示
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = formatAmount(balance.amount),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = balance.unit,
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
}
return
}
val generalTitle = stringResource(R.string.trae_general_credits_title)
val workTitle = stringResource(R.string.trae_work_credits_title)
TraeSplitLine(generalTitle, general)
Spacer(modifier = Modifier.height(6.dp))
TraeSplitLine(workTitle, work)
}
/** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */
@Composable
private fun TraeSplitLine(label: String, value: Double?) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = inkMuted()
)
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = value?.let { formatAmount(it) } ?: "—",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Credits",
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 2.dp)
)
}
}
}
@Composable
internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = formatAmount(balance.amount),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "%",
style = MaterialTheme.typography.titleLarge,
color = inkMuted(),
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 6.dp, start = 2.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.window_5h_usage),
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
}
}
@Composable
internal fun OpenCodeGoUsageWindows(balance: ServiceBalance) {
val windows = listOf(
Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["rolling.resetInSec"]?.toLongOrNull()),
Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["weekly.resetInSec"]?.toLongOrNull()),
Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["monthly.resetInSec"]?.toLongOrNull())
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
windows.forEach { (label, pct, resetSec) ->
if (pct != null) {
CompactUsageRow(label = label, pct = pct, resetInSec = resetSec)
} else {
CompactUsageRowEmpty(label = label, resetInSec = resetSec)
}
}
}
}
@Composable
internal fun CommandCodeGoMainBalance(balance: ServiceBalance) {
val total = balance.totalQuota
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = formatAmount(balance.amount),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "$",
style = MaterialTheme.typography.titleLarge,
color = inkMuted(),
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 6.dp, start = 2.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.service_remaining),
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
if (total != null && total > 0) {
val used = total - balance.amount
Text(
text = stringResource(R.string.service_used_total, formatAmount(used), formatAmount(total)),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
}
}
}
@Composable
internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) {
val extras = balance.extras
fun calcPct(used: Double?, cap: Double?): Int? {
if (used == null || cap == null || cap <= 0) return null
return ((used / cap) * 100).toInt().coerceIn(0, 100)
}
val windows = listOf(
Triple(stringResource(R.string.window_5h), calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()),
Triple(stringResource(R.string.window_weekly), calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()),
Triple(stringResource(R.string.window_monthly), calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull())
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
windows.forEach { (label, pct, resetSec) ->
if (pct != null) {
CompactUsageRow(label = label, pct = pct, resetInSec = resetSec)
} else {
CompactUsageRowEmpty(label = label, resetInSec = resetSec)
}
}
}
}
@Composable
internal fun CodexMainBalance(balance: ServiceBalance) {
val plan = balance.extras["plan"]?.takeIf { it.isNotBlank() }?.let {
when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } }
} ?: "—"
val primaryLabel = formatCodexPrimaryLabel(
balance.extras["primary.label"],
weeklyLabel = stringResource(R.string.window_every_week),
monthlyLabel = stringResource(R.string.window_every_month),
usageLabel = stringResource(R.string.window_usage)
)
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = formatAmount(balance.amount),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "%",
style = MaterialTheme.typography.titleLarge,
color = inkMuted(),
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 6.dp, start = 2.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.service_used_label, primaryLabel),
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.service_plan_suffix, plan),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
}
}
/**
* 把 Codex API 返回的 primary label 转成本地化短标签。
* 默认中文标签保持测试兼容;UI 调用方传入 stringResource 结果。
*/
internal fun formatCodexPrimaryLabel(
raw: String?,
weeklyLabel: String = "Weekly",
monthlyLabel: String = "Monthly",
usageLabel: String = "Usage"
): String = when (raw?.lowercase()) {
"5h" -> "5h"
"7d", "weekly", "每周", weeklyLabel -> weeklyLabel
"30d", "monthly", "每月", monthlyLabel -> monthlyLabel
"usage" -> usageLabel
null -> "5h"
else -> raw ?: "5h"
}
@Composable
internal fun CodexUsageWindows(balance: ServiceBalance) {
val extras = balance.extras
val windowCount = extras.keys
.mapNotNull { key -> key.removePrefix("window_").substringBefore('.').toIntOrNull() }
.distinct()
.maxOrNull()?.plus(1) ?: 0
val weeklyLabel = stringResource(R.string.window_every_week)
val monthlyLabel = stringResource(R.string.window_every_month)
val usageLabel = stringResource(R.string.window_usage)
val sparkTitle = stringResource(R.string.window_spark)
data class UiWindow(val label: String, val isSpark: Boolean, val remainingPct: Int?, val resetAt: Long?)
val windows = (0 until windowCount).map { i ->
val rawLabel = extras["window_$i.label"] ?: "usage"
val label = normalizeWindowLabel(
rawLabel,
weeklyLabel = weeklyLabel,
monthlyLabel = monthlyLabel,
usageLabel = usageLabel
)
UiWindow(
label = label,
isSpark = extras["window_$i.group"] == "SPARK",
remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull(),
resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
)
}
// 主模型(普通 Codex)窗口独占全部进度行;Spark 独立限额是次要通道,只留一行小字摘要
val mainWindows = windows.filter { !it.isSpark }
val sparkWindows = windows.filter { it.isSpark }
// 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签,仅看主模型窗口)
val fiveHourLabel = stringResource(R.string.window_5h)
val has5h = mainWindows.any { isFiveHourLabel(it.label, fiveHourLabel, stringResource(R.string.window_5h_short)) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (windows.isEmpty()) {
CompactUsageRowEmpty(label = stringResource(R.string.usage_window_title_plain), resetInSec = null)
} else {
// 如果没有 5h 窗口,在顶部插入一个空的 5h 槽位(保留位置,等恢复后自动填充)
if (!has5h) {
CompactUsageRowEmpty(label = fiveHourLabel, resetInSec = null)
}
mainWindows.forEach { w ->
val resetSec = w.resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
if (w.remainingPct != null) {
CompactUsageRow(label = w.label, pct = (100 - w.remainingPct).coerceIn(0, 100), resetInSec = resetSec)
} else {
CompactUsageRowEmpty(label = w.label, resetInSec = resetSec)
}
}
// Spark 次要限额:单行摘要,不与主模型争夺视觉焦点
if (sparkWindows.isNotEmpty()) {
val summary = sparkWindows.joinToString(" · ") { w ->
val used = w.remainingPct?.let { (100 - it).coerceIn(0, 100) }
if (used != null) "${w.label} $used%" else "${w.label} —"
}
Text(
text = "$sparkTitle · $summary",
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
}
}
@Composable
internal fun OllamaMainBalance(balance: ServiceBalance) {
val plan = balance.extras["plan"]?.takeIf { it.isNotBlank() }?.let {
when (it.lowercase()) { "pro" -> "Pro"; "max" -> "Max"; "free" -> "Free"; else -> it }
} ?: "—"
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = formatAmount(balance.amount),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "%",
style = MaterialTheme.typography.titleLarge,
color = inkMuted(),
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 6.dp, start = 2.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.service_5h_used),
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.service_plan_suffix, plan),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp)
)
}
}
@Composable
internal fun OllamaUsageWindows(balance: ServiceBalance) {
val extras = balance.extras
val sessionPct = extras["session.pct"]?.toFloatOrNull()
val weeklyPct = extras["weekly.pct"]?.toFloatOrNull()
val sessionResetAt = extras["session.resetAt"]?.toLongOrNull()
val weeklyResetAt = extras["weekly.resetAt"]?.toLongOrNull()
val weeklyLabel = stringResource(R.string.window_every_week)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (sessionPct != null) {
CompactUsageRow(
label = stringResource(R.string.window_5h_short),
pct = sessionPct.toInt().coerceIn(0, 100),
resetInSec = sessionResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
)
} else {
CompactUsageRowEmpty(label = stringResource(R.string.window_5h_short), resetInSec = null)
}
if (weeklyPct != null) {
CompactUsageRow(
label = weeklyLabel,
pct = weeklyPct.toInt().coerceIn(0, 100),
resetInSec = weeklyResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
)
} else {
CompactUsageRowEmpty(label = weeklyLabel, resetInSec = null)
}
}
}
// ── Shared UI components ──
@Composable
internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) {
val durationText = rememberDurationText()
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.weight(1f)
)
Text(
text = "—",
style = MaterialTheme.typography.bodyMedium,
color = inkMuted()
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { 0f },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp)),
color = inkMuted().copy(alpha = 0.3f),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Butt
)
if (resetInSec != null && resetInSec > 0) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
}
@Composable
internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) {
val durationText = rememberDurationText()
val pctValue = pct.coerceIn(0, 100).toFloat()
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.weight(1f)
)
Text(
text = "$pct%",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = when {
pct >= 80 -> MaterialTheme.colorScheme.error
pct >= 50 -> StatusOrange
else -> MaterialTheme.colorScheme.onSurface
}
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { pctValue / 100f },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp)),
color = when {
pctValue >= 80f -> MaterialTheme.colorScheme.error
pctValue >= 50f -> StatusOrange
else -> StrawberryPink
},
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Butt
)
if (resetInSec != null && resetInSec > 0) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
}
/** 当前语言环境的时长标签("天 / 小时 / 分")。 */
@Composable
private fun rememberDurationText(): DurationText = DurationText(
day = stringResource(R.string.format_day),
hour = stringResource(R.string.format_hour),
minute = stringResource(R.string.format_minute)
)
// ── Dashboard utility functions ──
internal fun DashboardCardUi.statusBadgeStyle(): StatusStyle = when {
credentialState == CredentialStatus.State.NOT_CONFIGURED ->
StatusStyle(R.string.status_not_configured, StatusLevel.WARNING)
lastFetchError != null ->
StatusStyle(R.string.status_refresh_failed, StatusLevel.ERROR)
credentialState == CredentialStatus.State.EXPIRED ->
StatusStyle(R.string.status_expired, StatusLevel.ERROR)
credentialState == CredentialStatus.State.WARNING ->
StatusStyle(R.string.status_relogin, StatusLevel.WARNING)
cachedBalance == null ->
StatusStyle(R.string.status_waiting, StatusLevel.INFO)
else ->
StatusStyle(R.string.status_normal, StatusLevel.OK)
}
/**
* Sub2API 副标题:显示登录方式(邮箱登录 / API 登录),而不是笼统的「自托管面板 · 用户级 sk- Key 余额」。
* 依据 UsageDetail/余额 extras 里的 auth_mode(由 Repository 写入):
* panel = 面板 Token(邮箱/账号登录面板)
* apikey = 仅配置了 sk- API Key
* 无数据时回退到通用描述。
*/
@Composable
internal fun sub2AuthModeText(card: DashboardCardUi): String {
val balance = card.displayBalance
val mode = balance?.extras?.get("auth_mode")
return when (mode) {
"panel" -> stringResource(R.string.sub2_auth_panel)
"apikey" -> stringResource(R.string.sub2_auth_apikey)
else -> stringResource(R.string.service_desc_sub2api)
}
}
/**
* Trae / WorkBuddy 卡片副标题:显示订阅状态(不再展示登录方式)。
*
* 取自 extras 的订阅字段(付费用户 / 订阅中),与详情页「账户信息」区同一数据源。
* 无缓存余额或字段缺失时返回 null,调用方回落到 [secondaryLineRes] 的通用描述。
*/
@Composable
internal fun subscriptionLine(card: DashboardCardUi): String? {
// 仅 Trae / WorkBuddy 使用本函数;其余服务回落通用描述
if (card.service != ServiceType.TRAE && card.service != ServiceType.WORKBUDDY) return null
val extras = card.displayBalance?.extras ?: return null
// WorkBuddy:订阅 / 付费状态
if ("is_paid_user" in extras || "subscription" in extras) {
val isPaid = extras["is_paid_user"] == "true"
val isSubscribed = extras["subscription"] == "true"
return when {
isSubscribed -> stringResource(R.string.extra_value_subscription_active)
isPaid -> stringResource(R.string.extra_value_paid_user)
else -> stringResource(R.string.extra_value_free_user)
}
}
// Trae:计费模式(积分计费 / 美元计费),无则不显示
val billing = when {
extras["is_credits_billing"] == "true" -> stringResource(R.string.extra_value_billing_credits)
extras["is_dollar_usage_billing"] == "true" -> stringResource(R.string.extra_value_billing_dollar)
else -> null
}
if (billing != null) {
return stringResource(R.string.extra_label_billing_mode_desc, billing)
}
// 字段完全缺失时返回 null,回落通用描述
return null
}
/** 服务副标题(资源 ID,由 UI 层解析)。 */
@StringRes
internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) {
ServiceType.DEEPSEEK -> R.string.service_desc_deepseek
ServiceType.OPENCODE_GO -> R.string.service_desc_opencode_go
ServiceType.COMMANDCODE_GO -> R.string.service_desc_commandcode_go
ServiceType.CODEX -> R.string.service_desc_codex
ServiceType.OLLAMA -> R.string.service_desc_ollama
ServiceType.TRAE -> R.string.service_desc_trae
ServiceType.WORKBUDDY -> R.string.service_desc_workbuddy
ServiceType.SUB2API -> R.string.service_desc_sub2api
}
/**
* 底部更新时间/错误文案(UiText 形式,UI 层按当前语言解析)。
* 错误信息由 ViewModel 按 RepositoryError 类型映射为本地化资源。
*/
/** 截断 UiText 到最长 60 字符(仅对 Dynamic 原始文本生效;Resource 长度由资源控制)。 */
private fun truncateUiText(text: UiText): UiText = when (text) {
is UiText.Dynamic -> UiText.Dynamic(text.value.take(60) + if (text.value.length > 60) "…" else "")
else -> text
}
internal fun footerText(card: DashboardCardUi): UiText {
val error = card.lastFetchError
if (error != null) {
// 截断保护:错误信息最长展示 60 字符(以 UiText 形式保留,UI 层按当前语言解析)
return UiText.Resource(R.string.dashboard_error_prefix, listOf(truncateUiText(error)))
}
val fetchedAt = card.cachedBalance?.fetchedAt
?: return UiText.Resource(R.string.footer_never_fetched)
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
val now = System.currentTimeMillis()
val diffMin = (now - fetchedAt) / 60_000
val timeStr = sdf.format(Date(fetchedAt))
return when {
diffMin < 1 -> UiText.Resource(R.string.footer_just_updated)
diffMin < 60 -> UiText.Resource(R.string.footer_minutes_ago, listOf(diffMin))
diffMin < 1440 -> UiText.Resource(R.string.footer_hours_ago, listOf(diffMin / 60))
else -> UiText.Resource(R.string.footer_updated_at, listOf(timeStr))
}
}
/**
* 用量卡片(OCGO / CCGO)底部刷新时间文案,规则与 [footerText] 完全一致:
* <1 分钟"刚刚更新"、<1 小时"X 分钟前"、<24 小时"X 小时前"、更早显示 "MM-dd HH:mm 更新"。
*/
@Composable
internal fun usageUpdatedAtText(updatedAt: Long): String {
val now = System.currentTimeMillis()
val diffMin = (now - updatedAt) / 60_000
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
return when {
diffMin < 1 -> stringResource(R.string.footer_just_updated)
diffMin < 60 -> stringResource(R.string.footer_minutes_ago, diffMin)
diffMin < 1440 -> stringResource(R.string.footer_hours_ago, diffMin / 60)
else -> stringResource(R.string.footer_updated_at, sdf.format(Date(updatedAt)))
}
}
// ── Sub2API 首页卡片:余额 + 日/周/月/本月 用量汇总 ──
/**
* 首页 Sub2API 卡主体。
*
* 布局:
* ① 余额区——「余额」小标签 + 大号数字 + 单位(带品牌色圆点的左侧强调条)
* ② 用量区——「今日 / 近7天」两块等宽磁贴,每块显示 请求次数 与 Token 数
*
* 磁贴等宽 + 两行固定结构,保证 360dp 窄屏不换行、不同周期数值长度不一会错位。
*/
@Composable
internal fun Sub2ApiBalanceWithUsage(balance: ServiceBalance) {
Column(modifier = Modifier.fillMaxWidth()) {
Sub2ApiBalanceHero(balance)
Spacer(modifier = Modifier.height(10.dp))
Sub2ApiUsageTiles(balance)
}
}
/** 余额区:小标签 + 大数字 + 单位。 */
@Composable
internal fun Sub2ApiMainBalance(balance: ServiceBalance, modifier: Modifier = Modifier) {
Sub2ApiBalanceHero(balance, modifier)
}
@Composable
private fun Sub2ApiBalanceHero(balance: ServiceBalance, modifier: Modifier = Modifier) {
// 「余额 357.06 USD」单行排列:小标签 + 大数字 + 单位
Row(verticalAlignment = Alignment.Bottom, modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.sub2_balance_label),
style = MaterialTheme.typography.labelMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 6.dp),
maxLines = 1
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatAmount(balance.amount),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = balance.unit,
style = MaterialTheme.typography.titleSmall,
color = inkMuted(),
modifier = Modifier.padding(bottom = 4.dp),
maxLines = 1
)
}
}
/** 用量区:今日 / 近7天 两块等宽磁贴。 */
@Composable
private fun Sub2ApiUsageTiles(balance: ServiceBalance) {
val detail = balance.sub2UsageDetail
val windows = remember(detail) { buildSub2SummaryWindows(detail) }
if (windows.isEmpty()) {
Text(
text = stringResource(R.string.sub2_home_no_usage),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
return
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
windows.forEach { w ->
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.Center
) {
// 第一行:周期名 + 消耗金额
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = stringResource(w.labelRes),
style = MaterialTheme.typography.labelSmall,
color = inkMuted(),
maxLines = 1
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.sub2_home_spend, w.cost),
style = MaterialTheme.typography.labelSmall,
color = inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(3.dp))
Text(
text = w.tokens,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
/** 聚合 dailyUsage 计算各窗口汇总行文案;无数据返回空列表。 */
private fun buildSub2SummaryWindows(detail: Sub2UsageDetail?): List<Sub2WindowSummary> {
val days = detail?.dailyUsage.orEmpty()
if (days.isEmpty()) return emptyList()
val today = LocalDate.now()
fun sum(from: LocalDate): Triple<Long, Long, Double> {
var req = 0L; var tok = 0L; var cost = 0.0
for (d in days) {
val date = runCatching { LocalDate.parse(d.date) }.getOrNull() ?: continue
if (!date.isBefore(from) && !date.isAfter(today)) {
req += d.requests; tok += d.totalTokens; cost += d.cost
}
}
return Triple(req, tok, cost)
}
// 首页仅显示 日/周 两个周期(近30天/本月 明细在详情页时间窗口里查看)
val windows = listOf(R.string.sub2_window_today to today, R.string.sub2_window_7d to today.minusDays(6))
return windows.map { (labelRes, from) ->
val (req, tok, cost) = sum(from)
val tokens = when {
tok >= 1_000_000_000 -> (tok / 1_000_000_000.0).formatLocale("%.1fB")
tok >= 1_000_000 -> (tok / 1_000_000.0).formatLocale("%.1fM")
tok >= 1_000 -> (tok / 1_000.0).formatLocale("%.1fK")
else -> tok.toString()
}
Sub2WindowSummary(labelRes = labelRes, requests = req, cost = cost, tokens = tokens)
}
}
/** 首页周期汇总(今日 / 近7天)。 */
private data class Sub2WindowSummary(
@androidx.annotation.StringRes val labelRes: Int,
val requests: Long,
val cost: Double,
val tokens: String
)
private fun Double.formatLocale(pattern: String): String =
String.format(java.util.Locale.getDefault(), pattern, this)

View File

@ -53,24 +53,30 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.local.ChartBucket
import com.rainy.token.data.local.ChartGranularity
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.Date
import java.util.Locale
// 图表用色板(每个模型一个颜色,12 色 Material Design 500 色阶,足够区分 12 个模型)
internal val modelColors = listOf(
Color(0xFFF44336), // Red
Color(0xFFE91E63), // Pink
Color(0xFFC9436C), // Pink(玫瑰主色)
Color(0xFF9C27B0), // Purple
Color(0xFF673AB7), // Deep Purple
Color(0xFF3F51B5), // Indigo
@ -83,11 +89,11 @@ internal val modelColors = listOf(
Color(0xFF607D8B) // Blue Grey
)
// Token 堆叠色板:命中缓存(浅粉·顶)、未命中输入(粉·中)、输出(深粉·底)
// Token 堆叠色板:命中缓存(浅樱粉·顶)、未命中输入(玫瑰粉·中)、输出(深玫瑰·底)
internal val tokenColors = listOf(
Color(0xFFFFD1DC), // cache hit — 浅粉(上面)
Color(0xFFFF85A2), // cache miss input — 粉色(中间)
Color(0xFFE91E63) // output — 深粉(下面)
Color(0xFFFFE3EC), // cache hit — 浅樱粉(上面)
Color(0xFFFFA3BC), // cache miss input — 玫瑰粉(中间)
Color(0xFFC9436C) // output — 深玫瑰(主色,下面)
)
@OptIn(ExperimentalMaterial3Api::class)
@ -97,13 +103,14 @@ fun UsageChartScreen(
viewModel: UsageChartViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
var granularityExpanded by remember { mutableStateOf(false) }
var modelExpanded by remember { mutableStateOf(false) }
var showCustomDayPicker by remember { mutableStateOf(false) }
var showCustomMonthPicker by remember { mutableStateOf(false) }
var showCustomRangeStart by remember { mutableStateOf(false) }
var showCustomRangeEnd by remember { mutableStateOf(false) }
var customRangeStartMs by remember { mutableStateOf(0L) }
var customRangeStartDate by remember { mutableStateOf<LocalDate?>(null) }
var showCostDetail by remember { mutableStateOf(false) }
var showReqDetail by remember { mutableStateOf(false) }
var showTokenDetail by remember { mutableStateOf(false) }
@ -112,9 +119,9 @@ fun UsageChartScreen(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text("统计图表") },
title = { Text(stringResource(R.string.title_chart)) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") }
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) }
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
@ -122,16 +129,17 @@ fun UsageChartScreen(
) { innerPadding ->
if (state.loading) {
Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
Text("加载中…", color = InkMuted)
Text(stringResource(R.string.common_loading), color = InkMuted)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// 容器自身宽度 > 700dp 时图表并排
val wideEnough = maxWidth > 700.dp
val hPad = if (wideEnough) 24.dp else 16.dp
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = hPad),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// ─── 顶部控制:粒度 + 模型 ───
item {
Row(
@ -142,13 +150,13 @@ fun UsageChartScreen(
// 粒度下拉
Box {
TextButton(onClick = { granularityExpanded = true }) {
Text(state.granularity.label, color = StrawberryPink)
Text(stringResource(state.granularity.labelRes), color = StrawberryPink)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
ChartGranularity.entries.forEach { g ->
DropdownMenuItem(
text = { Text(g.label) },
text = { Text(stringResource(g.labelRes)) },
onClick = {
granularityExpanded = false
when (g) {
@ -165,15 +173,15 @@ fun UsageChartScreen(
Spacer(Modifier.weight(1f))
// 模型多选下拉
Box {
val modelLabel = if (state.selectedModels.isEmpty()) "全部模型"
else "${state.selectedModels.size} 个模型"
val modelLabel = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models)
else stringResource(R.string.model_count, state.selectedModels.size)
TextButton(onClick = { modelExpanded = true }) {
Text(modelLabel, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(modelExpanded, { modelExpanded = false }) {
DropdownMenuItem(
text = { Text("全部模型", fontWeight = FontWeight.Bold) },
text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
onClick = { modelExpanded = false; viewModel.selectAllModels() }
)
state.allModels.forEach { model ->
@ -195,102 +203,181 @@ fun UsageChartScreen(
}
}
// ─── 图表1:消耗金额(按模型堆叠柱状图) ───
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
ChartCard(
title = "消耗金额 (USD)",
summary = "$${String.format(Locale.US, "%.4f", costTotal)}",
onSummaryClick = { showCostDetail = true }
) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
stackSelector = { bucket ->
models.mapIndexedNotNull { idx, model ->
val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null
v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size]
if (wideEnough) {
// 面板够宽:前两张图表并排,第三张独立
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
val reqTotal = state.buckets.sumOf { it.totalRequests }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(modifier = Modifier.weight(1f)) {
ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
stackSelector = { bucket ->
models.mapIndexedNotNull { idx, model ->
val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null
v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size]
}
},
stackLabels = { bucket ->
models.mapNotNull { model ->
if (bucket.byModel[model] != null) model else null
}
},
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
},
useUtc8 = state.useUtc8
)
}
},
stackLabels = { bucket ->
models.mapNotNull { model ->
if (bucket.byModel[model] != null) model else null
}
},
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
}
Box(modifier = Modifier.weight(1f)) {
ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) {
LineChart(
buckets = state.buckets,
valueSelector = { it.totalRequests.toFloat() },
lineColor = StrawberryPink,
formatValue = { context.getString(R.string.count_times, it.toInt()) },
granularity = state.granularity,
useUtc8 = state.useUtc8
)
}
}
}
}
// 第三张图表独立一行
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
stackSelector = { bucket ->
listOfNotNull(
bucket.outputTokens.toDouble() to tokenColors[2],
bucket.inputTokens.toDouble() to tokenColors[1],
bucket.cacheHitTokens.toDouble() to tokenColors[0]
)
},
stackLabels = { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
useUtc8 = state.useUtc8
)
}
ChartLegend(
items = listOf(
stringResource(R.string.chart_stack_input) to tokenColors[1],
stringResource(R.string.chart_stack_cache) to tokenColors[0],
stringResource(R.string.chart_stack_output) to tokenColors[2]
)
)
}
}
} else {
// 手机:三张图表纵向堆叠(原逻辑不变)
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
ChartCard(
title = stringResource(R.string.chart_cost),
summary = "$${String.format(Locale.US, "%.4f", costTotal)}",
onSummaryClick = { showCostDetail = true }
) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
stackSelector = { bucket ->
models.mapIndexedNotNull { idx, model ->
val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null
v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size]
}
},
stackLabels = { bucket ->
models.mapNotNull { model ->
if (bucket.byModel[model] != null) model else null
}
},
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
},
useUtc8 = state.useUtc8
)
}
}
// ─── 图表2:API 请求次数 ───
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard(
title = "API 请求次数",
summary = "${reqTotal}次",
onSummaryClick = { showReqDetail = true }
) {
LineChart(
buckets = state.buckets,
valueSelector = { it.totalRequests.toFloat() },
lineColor = StrawberryPink,
formatValue = { "${it.toInt()}次" },
granularity = state.granularity
)
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard(
title = stringResource(R.string.chart_requests),
summary = stringResource(R.string.count_times, reqTotal),
onSummaryClick = { showReqDetail = true }
) {
LineChart(
buckets = state.buckets,
valueSelector = { it.totalRequests.toFloat() },
lineColor = StrawberryPink,
formatValue = { context.getString(R.string.count_times, it.toInt()) },
granularity = state.granularity,
useUtc8 = state.useUtc8
)
}
}
}
// ─── 图表3:Token 消耗(底→顶:输出 / 未命中输入 / 命中缓存) ───
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard(
title = "Token 消耗",
summary = formatTokenComma(tokTotal),
onSummaryClick = { showTokenDetail = true }
) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
stackSelector = { bucket ->
listOfNotNull(
bucket.outputTokens.toDouble() to tokenColors[2], // 输出 — 底
bucket.inputTokens.toDouble() to tokenColors[1], // 输入(未命中) — 中
bucket.cacheHitTokens.toDouble() to tokenColors[0] // 命中缓存 — 顶
)
},
stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard(
title = stringResource(R.string.chart_tokens),
summary = formatTokenComma(tokTotal),
onSummaryClick = { showTokenDetail = true }
) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
stackSelector = { bucket ->
listOfNotNull(
bucket.outputTokens.toDouble() to tokenColors[2],
bucket.inputTokens.toDouble() to tokenColors[1],
bucket.cacheHitTokens.toDouble() to tokenColors[0]
)
},
stackLabels = { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
useUtc8 = state.useUtc8
)
}
ChartLegend(
items = listOf(
stringResource(R.string.chart_stack_input) to tokenColors[1],
stringResource(R.string.chart_stack_cache) to tokenColors[0],
stringResource(R.string.chart_stack_output) to tokenColors[2]
)
)
}
ChartLegend(
items = listOf(
"输入(未命中)" to tokenColors[1],
"命中缓存" to tokenColors[0],
"输出" to tokenColors[2]
)
)
}
item { Spacer(Modifier.height(32.dp)) }
}
}
}
// ─── 自定义日期选择器 ───
if (showCustomDayPicker) {
DateTimePickerDialog(
title = "选择日期",
title = stringResource(R.string.date_select_day),
onConfirm = { ms ->
val utc = java.time.ZoneOffset.UTC
val dayStart = java.time.Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate()
.atStartOfDay(utc).toInstant().toEpochMilli()
viewModel.setCustomDayRange(dayStart, dayStart + 86400_000L - 1)
viewModel.setCustomDay(ms.toUtcLocalDate())
showCustomDayPicker = false
},
onDismiss = { showCustomDayPicker = false }
@ -298,9 +385,9 @@ fun UsageChartScreen(
}
if (showCustomMonthPicker) {
DateTimePickerDialog(
title = "选择月份(任意一天)",
title = stringResource(R.string.date_select_month),
onConfirm = { ms ->
viewModel.setCustomMonth(ms)
viewModel.setCustomMonth(ms.toUtcLocalDate())
showCustomMonthPicker = false
},
onDismiss = { showCustomMonthPicker = false }
@ -308,9 +395,9 @@ fun UsageChartScreen(
}
if (showCustomRangeStart) {
DateTimePickerDialog(
title = "开始日期",
title = stringResource(R.string.date_pick_start),
onConfirm = { ms ->
customRangeStartMs = ms
customRangeStartDate = ms.toUtcLocalDate()
showCustomRangeStart = false
showCustomRangeEnd = true
},
@ -319,15 +406,14 @@ fun UsageChartScreen(
}
if (showCustomRangeEnd) {
DateTimePickerDialog(
title = "结束日期",
title = stringResource(R.string.date_pick_end),
onConfirm = { ms ->
val utc = java.time.ZoneOffset.UTC
val startDay = java.time.Instant.ofEpochMilli(customRangeStartMs).atOffset(utc).toLocalDate()
.atStartOfDay(utc).toInstant().toEpochMilli()
val endDay = java.time.Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate()
.atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
viewModel.setCustomRange(startDay, endDay)
showCustomRangeEnd = false
val startDate = customRangeStartDate ?: return@DateTimePickerDialog
val endDate = ms.toUtcLocalDate()
if (!endDate.isBefore(startDate)) {
viewModel.setCustomRange(startDate, endDate)
showCustomRangeEnd = false
}
},
onDismiss = { showCustomRangeEnd = false }
)
@ -338,7 +424,7 @@ fun UsageChartScreen(
if (showCostDetail) {
AlertDialog(
onDismissRequest = { showCostDetail = false },
title = { Text("消费明细", fontWeight = FontWeight.Bold) },
title = { Text(stringResource(R.string.chart_detail_cost), fontWeight = FontWeight.Bold) },
text = {
Column {
models.forEach { model ->
@ -353,13 +439,13 @@ fun UsageChartScreen(
}
}
},
confirmButton = { TextButton(onClick = { showCostDetail = false }) { Text("关闭") } }
confirmButton = { TextButton(onClick = { showCostDetail = false }) { Text(stringResource(R.string.action_close)) } }
)
}
if (showReqDetail) {
AlertDialog(
onDismissRequest = { showReqDetail = false },
title = { Text("调用次数明细", fontWeight = FontWeight.Bold) },
title = { Text(stringResource(R.string.chart_detail_requests), fontWeight = FontWeight.Bold) },
text = {
Column {
models.forEach { model ->
@ -367,13 +453,13 @@ fun UsageChartScreen(
if (total > 0) {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(model, style = MaterialTheme.typography.bodySmall)
Text("${total}次", color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.count_times, total), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
}
}
}
}
},
confirmButton = { TextButton(onClick = { showReqDetail = false }) { Text("关闭") } }
confirmButton = { TextButton(onClick = { showReqDetail = false }) { Text(stringResource(R.string.action_close)) } }
)
}
if (showTokenDetail) {
@ -382,24 +468,24 @@ fun UsageChartScreen(
val outTotal = state.buckets.sumOf { it.outputTokens }
AlertDialog(
onDismissRequest = { showTokenDetail = false },
title = { Text("Token 明细", fontWeight = FontWeight.Bold) },
title = { Text(stringResource(R.string.chart_detail_tokens), fontWeight = FontWeight.Bold) },
text = {
Column {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("命中缓存", style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.chart_stack_cache), style = MaterialTheme.typography.bodySmall)
Text(formatTokenComma(hitTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
}
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("输入(未命中)", style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.chart_stack_input), style = MaterialTheme.typography.bodySmall)
Text(formatTokenComma(missTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
}
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("输出", style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.chart_stack_output), style = MaterialTheme.typography.bodySmall)
Text(formatTokenComma(outTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
}
}
},
confirmButton = { TextButton(onClick = { showTokenDetail = false }) { Text("关闭") } }
confirmButton = { TextButton(onClick = { showTokenDetail = false }) { Text(stringResource(R.string.action_close)) } }
)
}
}
@ -409,6 +495,9 @@ fun UsageChartScreen(
// 图表卡片容器
// ═══════════════════════════════════════════
private fun Long.toUtcLocalDate(): LocalDate =
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()
@Composable
internal fun ChartCard(
title: String,
@ -455,20 +544,22 @@ internal fun StackedBarChart(
tooltipReversed: Boolean = false,
formatValue: (Double) -> String,
granularity: ChartGranularity,
legendItems: List<Pair<String, Color>> = emptyList()
legendItems: List<Pair<String, Color>> = emptyList(),
useUtc8: Boolean = false
) {
if (buckets.isEmpty()) {
Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall)
return
}
var tooltipBucket by remember { mutableStateOf<ChartBucket?>(null) }
// key = buckets:切换时间范围/筛选后重置 tooltip,避免残留旧数据
var tooltipBucket by remember(buckets) { mutableStateOf(buckets.lastOrNull()) }
val density = LocalDensity.current
val d = density.density
val barCount = buckets.size
val chartHPx = 160f * d
val labelHPx = 20f * d
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(1.0)
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(0.0)
val refTop = niceCeil(maxVal)
val refHalf = refTop / 2.0
// 以 refTop 为满刻度,柱子不会触顶
@ -512,7 +603,7 @@ internal fun StackedBarChart(
drawRect(color, Offset(x, yBase - h), Size(barW, h))
yBase -= h
}
val (label, show) = formatChartTime(bucket.ts, granularity, i, barCount)
val (label, show) = formatChartTime(bucket.ts, granularity, i, barCount, useUtc8)
if (show) {
drawContext.canvas.nativeCanvas.drawText(
label, x + barW / 2, chartHPx + labelHPx - 4f * d,
@ -552,8 +643,8 @@ internal fun StackedBarChart(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(Modifier.padding(8.dp)) {
Text(formatChartTime(bucket.ts, granularity, 0, 1).first, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
Text("合计: ${formatValue(total)}", style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
Text(formatChartTime(bucket.ts, granularity, 0, 1, useUtc8).first, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.chart_total_label, formatValue(total)), style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
val labels = stackLabels(bucket)
val items = stackSelector(bucket).withIndex()
val display = if (tooltipReversed) items.toList().reversed() else items.toList()
@ -584,19 +675,21 @@ internal fun LineChart(
valueSelector: (ChartBucket) -> Float,
lineColor: Color,
formatValue: (Float) -> String,
granularity: ChartGranularity
granularity: ChartGranularity,
useUtc8: Boolean = false
) {
if (buckets.isEmpty()) {
Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall)
return
}
var tooltipBucket by remember { mutableStateOf<ChartBucket?>(null) }
// key = buckets:切换时间范围/筛选后重置 tooltip,避免残留旧数据
var tooltipBucket by remember(buckets) { mutableStateOf(buckets.lastOrNull()) }
val density = LocalDensity.current
val d = density.density
val barCount = buckets.size
val chartHPx = 140f * d
val labelHPx = 20f * d
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(1f)
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(0f)
val refTop = niceCeil(maxVal.toDouble()).toFloat()
val refHalf = refTop / 2f
val scale = refTop
@ -641,7 +734,7 @@ internal fun LineChart(
drawCircle(lineColor, radius = 3f * d, center = p)
}
buckets.forEachIndexed { i, b ->
val (label, show) = formatChartTime(b.ts, granularity, i, barCount)
val (label, show) = formatChartTime(b.ts, granularity, i, barCount, useUtc8)
if (show) {
drawContext.canvas.nativeCanvas.drawText(
label, i * barAreaPx + barAreaPx / 2, chartHPx + labelHPx - 4f * d,
@ -675,7 +768,7 @@ internal fun LineChart(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(Modifier.padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatChartTime(bucket.ts, granularity, 0, 1).first, style = MaterialTheme.typography.labelSmall)
Text(formatChartTime(bucket.ts, granularity, 0, 1, useUtc8).first, style = MaterialTheme.typography.labelSmall)
Text(formatValue(valueSelector(bucket)), style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
}
}
@ -711,34 +804,39 @@ internal fun ChartLegend(items: List<Pair<String, Color>>) {
// ═══════════════════════════════════════════
/** 返回格式化标签 + 是否应该绘制 */
private fun formatChartTime(ts: Long, granularity: ChartGranularity, index: Int, total: Int): Pair<String, Boolean> {
val show = when (granularity) {
ChartGranularity.LAST_24H_HOURLY ->
index == 0 || index == total - 1 || index % 3 == 0 // 每3小时
ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY ->
index == 0 || index == total - 1 // 只显示首尾小时
ChartGranularity.LAST_7D_DAILY,
ChartGranularity.THIS_MONTH_DAILY,
ChartGranularity.CUSTOM_MONTH_DAILY,
ChartGranularity.CUSTOM_RANGE_DAILY ->
index == 0 || index == total - 1 // 只显示首尾日期
else -> true // 5小时全显示
}
val fmt = when (granularity) {
ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY -> SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
else -> SimpleDateFormat("MM/dd", Locale.getDefault()).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
}
return fmt.format(Date(ts)) to show
private fun formatChartTime(ts: Long, granularity: ChartGranularity, index: Int, total: Int, useUtc8: Boolean = false): Pair<String, Boolean> {
val show = when (granularity) {
ChartGranularity.LAST_12H_10MIN ->
index == 0 || index == total - 1 || index % 12 == 0 // 每2小时(每12个10分钟桶)
ChartGranularity.LAST_24H_HOURLY ->
index == 0 || index == total - 1 || index % 3 == 0 // 每3小时
ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY ->
index == 0 || index == total - 1 // 只显示首尾小时
ChartGranularity.LAST_7D_DAILY,
ChartGranularity.LAST_30D_DAILY,
ChartGranularity.THIS_MONTH_DAILY,
ChartGranularity.CUSTOM_MONTH_DAILY,
ChartGranularity.CUSTOM_RANGE_DAILY ->
index == 0 || index == total - 1 // 只显示首尾日期
else -> true // 5小时全显示
}
val tz = if (useUtc8) java.util.TimeZone.getTimeZone("Asia/Shanghai") else java.util.TimeZone.getTimeZone("UTC")
val fmt = when (granularity) {
ChartGranularity.LAST_12H_10MIN,
ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY -> SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
timeZone = tz
}
else -> SimpleDateFormat("MM/dd", Locale.getDefault()).apply {
timeZone = tz
}
}
return fmt.format(Date(ts)) to show
}
private fun formatTokenCount(tokens: Long): String = when {
@ -760,9 +858,15 @@ private fun niceCeil(v: Double): Double {
val normalized = v / magnitude // 1..10
val nice = when {
normalized <= 1.0 -> 1.0
normalized <= 1.15 -> 1.15 // 15% 余量
normalized <= 1.25 -> 1.25 // 25% 余量
normalized <= 1.5 -> 1.5 // 50% 余量
normalized <= 2.0 -> 2.0
normalized <= 2.5 -> 2.5
normalized <= 3.0 -> 3.0
normalized <= 4.0 -> 4.0
normalized <= 5.0 -> 5.0
normalized <= 7.5 -> 7.5
else -> 10.0
}
return nice * magnitude

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.rainy.token.data.local.ChartAggregator
import com.rainy.token.data.local.ChartBucket
import com.rainy.token.data.local.ChartGranularity
import com.rainy.token.data.local.ChartSettingsStore
import com.rainy.token.data.local.UsageCache
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
@ -14,8 +15,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import javax.inject.Inject
import javax.inject.Provider
@ -23,22 +26,37 @@ import javax.inject.Provider
@HiltViewModel
class UsageChartViewModel @Inject constructor(
private val cacheProvider: Provider<UsageCache>,
private val credentialRepository: CredentialRepository
private val credentialRepository: CredentialRepository,
private val chartSettingsStore: ChartSettingsStore
) : ViewModel() {
private val _state = MutableStateFlow(ChartUiState())
private val _state = MutableStateFlow(ChartUiState(useUtc8 = false))
val state: StateFlow<ChartUiState> = _state.asStateFlow()
private var workspaceIdOverride: String? = null
private var loadGeneration = 0
/** 是否允许自动降级到更大时间窗口(仅初始加载时允许,用户手动选粒度后关闭) */
private var allowFallback = true
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 load() 前调用。 */
fun setWorkspace(wid: String) {
workspaceIdOverride = wid
loadGeneration++
allowFallback = true
_state.value = ChartUiState(useUtc8 = _state.value.useUtc8)
load()
}
private suspend fun workspaceId(): String? {
workspaceIdOverride?.let { return it }
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
}
init {
load()
}
// 不在 init 自动加载,由 Composable 层显式触发 load()(OCGO)或 setWorkspace()(CCGO)
fun setGranularity(g: ChartGranularity) {
allowFallback = false // 用户手动选择,关闭自动降级
_state.update { it.copy(granularity = g) }
load()
}
@ -62,9 +80,23 @@ class UsageChartViewModel @Inject constructor(
load()
}
fun toggleUtc8() {
_state.update { it.copy(useUtc8 = !it.useUtc8) }
viewModelScope.launch {
chartSettingsStore.setUseUtc8(_state.value.useUtc8)
}
load()
}
fun load() {
viewModelScope.launch {
// 首次加载时同步读取 UTC 偏好,避免用默认值 false 加载后再切换
if (!_state.value.useUtc8Initialized) {
val saved = chartSettingsStore.useUtc8Flow.first()
_state.update { it.copy(useUtc8 = saved, useUtc8Initialized = true) }
}
val wid = workspaceId() ?: return@launch
val genAtStart = loadGeneration
val cache = cacheProvider.get()
val allModels = cache.getDistinctModels(wid)
@ -76,18 +108,22 @@ class UsageChartViewModel @Inject constructor(
val filtered = if (selected.isEmpty()) records
else records.filter { it.model in selected }
val offsetHours = if (_state.value.useUtc8) 8 else 0
val buckets = when (_state.value.granularity) {
ChartGranularity.LAST_12H_10MIN ->
ChartAggregator.aggregate10Min(filtered, offsetHours)
ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY ->
ChartAggregator.aggregateHourly(filtered)
ChartAggregator.aggregateHourly(filtered, offsetHours)
ChartGranularity.THIS_MONTH_DAILY,
ChartGranularity.LAST_7D_DAILY,
ChartGranularity.LAST_30D_DAILY,
ChartGranularity.CUSTOM_MONTH_DAILY,
ChartGranularity.CUSTOM_RANGE_DAILY ->
ChartAggregator.aggregateDaily(filtered)
ChartAggregator.aggregateDaily(filtered, offsetHours)
}
// 填充可能空缺的小时/天桶
@ -96,79 +132,106 @@ class UsageChartViewModel @Inject constructor(
val result = if (_state.value.granularity == ChartGranularity.LAST_5H_HOURLY)
filled.takeLast(5) else filled
// 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果
if (loadGeneration != genAtStart) return@launch
// 自动降级:当前粒度无实际数据且允许降级 → 尝试更大窗口
val hasData = result.any { it.totalRequests > 0 }
if (allowFallback && !hasData) {
val fallbackTo = when (_state.value.granularity) {
ChartGranularity.LAST_5H_HOURLY -> ChartGranularity.LAST_12H_10MIN
ChartGranularity.LAST_12H_10MIN -> ChartGranularity.LAST_7D_DAILY
ChartGranularity.LAST_7D_DAILY -> ChartGranularity.THIS_MONTH_DAILY
else -> null
}
if (fallbackTo != null) {
_state.update { it.copy(granularity = fallbackTo) }
load() // 递归重新加载
return@launch
}
}
// 降级后或用户手动选择 → 关闭降级,正常显示
allowFallback = false
// 图例数据源:只保留当前显示范围内实际出现过的模型
val rangeModels = result.flatMap { it.byModel.keys }.distinct().sorted()
_state.update {
it.copy(
buckets = result,
allModels = allModels,
rangeModels = rangeModels,
loading = false
)
}
}
}
fun setCustomDayRange(from: Long, to: Long) {
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY) }
_customDayRange = from to to
fun setCustomDay(date: LocalDate) {
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY, customDay = date) }
_customDay = date
load()
}
fun setCustomMonth(monthStartMs: Long) {
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY) }
_customMonthStart = monthStartMs
fun setCustomMonth(monthDate: LocalDate) {
val month = monthDate.withDayOfMonth(1)
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY, customMonth = month) }
_customMonth = month
load()
}
fun setCustomRange(from: Long, to: Long) {
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY) }
_customRange = from to to
fun setCustomRange(fromDate: LocalDate, toDate: LocalDate) {
if (toDate.isBefore(fromDate)) return
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY, customRange = fromDate to toDate) }
_customRange = fromDate to toDate
load()
}
private var _customDayRange: Pair<Long, Long> = 0L to 0L
private var _customMonthStart: Long = 0L
private var _customRange: Pair<Long, Long> = 0L to 0L
private var _customDay: LocalDate? = null
private var _customMonth: LocalDate? = null
private var _customRange: Pair<LocalDate, LocalDate>? = null
private fun timeRangeFor(g: ChartGranularity): Pair<Long?, Long?> {
val now = System.currentTimeMillis()
val utc = ZoneOffset.UTC
val today = Instant.ofEpochMilli(now).atOffset(utc).toLocalDate()
val todayMidnight = today.atStartOfDay(utc).toInstant().toEpochMilli()
val zoneOffset = if (_state.value.useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val today = Instant.ofEpochMilli(now).atOffset(zoneOffset).toLocalDate()
val todayMidnight = today.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
return when (g) {
ChartGranularity.LAST_12H_10MIN -> now - 12 * 3600_000L to null
ChartGranularity.LAST_5H_HOURLY -> now - 5 * 3600_000L to null
ChartGranularity.LAST_24H_HOURLY -> now - 24 * 3600_000L to null
ChartGranularity.TODAY_HOURLY -> todayMidnight to todayMidnight + 86400_000L - 1
ChartGranularity.YESTERDAY_HOURLY -> todayMidnight - 86400_000L to todayMidnight - 1
ChartGranularity.LAST_7D_DAILY -> {
todayMidnight - 6 * 86400_000L to todayMidnight + 86400_000L - 1
}
ChartGranularity.CUSTOM_DAY_HOURLY -> {
if (_customDayRange.first == 0L) todayMidnight to todayMidnight + 86400_000L - 1
else _customDayRange.first to _customDayRange.second
}
ChartGranularity.THIS_MONTH_DAILY -> {
val firstOfMonth = today.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli()
val lastOfMonth = today.withDayOfMonth(today.lengthOfMonth())
.atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
firstOfMonth to lastOfMonth
}
ChartGranularity.LAST_7D_DAILY -> todayMidnight - 6 * 86400_000L to todayMidnight + 86400_000L - 1
ChartGranularity.LAST_30D_DAILY -> todayMidnight - 29 * 86400_000L to todayMidnight + 86400_000L - 1
ChartGranularity.CUSTOM_DAY_HOURLY -> dayRange(_customDay ?: today, zoneOffset)
ChartGranularity.THIS_MONTH_DAILY -> monthRange(today.withDayOfMonth(1), zoneOffset)
ChartGranularity.CUSTOM_MONTH_DAILY -> {
if (_customMonthStart == 0L) null to null
else {
val targetMonth = Instant.ofEpochMilli(_customMonthStart).atOffset(utc).toLocalDate()
.withDayOfMonth(1)
val firstOfMonth = targetMonth.atStartOfDay(utc).toInstant().toEpochMilli()
val lastOfMonth = targetMonth.withDayOfMonth(targetMonth.lengthOfMonth())
.atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
firstOfMonth to lastOfMonth
}
val month = _customMonth ?: return null to null
monthRange(month, zoneOffset)
}
ChartGranularity.CUSTOM_RANGE_DAILY -> {
if (_customRange.first == 0L) null to null
else _customRange.first to _customRange.second
val range = _customRange ?: return null to null
val from = range.first.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
val to = range.second.plusDays(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli() - 1
from to to
}
}
}
private fun dayRange(date: LocalDate, zoneOffset: ZoneOffset): Pair<Long, Long> {
val from = date.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
val to = date.plusDays(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli() - 1
return from to to
}
private fun monthRange(month: LocalDate, zoneOffset: ZoneOffset): Pair<Long, Long> {
val firstDay = month.withDayOfMonth(1)
val from = firstDay.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
val to = firstDay.plusMonths(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli() - 1
return from to to
}
private fun fillGaps(
buckets: List<ChartBucket>,
granularity: ChartGranularity,
@ -177,6 +240,7 @@ class UsageChartViewModel @Inject constructor(
): List<ChartBucket> {
if (buckets.isEmpty() || from == null) return buckets
val interval = when (granularity) {
ChartGranularity.LAST_12H_10MIN -> 600_000L // 10分钟
ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY,
@ -187,7 +251,9 @@ class UsageChartViewModel @Inject constructor(
val end = to ?: (System.currentTimeMillis() / interval * interval)
val result = mutableListOf<ChartBucket>()
val map = buckets.associateBy { it.ts }
var t = from / interval * interval
// 日粒度:from 已由 timeRangeFor 按当前时区对齐到午夜,直接用
// 小时粒度:from 是滑动窗口起点,需截断到 interval 边界
var t = if (interval == 86_400_000L) from else from / interval * interval
while (t <= end) {
result.add(map[t] ?: ChartBucket(ts = t, totalCost = 0, totalRequests = 0,
inputTokens = 0, cacheHitTokens = 0, outputTokens = 0))
@ -201,6 +267,14 @@ data class ChartUiState(
val loading: Boolean = true,
val granularity: ChartGranularity = ChartGranularity.LAST_5H_HOURLY,
val allModels: List<String> = emptyList(),
/** 当前显示范围内实际出现过的模型(图例只显示这些) */
val rangeModels: List<String> = emptyList(),
/** 自定义选择的回显值(选择器打开时预填用) */
val customDay: LocalDate? = null,
val customMonth: LocalDate? = null,
val customRange: Pair<LocalDate, LocalDate>? = null,
val selectedModels: Set<String> = emptySet(), // empty = all
val buckets: List<ChartBucket> = emptyList()
val buckets: List<ChartBucket> = emptyList(),
val useUtc8: Boolean = false,
val useUtc8Initialized: Boolean = false
)

View File

@ -38,6 +38,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -45,17 +46,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.local.UsageRecord
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.Date
import java.util.Locale
@ -67,15 +71,19 @@ import java.util.Locale
@Composable
fun UsageDataScreen(
onBack: () -> Unit,
viewModel: UsageDataViewModel = hiltViewModel()
viewModel: UsageDataViewModel = hiltViewModel(),
autoLoad: Boolean = true
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// OCGO 首次加载(CCGO 由 setWorkspace 触发,不重复 load)
LaunchedEffect(Unit) {
if (autoLoad) viewModel.loadData()
}
var timeMenuExpanded by remember { mutableStateOf(false) }
var modelMenuExpanded by remember { mutableStateOf(false) }
var showStartPicker by remember { mutableStateOf(false) }
var showEndPicker by remember { mutableStateOf(false) }
var customStartMs by remember { mutableStateOf(0L) }
var customEndMs by remember { mutableStateOf(0L) }
var showRangePicker by remember { mutableStateOf(false) }
var showCustomDayPicker by remember { mutableStateOf(false) }
var showCustomMonthPicker by remember { mutableStateOf(false) }
var rawRecord by remember { mutableStateOf<UsageRecord?>(null) }
@ -85,8 +93,8 @@ fun UsageDataScreen(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text("详细数据") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } },
title = { Text(stringResource(R.string.title_raw_data)) },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
@ -97,7 +105,7 @@ fun UsageDataScreen(
// 时间筛选
Box {
TextButton(onClick = { timeMenuExpanded = true }) {
Text(state.timeFilter.label, color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Text(stringResource(state.timeFilter.labelRes), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(timeMenuExpanded, { timeMenuExpanded = false }) {
@ -106,18 +114,18 @@ fun UsageDataScreen(
TimeFilter.Today, TimeFilter.Yesterday,
TimeFilter.Last7Days, TimeFilter.Last30Days, TimeFilter.ThisMonth
).forEach { f ->
DropdownMenuItem(text = { Text(f.label) }, onClick = {
DropdownMenuItem(text = { Text(stringResource(f.labelRes)) }, onClick = {
timeMenuExpanded = false; viewModel.setTimeFilter(f)
})
}
DropdownMenuItem(text = { Text("自定义天") }, onClick = {
DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_day)) }, onClick = {
timeMenuExpanded = false; showCustomDayPicker = true
})
DropdownMenuItem(text = { Text("自定义月") }, onClick = {
DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_month)) }, onClick = {
timeMenuExpanded = false; showCustomMonthPicker = true
})
DropdownMenuItem(text = { Text("自定义时间") }, onClick = {
timeMenuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L))
DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_range)) }, onClick = {
timeMenuExpanded = false; showRangePicker = true
})
}
}
@ -125,13 +133,13 @@ fun UsageDataScreen(
Spacer(Modifier.weight(1f))
// 模型筛选
Box {
val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个"
val label = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) else stringResource(R.string.model_count_short, state.selectedModels.size)
TextButton(onClick = { modelMenuExpanded = true }) {
Text(label, color = StrawberryPink, fontSize = 13.sp)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(modelMenuExpanded, { modelMenuExpanded = false }) {
DropdownMenuItem(text = { Text("全部模型", fontWeight = FontWeight.Bold) },
DropdownMenuItem(text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
onClick = { modelMenuExpanded = false; viewModel.selectAllModels() })
state.allModels.forEach { m ->
DropdownMenuItem(text = {
@ -145,17 +153,14 @@ fun UsageDataScreen(
}
}
// 自定义时间范围
if (state.timeFilter is TimeFilter.Custom && (state.timeFilter as TimeFilter.Custom).from == 0L) {
CustomTimeRangeRow(customStartMs, customEndMs,
{ showStartPicker = true }, { showEndPicker = true },
{ viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) })
Text("所有时间按 UTC+0 计算", style = MaterialTheme.typography.bodySmall, color = inkMuted())
// 自定义时间范围:确认选择后显示 UTC 说明
if (state.timeFilter is TimeFilter.Custom) {
Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
// ─── 记录数 + 分页 ───
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) {
Text("${state.totalRecords} 条记录", style = MaterialTheme.typography.bodySmall, color = inkMuted())
Text(stringResource(R.string.usage_record_count, state.totalRecords), style = MaterialTheme.typography.bodySmall, color = inkMuted())
if (state.totalPages > 1) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { viewModel.prevPage() }, enabled = state.currentPage > 1) {
@ -180,7 +185,7 @@ fun UsageDataScreen(
val p = pageInput.toIntOrNull()
if (p != null) { viewModel.goToPage(p); pageInput = "" }
}) {
Text("跳转", color = StrawberryPink, fontSize = 12.sp)
Text(stringResource(R.string.action_jump), color = StrawberryPink, fontSize = 12.sp)
}
}
}
@ -190,27 +195,27 @@ fun UsageDataScreen(
Card(Modifier.fillMaxWidth(), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
HeaderCell("时间", Modifier.weight(1.5f))
HeaderCell("模型", Modifier.weight(1.8f))
HeaderCell("输入(缓存)", Modifier.weight(1.5f))
HeaderCell("输出", Modifier.weight(1.2f))
HeaderCell("成本", Modifier.weight(1.0f))
HeaderCell(stringResource(R.string.header_time), Modifier.weight(1.5f), false)
HeaderCell(stringResource(R.string.header_model), Modifier.weight(1.8f), false)
HeaderCell(stringResource(R.string.header_input_cache), Modifier.weight(1.5f), false)
HeaderCell(stringResource(R.string.header_output), Modifier.weight(1.2f), false)
HeaderCell(stringResource(R.string.header_cost), Modifier.weight(1.0f), false)
}
}
// ─── 记录列表 ───
if (state.loading) {
Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) {
Text("加载中…", color = inkMuted())
Text(stringResource(R.string.common_loading), color = inkMuted())
}
} else if (state.records.isEmpty()) {
Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) {
Text("暂无数据", color = inkMuted())
Text(stringResource(R.string.common_no_data), color = inkMuted())
}
} else {
LazyColumn(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(1.dp)) {
itemsIndexed(state.records, key = { _, r -> r.id }) { idx, record ->
DataRecordRow(record, idx, { rawRecord = it })
DataRecordRow(record, idx, { rawRecord = it }, false)
}
item { Spacer(Modifier.height(8.dp)) }
}
@ -218,75 +223,103 @@ fun UsageDataScreen(
}
// 日期选择器
if (showCustomDayPicker) DateTimePickerDialog("选择日期", { ms ->
val utc = ZoneOffset.UTC; val ds = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli()
viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1)); showCustomDayPicker = false
}, { showCustomDayPicker = false })
if (showCustomMonthPicker) DateTimePickerDialog("选择月份(任意一天)", { ms ->
val utc = ZoneOffset.UTC; val ld = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate()
val msStart = ld.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli()
val msEnd = ld.withDayOfMonth(ld.lengthOfMonth()).plusDays(1).atStartOfDay(utc).toInstant().toEpochMilli() - 1
viewModel.setTimeFilter(TimeFilter.Custom(msStart, msEnd)); showCustomMonthPicker = false
}, { showCustomMonthPicker = false })
if (showStartPicker) DateTimePickerDialog("开始时间", { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
if (showEndPicker) DateTimePickerDialog("结束时间", { ms ->
viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, ms)); showEndPicker = false
}, { showEndPicker = false })
if (showCustomDayPicker) DateOnlyPickerDialog(
title = stringResource(R.string.date_select_day),
initialDate = (state.timeFilter as? TimeFilter.Custom)?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
onConfirm = { date ->
val ds = date.toUtcStartOfDayMillis()
viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1))
showCustomDayPicker = false
},
onDismiss = { showCustomDayPicker = false }
)
if (showCustomMonthPicker) DateOnlyPickerDialog(
title = stringResource(R.string.date_select_month),
initialDate = (state.timeFilter as? TimeFilter.Custom)?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
onConfirm = { date ->
val utc = ZoneOffset.UTC
val msStart = date.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli()
val msEnd = date.withDayOfMonth(date.lengthOfMonth()).plusDays(1).atStartOfDay(utc).toInstant().toEpochMilli() - 1
viewModel.setTimeFilter(TimeFilter.Custom(msStart, msEnd))
showCustomMonthPicker = false
},
onDismiss = { showCustomMonthPicker = false }
)
if (showRangePicker) {
val current = state.timeFilter as? TimeFilter.Custom
DateRangePickerDialog(
title = stringResource(R.string.date_pick_range),
initialStart = current?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
initialEnd = current?.to?.takeIf { current.from > 0 }?.toUtcLocalDate(),
onConfirm = { from, to ->
val fromMs = from.toUtcStartOfDayMillis()
val toMs = to.toUtcStartOfDayMillis()
viewModel.setTimeFilter(TimeFilter.Custom(fromMs, toMs + 86400_000L - 1))
showRangePicker = false
},
onDismiss = { showRangePicker = false }
)
}
// 原始数据弹窗
rawRecord?.let { record ->
AlertDialog(
onDismissRequest = { rawRecord = null },
title = { Text("原始数据", fontWeight = FontWeight.Bold) },
title = { Text(stringResource(R.string.raw_record_title), fontWeight = FontWeight.Bold) },
text = {
Column(Modifier.horizontalScroll(rememberScrollState())) {
RawField("ID", record.id)
RawField("时间", formatUtcTime(record.timeCreated))
RawField("模型", record.model)
RawField(stringResource(R.string.header_time), formatUtcTime(record.timeCreated))
RawField(stringResource(R.string.header_model), record.model)
RawField("Provider", record.provider)
RawField("输入 Token", "%,d".format(Locale.US, record.inputTokens))
RawField("输出 Token", "%,d".format(Locale.US, record.outputTokens))
RawField("推理 Token", "%,d".format(Locale.US, record.reasoningTokens))
RawField("缓存读取", "%,d".format(Locale.US, record.cacheReadTokens))
RawField("缓存写入(5m)", "%,d".format(Locale.US, record.cacheWrite5mTokens))
RawField("缓存写入(1h)", "%,d".format(Locale.US, record.cacheWrite1hTokens))
RawField("成本(USD)", "$${String.format(Locale.US, "%.6f", record.costUsd)}")
RawField("成本(Raw)", "${record.cost}")
RawField(stringResource(R.string.usage_input_tokens), "%,d".format(Locale.US, record.inputTokens))
RawField(stringResource(R.string.usage_output_tokens), "%,d".format(Locale.US, record.outputTokens))
RawField(stringResource(R.string.usage_reasoning_tokens), "%,d".format(Locale.US, record.reasoningTokens))
RawField(stringResource(R.string.stat_cache_read), "%,d".format(Locale.US, record.cacheReadTokens))
RawField(stringResource(R.string.stat_cache_write_5m), "%,d".format(Locale.US, record.cacheWrite5mTokens))
RawField(stringResource(R.string.stat_cache_write_1h), "%,d".format(Locale.US, record.cacheWrite1hTokens))
RawField(stringResource(R.string.raw_cost_usd), "$${String.format(Locale.US, "%.6f", record.costUsd)}")
RawField(stringResource(R.string.raw_cost_raw), "${record.cost}")
RawField("KeyId", record.keyId)
RawField("SessionId", record.sessionId)
}
},
confirmButton = { TextButton(onClick = { rawRecord = null }) { Text("关闭") } }
confirmButton = { TextButton(onClick = { rawRecord = null }) { Text(stringResource(R.string.action_close)) } }
)
}
}
}
@Composable
private fun HeaderCell(text: String, modifier: Modifier) {
Text(text, modifier = modifier, fontSize = 12.sp, fontWeight = FontWeight.Bold,
private fun HeaderCell(text: String, modifier: Modifier, expanded: Boolean = false) {
Text(text, modifier = modifier,
fontSize = if (expanded) 14.sp else 12.sp,
fontWeight = FontWeight.Bold,
color = inkMuted(), textAlign = TextAlign.Center)
}
@Composable
private fun DataRecordRow(record: UsageRecord, idx: Int, onClick: (UsageRecord) -> Unit) {
private fun DataRecordRow(record: UsageRecord, idx: Int, onClick: (UsageRecord) -> Unit, expanded: Boolean = false) {
val bg = if (idx % 2 == 0) Color.Transparent else MaterialTheme.colorScheme.surface.copy(alpha = 0.3f)
val rowPad = if (expanded) 12.dp else 8.dp
Card(Modifier.fillMaxWidth().clickable { onClick(record) },
RoundedCornerShape(0.dp), CardDefaults.cardColors(containerColor = bg)) {
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = rowPad),
verticalAlignment = Alignment.CenterVertically) {
DataCell(formatUtcTimeShort(record.timeCreated), Modifier.weight(1.5f))
DataCell(record.model, Modifier.weight(1.8f))
DataCell(formatInputWithCache(record), Modifier.weight(1.5f))
DataCell("%,d".format(Locale.US, record.outputTokens), Modifier.weight(1.2f))
DataCell("$${String.format(Locale.US, "%.4f", record.costUsd)}", Modifier.weight(1.0f))
DataCell(formatUtcTimeShort(record.timeCreated), Modifier.weight(1.5f), expanded)
DataCell(record.model, Modifier.weight(1.8f), expanded)
DataCell(formatInputWithCache(record), Modifier.weight(1.5f), expanded)
DataCell("%,d".format(Locale.US, record.outputTokens), Modifier.weight(1.2f), expanded)
DataCell("$${String.format(Locale.US, "%.4f", record.costUsd)}", Modifier.weight(1.0f), expanded)
}
}
}
@Composable
private fun DataCell(text: String, modifier: Modifier) {
Text(text, modifier = modifier, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface,
private fun DataCell(text: String, modifier: Modifier, expanded: Boolean = false) {
Text(text, modifier = modifier,
fontSize = if (expanded) 13.sp else 11.sp,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, maxLines = 1)
}
@ -298,6 +331,12 @@ private fun RawField(label: String, value: String) {
}
}
private fun Long.toUtcLocalDate(): LocalDate =
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()
private fun LocalDate.toUtcStartOfDayMillis(): Long =
atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
private fun formatInputWithCache(record: UsageRecord): String {
val input = record.inputTokens + record.cacheReadTokens
val cache = record.cacheReadTokens

View File

@ -27,22 +27,36 @@ class UsageDataViewModel @Inject constructor(
private val _state = MutableStateFlow(UsageDataState())
val state: StateFlow<UsageDataState> = _state.asStateFlow()
init {
loadModelsAndData()
private var workspaceIdOverride: String? = null
private var loadGeneration = 0
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadModelsAndData() 前调用。 */
fun setWorkspace(wid: String) {
workspaceIdOverride = wid
loadGeneration++
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
_state.value = UsageDataState()
loadData()
}
// 不在 init 自动加载,由 Composable 层显式触发 loadData()(OCGO)或 setWorkspace()(CCGO)
private fun loadModelsAndData() {
viewModelScope.launch {
val wid = workspaceId() ?: return@launch
val genAtStart = loadGeneration
val cache = cacheProvider.get()
val models = cache.getDistinctModels(wid)
_state.update { it.copy(allModels = models) }
// 如果 loadGeneration 已经变化,丢弃这次结果
if (loadGeneration != genAtStart) return@launch
// 紧接着加载数据
loadDataInternal(cache, wid)
}
}
private suspend fun workspaceId(): String? {
workspaceIdOverride?.let { return it }
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
}
@ -51,8 +65,11 @@ class UsageDataViewModel @Inject constructor(
viewModelScope.launch {
_state.update { it.copy(loading = true) }
val wid = workspaceId() ?: return@launch
val genAtStart = loadGeneration
val cache = cacheProvider.get()
loadDataInternal(cache, wid)
// 如果 loadGeneration 已经变化,丢弃这次结果
if (loadGeneration != genAtStart) return@launch
}
}

View File

@ -2,13 +2,17 @@ package com.rainy.token.ui.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@ -25,6 +29,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
@ -33,21 +41,28 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.local.ChartGranularity
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
@ -59,35 +74,67 @@ import java.util.Locale
/**
* 用量详情页 —— 统计图表。
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun UsageDetailScreen(
onBack: () -> Unit,
onOpenOverview: () -> Unit,
onOpenData: () -> Unit = {},
viewModel: UsageChartViewModel = hiltViewModel()
viewModel: UsageChartViewModel = hiltViewModel(),
clearViewModel: UsageViewModel? = null // non-null = CCGO, 显示清除按钮
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
// OCGO 首次加载(CCGO 由 NavHost 的 setWorkspace 触发,不重复 load)
LaunchedEffect(Unit) {
if (clearViewModel == null) viewModel.load()
}
var showClearDialog by remember { mutableStateOf(false) }
var clearCountdown by remember { mutableStateOf(0) }
var granularityExpanded by remember { mutableStateOf(false) }
var modelExpanded by remember { mutableStateOf(false) }
var showCustomDayPicker by remember { mutableStateOf(false) }
var showCustomMonthPicker by remember { mutableStateOf(false) }
var showCustomRangeStart by remember { mutableStateOf(false) }
var showCustomRangeEnd by remember { mutableStateOf(false) }
var customRangeStartMs by remember { mutableStateOf(0L) }
var showCustomRangePicker by remember { mutableStateOf(false) }
var showCostDetail by remember { mutableStateOf(false) }
var showReqDetail by remember { mutableStateOf(false) }
var showTokenDetail by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("用量详情") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } },
title = {
val serviceLabel = if (clearViewModel == null) "OCGO" else "CommandCode"
Column {
Text(
stringResource(R.string.title_usage_detail),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
serviceLabel,
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } },
actions = {
if (clearViewModel != null) {
TextButton(onClick = { showClearDialog = true }) {
Text(stringResource(R.string.action_clear), color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
}
}
TextButton(onClick = onOpenData) {
Text("详细数据", color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Text(stringResource(R.string.action_view_raw_data), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
@ -96,125 +143,267 @@ fun UsageDetailScreen(
) { innerPadding ->
if (state.loading) {
Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
Text("加载中…", color = InkMuted)
Text(stringResource(R.string.common_loading), color = InkMuted)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) {
Box {
TextButton(onClick = { granularityExpanded = true }) {
Text(state.granularity.label, color = StrawberryPink)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
ChartGranularity.entries.forEach { g ->
DropdownMenuItem(text = { Text(g.label) }, onClick = {
granularityExpanded = false
when (g) {
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true
else -> viewModel.setGranularity(g)
}
})
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// 容器自身宽度 > 700dp 时图表并排(自适应父容器,而非全局窗口)
val wideEnough = maxWidth > 700.dp
val hPad = if (wideEnough) 24.dp else 16.dp
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = hPad),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) {
Box {
TextButton(onClick = { granularityExpanded = true }) {
Text(stringResource(state.granularity.labelRes), color = StrawberryPink)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
ChartGranularity.entries.forEach { g ->
DropdownMenuItem(text = { Text(stringResource(g.labelRes)) }, onClick = {
granularityExpanded = false
when (g) {
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangePicker = true
else -> viewModel.setGranularity(g)
}
})
}
}
}
}
Text("UTC+0", style = MaterialTheme.typography.bodySmall, color = InkMuted)
Spacer(Modifier.weight(1f))
Box {
val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型"
TextButton(onClick = { modelExpanded = true }) {
Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
TextButton(onClick = {
viewModel.toggleUtc8()
scope.launch {
snackbarHostState.showSnackbar(
if (state.useUtc8) context.getString(R.string.msg_utc8_on) else context.getString(R.string.msg_utc0_on),
duration = SnackbarDuration.Short
)
}
}) {
Text(
if (state.useUtc8) "UTC+8」" else "UTC+0」",
style = MaterialTheme.typography.bodySmall,
color = if (state.useUtc8) StrawberryPink else InkMuted,
fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal
)
Text(
stringResource(R.string.usage_click_to_switch),
style = MaterialTheme.typography.bodySmall,
color = InkMuted.copy(alpha = 0.5f),
fontWeight = FontWeight.Normal
)
}
DropdownMenu(modelExpanded, { modelExpanded = false }) {
DropdownMenuItem(
text = { Text("全部模型", fontWeight = FontWeight.Bold) },
onClick = { modelExpanded = false; viewModel.selectAllModels() })
state.allModels.forEach { model ->
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(state.selectedModels.isEmpty() || model in state.selectedModels, { viewModel.toggleModel(model) })
Text(model, modifier = Modifier.padding(start = 4.dp))
}
}, onClick = { viewModel.toggleModel(model) })
Spacer(Modifier.weight(1f))
Box {
val label = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) else stringResource(R.string.model_count, state.selectedModels.size)
TextButton(onClick = { modelExpanded = true }) {
Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(modelExpanded, { modelExpanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
onClick = { modelExpanded = false; viewModel.selectAllModels() })
state.allModels.forEach { model ->
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(state.selectedModels.isEmpty() || model in state.selectedModels, { viewModel.toggleModel(model) })
Text(model, modifier = Modifier.padding(start = 4.dp))
}
}, onClick = { viewModel.toggleModel(model) })
}
}
}
}
}
}
// 图表1
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(state.buckets,
{ it.totalCost.toDouble() / 100_000_000.0 },
{ bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } },
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m -> m to modelColors[idx % modelColors.size] })
if (wideEnough) {
// 面板够宽:前两张图表并排
item {
val models = state.rangeModels
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
val reqTotal = state.buckets.sumOf { it.totalRequests }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(modifier = Modifier.weight(1f)) {
ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(state.buckets,
{ it.totalCost.toDouble() / 100_000_000.0 },
{ bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } },
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
},
useUtc8 = state.useUtc8
)
}
}
Box(modifier = Modifier.weight(1f)) {
ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) {
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { context.getString(R.string.count_times, it.toInt()) }, state.granularity, useUtc8 = state.useUtc8)
}
}
}
}
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(state.buckets,
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
{ listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
useUtc8 = state.useUtc8
)
}
ChartLegend(listOf(stringResource(R.string.chart_stack_input) to tokenColors[1], stringResource(R.string.chart_stack_cache) to tokenColors[0], stringResource(R.string.chart_stack_output) to tokenColors[2]))
}
} else {
// 窄面板:三张图表纵向堆叠
item {
val models = state.rangeModels
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(state.buckets,
{ it.totalCost.toDouble() / 100_000_000.0 },
{ bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } },
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
},
useUtc8 = state.useUtc8
)
}
}
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) {
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { context.getString(R.string.count_times, it.toInt()) }, state.granularity, useUtc8 = state.useUtc8)
}
}
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(state.buckets,
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
{ listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
useUtc8 = state.useUtc8
)
}
ChartLegend(listOf(stringResource(R.string.chart_stack_input) to tokenColors[1], stringResource(R.string.chart_stack_cache) to tokenColors[0], stringResource(R.string.chart_stack_output) to tokenColors[2]))
}
}
}
// 图表2
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity)
item {
TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) {
Text("📋 " + stringResource(R.string.title_overview), color = StrawberryPink, fontWeight = FontWeight.Bold)
}
}
item { Spacer(Modifier.height(32.dp)) }
}
// 图表3
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(state.buckets,
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
{ listOf("输出", "输入(未命中)", "命中缓存") },
tooltipReversed = true,
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity)
}
ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
}
// 总统计入口
item {
TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) {
Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold)
}
}
item { Spacer(Modifier.height(32.dp)) }
}
}
// 自定义日期选择器
if (showCustomDayPicker) DateTimePickerDialog("选择日期", { ms ->
val utc = ZoneOffset.UTC; val ds = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli()
viewModel.setCustomDayRange(ds, ds + 86400_000L - 1); showCustomDayPicker = false }, { showCustomDayPicker = false })
if (showCustomMonthPicker) DateTimePickerDialog("选择月份(任意一天)", { viewModel.setCustomMonth(it); showCustomMonthPicker = false }, { showCustomMonthPicker = false })
if (showCustomRangeStart) DateTimePickerDialog("开始日期", { customRangeStartMs = it; showCustomRangeStart = false; showCustomRangeEnd = true }, { showCustomRangeStart = false })
if (showCustomRangeEnd) DateTimePickerDialog("结束日期", { ms ->
val utc = ZoneOffset.UTC; val sd = Instant.ofEpochMilli(customRangeStartMs).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli()
val ed = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
viewModel.setCustomRange(sd, ed); showCustomRangeEnd = false }, { showCustomRangeEnd = false })
// 详情弹窗
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
if (showCostDetail) ChartDetailDialog("消费明细", { showCostDetail = false }) {
if (showCustomDayPicker) DateOnlyPickerDialog(
title = stringResource(R.string.date_select_day),
initialDate = state.customDay,
onConfirm = { date ->
viewModel.setCustomDay(date)
showCustomDayPicker = false
},
onDismiss = { showCustomDayPicker = false }
)
if (showCustomMonthPicker) DateOnlyPickerDialog(
title = stringResource(R.string.date_select_month),
initialDate = state.customMonth,
onConfirm = { date ->
viewModel.setCustomMonth(date)
showCustomMonthPicker = false
},
onDismiss = { showCustomMonthPicker = false }
)
if (showCustomRangePicker) {
val currentRange = state.customRange
DateRangePickerDialog(
title = stringResource(R.string.date_pick_range),
initialStart = currentRange?.first,
initialEnd = currentRange?.second,
onConfirm = { from, to ->
viewModel.setCustomRange(from, to)
showCustomRangePicker = false
},
onDismiss = { showCustomRangePicker = false }
)
}
val models = state.rangeModels
if (showCostDetail) ChartDetailDialog(stringResource(R.string.chart_detail_cost), { showCostDetail = false }) {
models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.cost ?:0L }; if (t>0) DetailRow(model, "$${String.format(Locale.US, "%.4f", t/100_000_000.0)}") }
}
if (showReqDetail) ChartDetailDialog("调用次数明细", { showReqDetail = false }) {
models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.requests ?:0 }; if (t>0) DetailRow(model, "${t}次") }
if (showReqDetail) ChartDetailDialog(stringResource(R.string.chart_detail_requests), { showReqDetail = false }) {
models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.requests ?: 0 }; if (t>0) DetailRow(model, stringResource(R.string.count_times, t)) }
}
if (showTokenDetail) ChartDetailDialog("Token 明细", { showTokenDetail = false }) {
DetailRow("命中缓存", formatTokenComma(state.buckets.sumOf { it.cacheHitTokens }))
DetailRow("输入(未命中)", formatTokenComma(state.buckets.sumOf { it.inputTokens }))
DetailRow("输出", formatTokenComma(state.buckets.sumOf { it.outputTokens }))
if (showTokenDetail) ChartDetailDialog(stringResource(R.string.chart_detail_tokens), { showTokenDetail = false }) {
DetailRow(stringResource(R.string.chart_stack_cache), formatTokenComma(state.buckets.sumOf { it.cacheHitTokens }))
DetailRow(stringResource(R.string.chart_stack_input), formatTokenComma(state.buckets.sumOf { it.inputTokens }))
DetailRow(stringResource(R.string.chart_stack_output), formatTokenComma(state.buckets.sumOf { it.outputTokens }))
}
if (showClearDialog && clearViewModel != null) {
val cd = clearCountdown
androidx.compose.material3.AlertDialog(
onDismissRequest = { showClearDialog = false; clearCountdown = 0 },
title = { Text("⚠️ " + stringResource(R.string.dialog_clear_title), fontWeight = FontWeight.Bold) },
text = {
Column {
Text(stringResource(R.string.dialog_clear_body))
Spacer(Modifier.height(12.dp))
Text(stringResource(R.string.dialog_clear_confirm_question), fontWeight = FontWeight.SemiBold)
}
},
confirmButton = {
TextButton(
onClick = {
showClearDialog = false
clearCountdown = 0
clearViewModel?.clearAndResync()
onBack()
},
enabled = cd == 0
) {
Text(
if (cd > 0) stringResource(R.string.action_confirm_countdown, cd) else stringResource(R.string.action_confirm_clear),
color = if (cd == 0) MaterialTheme.colorScheme.error else InkMuted
)
}
},
dismissButton = {
TextButton(onClick = { showClearDialog = false; clearCountdown = 0 }) {
Text(stringResource(R.string.action_cancel))
}
}
)
LaunchedEffect(showClearDialog) {
if (!showClearDialog) return@LaunchedEffect
clearCountdown = 3
for (i in 3 downTo 1) {
kotlinx.coroutines.delay(1000)
clearCountdown = i - 1
}
}
}
}
}
@ -225,7 +414,7 @@ private fun ChartDetailDialog(title: String, onDismiss: () -> Unit, content: @Co
onDismissRequest = onDismiss,
title = { Text(title, fontWeight = FontWeight.Bold) },
text = { androidx.compose.foundation.layout.Column { content() } },
confirmButton = { TextButton(onClick = onDismiss) { Text("关闭") } })
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_close)) } })
}
@Composable private fun DetailRow(label: String, value: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), Arrangement.SpaceBetween) {
@ -255,14 +444,14 @@ internal fun CustomTimeRangeRow(
) {
TextButton(onClick = onPickStart, modifier = Modifier.weight(1f)) {
Text(
text = if (startMs > 0) "从 ${fmt.format(Date(startMs))}" else "开始时间",
text = if (startMs > 0) stringResource(R.string.range_from, fmt.format(Date(startMs))) else stringResource(R.string.date_start),
style = MaterialTheme.typography.bodySmall,
color = if (startMs > 0) StrawberryPink else InkMuted
)
}
TextButton(onClick = onPickEnd, modifier = Modifier.weight(1f)) {
Text(
text = if (endMs > 0) "至 ${fmt.format(Date(endMs))}" else "结束时间",
text = if (endMs > 0) stringResource(R.string.range_to, fmt.format(Date(endMs))) else stringResource(R.string.date_end),
style = MaterialTheme.typography.bodySmall,
color = if (endMs > 0) StrawberryPink else InkMuted
)
@ -270,12 +459,73 @@ internal fun CustomTimeRangeRow(
}
if (startMs > 0 && endMs > 0) {
TextButton(onClick = onApply) {
Text("应用自定义范围", color = StrawberryPink, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.action_apply_custom_range), color = StrawberryPink, fontWeight = FontWeight.Bold)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DateOnlyPickerDialog(
title: String,
initialDate: LocalDate? = null,
onConfirm: (LocalDate) -> Unit,
onDismiss: () -> Unit,
minDate: LocalDate? = null
) {
val utc = remember { ZoneOffset.UTC }
val minDateStartMs = remember(minDate) { minDate?.atStartOfDay(utc)?.toInstant()?.toEpochMilli() }
val selectableDates = remember(minDateStartMs) {
object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
return minDateStartMs == null || utcTimeMillis >= minDateStartMs
}
}
}
val dateState = rememberDatePickerState(
initialSelectedDateMillis = initialDate?.atStartOfDay(utc)?.toInstant()?.toEpochMilli(),
selectableDates = selectableDates
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
val millis = dateState.selectedDateMillis ?: return@TextButton
onConfirm(Instant.ofEpochMilli(millis).atOffset(utc).toLocalDate())
},
enabled = dateState.selectedDateMillis != null
) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
) {
Column {
Text(
text = title,
modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
if (minDate != null) {
Text(
text = stringResource(R.string.date_end_before_start, minDate.formatDateLabel()),
modifier = Modifier.padding(start = 24.dp, top = 8.dp, end = 24.dp),
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
}
DatePicker(state = dateState, title = {})
}
}
}
private fun LocalDate.formatDateLabel(): String =
String.format(Locale.US, "%04d-%02d-%02d", year, monthValue, dayOfMonth)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DateTimePickerDialog(
@ -302,24 +552,24 @@ internal fun DateTimePickerDialog(
onConfirm(epochMs)
}
}) {
Text("确定")
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
) {
Column {
DatePicker(state = dateState)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "时间 ${
String.format("%02d", timeState.hour)
}:${
text = stringResource(
R.string.time_hm,
String.format("%02d", timeState.hour),
String.format("%02d", timeState.minute)
}",
),
modifier = Modifier.padding(horizontal = 24.dp),
style = MaterialTheme.typography.titleMedium
)
TimePicker(state = timeState)
}
}
}
}

View File

@ -3,6 +3,8 @@ package com.rainy.token.ui.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -30,6 +32,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -37,42 +40,52 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.local.ModelStats
import com.rainy.token.data.local.OverviewStats
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.Date
import java.util.Locale
/**
* 总统计页 —— 总览、按模型、按天统计。
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun UsageOverviewScreen(
onBack: () -> Unit,
viewModel: UsageViewModel = hiltViewModel()
viewModel: UsageViewModel = hiltViewModel(),
autoLoad: Boolean = true
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// OCGO 首次加载(CCGO 由 setWorkspace 触发,不重复 load)
LaunchedEffect(Unit) {
if (autoLoad) viewModel.loadStats()
}
var menuExpanded by remember { mutableStateOf(false) }
var modelMenuExpanded by remember { mutableStateOf(false) }
var showStartPicker by remember { mutableStateOf(false) }
var showEndPicker by remember { mutableStateOf(false) }
var customStartMs by remember { mutableStateOf(0L) }
var customEndMs by remember { mutableStateOf(0L) }
var showRangePicker by remember { mutableStateOf(false) }
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text("总统计") },
title = { Text(stringResource(R.string.title_overview)) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") }
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) }
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
@ -86,7 +99,7 @@ fun UsageOverviewScreen(
item {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { menuExpanded = true }) {
Text(uiState.timeFilter.label, color = StrawberryPink, fontWeight = FontWeight.SemiBold)
Text(stringResource(uiState.timeFilter.labelRes), color = StrawberryPink, fontWeight = FontWeight.SemiBold)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
Text("UTC+0", style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(start = 4.dp))
@ -96,35 +109,32 @@ fun UsageOverviewScreen(
TimeFilter.Last7Days, TimeFilter.Last30Days, TimeFilter.ThisMonth
).forEach { filter ->
DropdownMenuItem(
text = { Text(filter.label) },
text = { Text(stringResource(filter.labelRes)) },
onClick = { menuExpanded = false; viewModel.setTimeFilter(filter) }
)
}
DropdownMenuItem(
text = { Text("自定义") },
onClick = { menuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L)) }
text = { Text(stringResource(R.string.time_custom)) },
onClick = { menuExpanded = false; showRangePicker = true }
)
}
}
}
if (uiState.timeFilter is TimeFilter.Custom) {
item {
CustomTimeRangeRow(customStartMs, customEndMs,
{ showStartPicker = true }, { showEndPicker = true },
{ viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) })
Text("所有时间按 UTC+0 计算", style = MaterialTheme.typography.bodySmall, color = inkMuted())
Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
// 总览
uiState.overview?.let { overview ->
item { OverviewCard(overview) }
item { OverviewCard(overview, false) }
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
item { CacheBreakdownCard(overview) }
}
}
// 按模型
if (uiState.modelStats.isNotEmpty()) {
item { SectionHeader("按模型统计") }
item { SectionHeader(stringResource(R.string.section_by_model)) }
items(uiState.modelStats, key = { it.model }) { ModelDetailRow(it) }
}
// 按天
@ -134,14 +144,14 @@ fun UsageOverviewScreen(
val shown = uiState.dailyStats.drop(startIdx).take(DAILY_PAGE_SIZE)
item {
Row(verticalAlignment = Alignment.CenterVertically) {
SectionHeader("按天统计(UTC+0)"); Spacer(Modifier.weight(1f))
SectionHeader(stringResource(R.string.section_by_day)); Spacer(Modifier.weight(1f))
Box {
TextButton(onClick = { modelMenuExpanded = true }) {
Text(uiState.modelFilter ?: "全部模型", color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Text(uiState.modelFilter ?: stringResource(R.string.common_all_models), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(modelMenuExpanded, { modelMenuExpanded = false }) {
DropdownMenuItem(text = { Text("全部模型", fontWeight = FontWeight.Bold) },
DropdownMenuItem(text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
onClick = { modelMenuExpanded = false; viewModel.setModelFilter(null) })
uiState.modelStats.forEach { stat ->
DropdownMenuItem(text = { Text(stat.model) },
@ -156,35 +166,65 @@ fun UsageOverviewScreen(
item {
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
TextButton(onClick = { viewModel.prevDailyPage() }, enabled = uiState.dailyPage > 1) {
Text("◀ 上一页", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) }
Text(stringResource(R.string.action_prev_page), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) }
Text("${uiState.dailyPage} / $totalPages", style = MaterialTheme.typography.bodySmall, color = inkMuted())
TextButton(onClick = { viewModel.nextDailyPage() }, enabled = uiState.dailyPage < totalPages) {
Text("下一页 ▶", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) }
Text(stringResource(R.string.action_next_page), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) }
}
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
if (showStartPicker) DateTimePickerDialog("开始时间", { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
if (showEndPicker) DateTimePickerDialog("结束时间", { customEndMs = it; showEndPicker = false }, { showEndPicker = false })
if (showRangePicker) {
val current = uiState.timeFilter as? TimeFilter.Custom
DateRangePickerDialog(
title = stringResource(R.string.date_pick_range),
initialStart = current?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
initialEnd = current?.to?.takeIf { current.from > 0 }?.toUtcLocalDate(),
onConfirm = { from, to ->
val fromMs = from.toUtcStartOfDayMillis()
val toMs = to.toUtcStartOfDayMillis()
viewModel.setTimeFilter(TimeFilter.Custom(fromMs, toMs + 86400_000L - 1))
showRangePicker = false
},
onDismiss = { showRangePicker = false }
)
}
}
}
@Composable private fun OverviewCard(overview: OverviewStats) {
@OptIn(ExperimentalLayoutApi::class)
@Composable private fun OverviewCard(overview: OverviewStats, isExpanded: Boolean = false) {
val inputTotal = overview.inputTokens + overview.cacheReadTokens
Card(Modifier.fillMaxWidth(), RoundedCornerShape(20.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(0.dp)) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
StatItem("输入 Token", formatTokenCount(inputTotal))
StatItem("输出 Token", formatTokenCount(overview.outputTokens))
StatItem("推理 Token", formatTokenCount(overview.reasoningTokens))
}
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
StatItem("总计", formatTokenCount(overview.totalTokens))
StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
StatItem("记录数", "${overview.totalCount}")
if (isExpanded) {
// 平板:FlowRow 横向排列 6 项统计
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
StatItem(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
StatItem(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens))
StatItem(stringResource(R.string.usage_reasoning_tokens), formatTokenCount(overview.reasoningTokens))
StatItem(stringResource(R.string.usage_total), formatTokenCount(overview.totalTokens))
StatItem(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
StatItem(stringResource(R.string.usage_records), "${overview.totalCount}")
}
} else {
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
StatItem(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
StatItem(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens))
StatItem(stringResource(R.string.usage_reasoning_tokens), formatTokenCount(overview.reasoningTokens))
}
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
StatItem(stringResource(R.string.usage_total), formatTokenCount(overview.totalTokens))
StatItem(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
StatItem(stringResource(R.string.usage_records), "${overview.totalCount}")
}
}
}
}
@ -193,15 +233,15 @@ fun UsageOverviewScreen(
@Composable private fun CacheBreakdownCard(overview: OverviewStats) {
Card(Modifier.fillMaxWidth(), RoundedCornerShape(20.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Column(Modifier.padding(16.dp)) {
Text("缓存 Token 细分", style = MaterialTheme.typography.labelMedium, color = inkMuted())
Text(stringResource(R.string.usage_cache_breakdown), style = MaterialTheme.typography.labelMedium, color = inkMuted())
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
StatItem("缓存读取", formatTokenCount(overview.cacheReadTokens))
StatItem("缓存写入", formatTokenCount(overview.cacheWriteTokens))
StatItem(stringResource(R.string.stat_cache_read), formatTokenCount(overview.cacheReadTokens))
StatItem(stringResource(R.string.stat_cache_write), formatTokenCount(overview.cacheWriteTokens))
}
if (overview.cacheReadTokens > 0) {
Spacer(Modifier.height(4.dp))
Text("※ 缓存读取已计入「输入 Token」", style = MaterialTheme.typography.bodySmall, color = inkMuted())
Text(stringResource(R.string.usage_cache_read_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
}
@ -216,7 +256,7 @@ fun UsageOverviewScreen(
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stat.model, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
Text("${stat.count} 次调用", style = MaterialTheme.typography.bodySmall, color = inkMuted())
Text(stringResource(R.string.usage_calls, stat.count), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
Column(horizontalAlignment = Alignment.End) {
Text(formatTokenCount(stat.totalTokens), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = StrawberryPink)
@ -227,13 +267,18 @@ fun UsageOverviewScreen(
}
@Composable private fun DailyDetailRow(day: com.rainy.token.data.local.DailyStats) {
val utcFmt = remember { SimpleDateFormat("MM月dd日 EEEE", Locale.CHINA).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } }
val datePattern = stringResource(R.string.date_format_md)
// 从当前资源 configuration 取 locale(跟随应用内语言切换),
// 不能依赖 Locale.getDefault():LocaleManager 只覆写 activity Configuration,不改变 JVM 默认 locale
val configuration = LocalConfiguration.current
val activeLocale = remember(configuration) { configuration.locales[0] }
val utcFmt = remember(datePattern, activeLocale) { SimpleDateFormat(datePattern, activeLocale).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } }
Card(Modifier.fillMaxWidth(), RoundedCornerShape(12.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(utcFmt.format(Date(day.dayTs)), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Column(horizontalAlignment = Alignment.End) {
Text(formatTokenCount(day.totalTokens), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = StrawberryPink)
Text("${day.count} 次 · $${String.format(Locale.US, "%.4f", day.totalCost / 100_000_000.0)}", style = MaterialTheme.typography.bodySmall, color = inkMuted())
Text(stringResource(R.string.daily_count_cost, day.count, String.format(Locale.US, "%.4f", day.totalCost / 100_000_000.0)), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
}
@ -246,6 +291,12 @@ fun UsageOverviewScreen(
}
}
private fun Long.toUtcLocalDate(): LocalDate =
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()
private fun LocalDate.toUtcStartOfDayMillis(): Long =
atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
private fun formatTokenCount(tokens: Long): String = when {
tokens >= 1_000_000 -> "${"%.1f".format(Locale.US, tokens / 1_000_000.0)}M"
tokens >= 1_000 -> "${"%.1f".format(Locale.US, tokens / 1_000.0)}K"

View File

@ -24,11 +24,15 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.local.OverviewStats
import com.rainy.token.ui.components.asString
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
import java.util.Locale
@ -41,11 +45,17 @@ import java.util.Locale
@Composable
fun UsageStatsCard(
onOpenDetail: () -> Unit,
onOpenHeatmap: () -> Unit = {},
refreshTrigger: Int = 0,
viewModel: UsageViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// OCGO 首次加载(init 不再自动加载)
LaunchedEffect(Unit) {
viewModel.loadStats()
}
// 全局刷新触发用量同步(跳过首次 0)
LaunchedEffect(refreshTrigger) {
if (refreshTrigger > 0) viewModel.sync()
@ -62,12 +72,12 @@ fun UsageStatsCard(
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "OpenCode 用量",
text = stringResource(R.string.usage_title_opencode),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (uiState.recordCount > 0) "${uiState.recordCount} 条记录" else "点击同步",
text = if (uiState.recordCount > 0) stringResource(R.string.usage_record_count, uiState.recordCount) else stringResource(R.string.usage_click_to_sync),
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
@ -82,7 +92,7 @@ fun UsageStatsCard(
IconButton(onClick = { viewModel.sync() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "同步用量数据",
contentDescription = stringResource(R.string.action_sync_usage),
tint = StrawberryPink
)
}
@ -95,13 +105,13 @@ fun UsageStatsCard(
// 输入 Token = input + cacheRead
val inputTotal = overview.inputTokens + overview.cacheReadTokens
MetricRow("输入 Token", formatTokenCount(inputTotal))
MetricRow(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
// 缓存子行
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
val parts = mutableListOf<String>()
if (overview.cacheReadTokens > 0) parts.add("缓存读取 ${formatTokenCount(overview.cacheReadTokens)}")
if (overview.cacheWriteTokens > 0) parts.add("缓存写入 ${formatTokenCount(overview.cacheWriteTokens)}")
if (overview.cacheReadTokens > 0) parts.add(stringResource(R.string.usage_cache_read, formatTokenCount(overview.cacheReadTokens)))
if (overview.cacheWriteTokens > 0) parts.add(stringResource(R.string.usage_cache_write, formatTokenCount(overview.cacheWriteTokens)))
Text(
text = parts.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
@ -114,8 +124,8 @@ fun UsageStatsCard(
// 输出 Token + 总花费 同一排
Row(Modifier.fillMaxWidth()) {
MetricRow("输出 Token", formatTokenCount(overview.outputTokens), Modifier.weight(1f))
MetricRow("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
MetricRow(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens), Modifier.weight(1f))
MetricRow(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
}
}
@ -123,24 +133,49 @@ fun UsageStatsCard(
if (uiState.overview == null && !uiState.syncing) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (uiState.loading) "加载中…" else "暂无数据,点击 🔄 同步",
text = if (uiState.loading) stringResource(R.string.common_loading) else stringResource(R.string.usage_no_data_sync),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
}
// ─── 查看详情 ───
// ─── 查看详情 + Token 活动快捷入口 ───
if (uiState.overview != null) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onOpenDetail) {
Text("查看详情", color = StrawberryPink)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
tint = StrawberryPink,
modifier = Modifier.padding(top = 1.dp)
)
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onOpenDetail) {
Text(
stringResource(R.string.action_view_detail),
color = StrawberryPink,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
tint = StrawberryPink,
modifier = Modifier.padding(top = 1.dp)
)
}
// 弹性间距:窄屏/英文长文案时优先压缩间距,避免按钮折行变形
Spacer(modifier = Modifier.weight(1f))
// OCGO Token 活动热力图快捷入口(与"查看详情"并列,右对齐)
TextButton(onClick = onOpenHeatmap) {
Text(
stringResource(R.string.heatmap_quick_entry),
color = StrawberryPink,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
tint = StrawberryPink,
modifier = Modifier.padding(top = 1.dp)
)
}
}
}
@ -148,7 +183,7 @@ fun UsageStatsCard(
if (uiState.lastSyncResult > 0) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "✓ 新增 ${uiState.lastSyncResult} 条",
text = stringResource(R.string.usage_new_records, uiState.lastSyncResult),
style = MaterialTheme.typography.bodySmall,
color = StrawberryPink
)
@ -156,11 +191,21 @@ fun UsageStatsCard(
uiState.lastSyncError?.let { err ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "✗ $err",
text = "✗ ${err.asString()}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
// ─── 刷新时间(与其余服务卡片底部一致) ───
if (uiState.lastSyncAt > 0) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = usageUpdatedAtText(uiState.lastSyncAt),
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
}
}
}
}

View File

@ -1,15 +1,21 @@
package com.rainy.token.ui.dashboard
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.R
import com.rainy.token.data.local.OverviewStats
import com.rainy.token.data.local.ModelStats
import com.rainy.token.data.local.DailyStats
import com.rainy.token.data.local.UsageCache
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.SyncCommandCodeUsageUseCase
import com.rainy.token.domain.usecase.SyncError
import com.rainy.token.domain.usecase.SyncUsageUseCase
import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@ -25,17 +31,17 @@ import javax.inject.Provider
internal const val DAILY_PAGE_SIZE = 5
/** 时间筛选条件 */
sealed class TimeFilter(val label: String) {
data object All : TimeFilter("全部")
data object Last5h : TimeFilter("最近5小时")
data object Last24h : TimeFilter("最近24小时")
data object Today : TimeFilter("今天")
data object Yesterday : TimeFilter("昨天")
data object Last7Days : TimeFilter("最近7天")
data object Last30Days : TimeFilter("最近30天")
data object ThisMonth : TimeFilter("当月")
data class Custom(val from: Long, val to: Long) : TimeFilter("自定义")
/** 时间筛选条件(label 用字符串资源 ID,UI 层按当前语言解析) */
sealed class TimeFilter(@StringRes val labelRes: Int) {
data object All : TimeFilter(R.string.time_all)
data object Last5h : TimeFilter(R.string.time_last_5h)
data object Last24h : TimeFilter(R.string.time_last_24h)
data object Today : TimeFilter(R.string.time_today)
data object Yesterday : TimeFilter(R.string.time_yesterday)
data object Last7Days : TimeFilter(R.string.time_last_7d)
data object Last30Days : TimeFilter(R.string.time_last_30d)
data object ThisMonth : TimeFilter(R.string.time_this_month)
data class Custom(val from: Long, val to: Long) : TimeFilter(R.string.time_custom)
/** 计算筛选的起止 epoch 毫秒(from 含,to 含)。
* 返回 Pair(null, null) 表示不限制。 */
@ -72,27 +78,45 @@ sealed class TimeFilter(val label: String) {
class UsageViewModel @Inject constructor(
private val cacheProvider: Provider<UsageCache>,
private val syncUseCaseProvider: Provider<SyncUsageUseCase>,
private val syncCommandCodeUseCaseProvider: Provider<SyncCommandCodeUsageUseCase>,
private val credentialRepository: CredentialRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UsageUiState())
val uiState: StateFlow<UsageUiState> = _uiState.asStateFlow()
init {
loadStatsAndSyncIfNeeded()
private var workspaceIdOverride: String? = null
private var loadGeneration = 0 // 递增:过时的 loadStatsInternal 结果自动丢弃
/** 用量同步时间持久化(按 workspace 区分 OCGO / CCGO),重启后卡片仍能显示刷新时间。 */
private fun syncAtPrefs(): android.content.SharedPreferences =
com.rainy.token.RainyTokenApplication.appContext.getSharedPreferences(
"usage_sync_at",
android.content.Context.MODE_PRIVATE
)
private fun syncAtKey(wid: String): String = "sync_at_$wid"
private fun loadLastSyncAt(wid: String): Long =
syncAtPrefs().getLong(syncAtKey(wid), 0L)
private fun saveLastSyncAt(wid: String, ts: Long) {
syncAtPrefs().edit().putLong(syncAtKey(wid), ts).apply()
}
private fun loadStatsAndSyncIfNeeded() {
viewModelScope.launch {
loadStatsInternal()
// 无缓存数据 → 自动触发同步
if (_uiState.value.overview == null && _uiState.value.recordCount == 0) {
sync()
}
}
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadStats() 前调用。 */
fun setWorkspace(wid: String) {
workspaceIdOverride = wid
loadGeneration++
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
_uiState.value = UsageUiState()
loadStats()
}
// 不在 init 自动加载,由 Composable 层显式触发 loadStats()(OCGO)或 setWorkspace()(CCGO)
private suspend fun workspaceId(): String? {
workspaceIdOverride?.let { return it }
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
}
@ -104,6 +128,7 @@ class UsageViewModel @Inject constructor(
/** 只调一次 getRecords(),所有统计从同一次结果派生。重 IO/CPU 操作跑在 Default 上。 */
private suspend fun loadStatsInternal() {
val wid = workspaceId() ?: return
val genAtStart = loadGeneration // 记下发起时的世代
val cache = cacheProvider.get()
val filter = _uiState.value.timeFilter
val (fromTs, toTs) = filter.toRange()
@ -139,6 +164,9 @@ class UsageViewModel @Inject constructor(
LoadResult(ov, ms, ds, records.size)
}
// 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果
if (loadGeneration != genAtStart) return
_uiState.update {
it.copy(
overview = overview,
@ -146,7 +174,8 @@ class UsageViewModel @Inject constructor(
dailyStats = dailyStats,
recordCount = totalCount,
loading = false,
dailyPage = 1
dailyPage = 1,
lastSyncAt = loadLastSyncAt(wid)
)
}
}
@ -177,22 +206,60 @@ class UsageViewModel @Inject constructor(
fun sync() {
viewModelScope.launch {
_uiState.update { it.copy(syncing = true) }
val wid = workspaceIdOverride ?: workspaceId()
val result = withContext(Dispatchers.Default) {
val useCase = syncUseCaseProvider.get()
val cache = cacheProvider.get()
val count = cache.count()
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
if (workspaceIdOverride == com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID) {
val useCase = syncCommandCodeUseCaseProvider.get()
val count = cache.count(com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID)
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
} else {
val useCase = syncUseCaseProvider.get()
val count = cache.count()
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
}
}
val syncedAt = System.currentTimeMillis()
if (result.isSuccess && wid != null) saveLastSyncAt(wid, syncedAt)
result.onSuccess { loadStats() }
_uiState.update {
it.copy(
syncing = false,
lastSyncResult = result.getOrNull()?.inserted ?: 0,
lastSyncError = result.exceptionOrNull()?.message
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) },
lastSyncAt = if (result.isSuccess && wid != null) syncedAt else it.lastSyncAt
)
}
}
}
/** 清除当前 workspace 的缓存数据并全量重新同步。 */
fun clearAndResync() {
viewModelScope.launch {
val wid = workspaceIdOverride ?: return@launch
_uiState.update { it.copy(syncing = true) }
val cache = cacheProvider.get()
cache.deleteByWorkspaceId(wid)
invalidateData()
val useCase = syncCommandCodeUseCaseProvider.get()
val result = useCase.fullSync()
val syncedAt = System.currentTimeMillis()
if (result.isSuccess) saveLastSyncAt(wid, syncedAt)
result.onSuccess { loadStats() }
_uiState.update {
it.copy(
syncing = false,
lastSyncResult = result.getOrNull()?.inserted ?: 0,
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) },
lastSyncAt = if (result.isSuccess) syncedAt else it.lastSyncAt
)
}
}
}
private fun invalidateData() {
_uiState.value = UsageUiState()
}
}
data class UsageUiState(
@ -203,7 +270,8 @@ data class UsageUiState(
val dailyStats: List<DailyStats> = emptyList(),
val recordCount: Int = 0,
val lastSyncResult: Int = 0,
val lastSyncError: String? = null,
val lastSyncError: UiText? = null,
val lastSyncAt: Long = 0, // 最近一次成功同步的 epoch ms(0 = 从未同步)
val timeFilter: TimeFilter = TimeFilter.All,
val dailyPage: Int = 1,
val modelFilter: String? = null // null = 全部模型
@ -215,4 +283,37 @@ private data class LoadResult(
val modelStats: List<ModelStats>,
val dailyStats: List<DailyStats>,
val totalCount: Int
)
)
/** 把同步错误映射为本地化文案:SyncError / RepositoryError 全部走资源,不透传中文 message。 */
private fun syncErrorToUiText(error: Throwable): UiText = when (error) {
is SyncError.PartialSync ->
UiText.Resource(R.string.sync_partial, listOf(error.inserted, error.errors.size))
is RepositoryError.SessionExpired ->
UiText.Resource(R.string.error_credential_session_expired)
is RepositoryError.InvalidCredential ->
UiText.Resource(R.string.error_credential_invalid_reconfigure)
is RepositoryError.CredentialChanged ->
UiText.Resource(R.string.error_credential_changed_reload)
is RepositoryError.RateLimited -> UiText.Resource(
R.string.error_rate_limited_retry,
listOf(
error.retryAfterSeconds?.let {
UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it))
} ?: ""
)
)
is RepositoryError.Network -> UiText.Resource(R.string.error_network_check)
is RepositoryError.ServerError ->
UiText.Resource(R.string.error_server_http, listOf(error.code))
is RepositoryError.ParseError -> when (error.reason) {
RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body)
RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json)
RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows)
RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models)
RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
}
is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown)
else -> UiText.Resource(R.string.common_unknown)
}

View File

@ -0,0 +1,16 @@
package com.rainy.token.ui.dashboard.autocheckin
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* 后台广播(自动签到闹钟 / 开机重排)获取 Hilt 单例的入口。
* 与 ui.widget.WidgetRefreshEntryPoint 同一模式:广播接收器不依赖
* @AndroidEntryPoint,通过 EntryPointAccessors 拿 SingletonComponent 里的依赖。
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AutoCheckinEntryPoint {
fun autoCheckinManager(): AutoCheckinManager
}

View File

@ -0,0 +1,296 @@
package com.rainy.token.ui.dashboard.autocheckin
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import com.rainy.token.data.repository.CheckinStatusStore
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.WorkBuddyRepository
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceAccountInfo
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.ZonedDateTime
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* 每日自动签到编排器(小组件开关控制启用)。
*
* 行为契约(用户需求 2026-09-13 / 多账号版 2026-10 / 零点后台版 2026-11):
* - 开启后,每天零点后由 AlarmManager 自动触发([AutoCheckinReceiver]),
* 为所有「已配置凭据」的可签服务的【全部账号】各签一次;
* - 兜底:每天首次打开 App(DashboardViewModel)也会补签一次(错过闹钟/被杀进程场景);
* - 当日每账号只签一次:本地有「今日已成功签到」记录的账号直接跳过(手动签过也算);
* 失败/限流/设备拦截等非成功记录不占位,下次打开 App 会再次唤起签到流程;
* - 结果汇报:签完把每个账号的结果暴露为 [report],首页据此弹「xx 已签到,积分增加 xx」;
* - 开关持久化在 SharedPreferences "auto_checkin"(小组件与 App 内共用同一份)。
*
* 仅负责编排与记录,不做 UI:签到复用 RefreshBalanceUseCase(带凭据快照会话)。
*
* 调度说明:使用框架自带的 AlarmManager(非精确 setAndAllowWhileIdle)实现「每日零点后
* 后台签到」,零新增依赖;重启设备后闹钟由 BOOT_COMPLETED 广播重排。国产 ROM(如 vivo/
* iQOO)可能因电池优化延迟或抑制该闹钟,届时由「首次打开 App」兜底补签。
*/
@Singleton
class AutoCheckinManager @Inject constructor(
@ApplicationContext private val context: Context,
private val refreshBalanceUseCase: RefreshBalanceUseCase,
private val credentialRepository: CredentialRepository,
private val checkinStatusStore: CheckinStatusStore
) {
/** 单个账号的自动签到结果(UI 层本地化展示)。 */
data class ServiceResult(
val service: ServiceType,
/** 该账号的自定义名称;null=单账号服务/未命名的默认账号。 */
val accountLabel: String? = null,
/** true=本次签到成功(新签到账);false=已签/失败/跳过 */
val newlyCheckedIn: Boolean,
/** 今日已签(成功或本来已签),弹窗「已签到」行的判定依据 */
val checkedInToday: Boolean,
/** 本次新增积分(仅 SUCCESS 时有值;ALREADY/失败为 0) */
val creditsGained: Long,
/** 失败/提示原因(服务端原文,仅 failed 时非空) */
val message: String,
val failed: Boolean
)
/**
* 自动签到汇总结果。null=没有可展示的内容(未开启/无候选账号/全部账号已签或静默跳过)。
* 弹窗消费后由 [consumeReport] 置 null。
*/
private val _report = MutableStateFlow<List<ServiceResult>?>(null)
val report: StateFlow<List<ServiceResult>?> = _report.asStateFlow()
/** 防同日并发重复签到:闹钟广播(IO 线程)与 Dashboard init(viewModelScope)可能同时进入。 */
private val runMutex = Mutex()
private fun prefs() =
context.getSharedPreferences("auto_checkin", Context.MODE_PRIVATE)
/** 自动签到开关(小组件 CheckBox 与 App 内共用)。开关变更时同步排/撤零点闹钟。 */
var enabled: Boolean
get() = prefs().getBoolean(KEY_ENABLED, false)
set(value) {
prefs().edit().putBoolean(KEY_ENABLED, value).apply()
refreshSchedule()
}
/**
* 每日签到执行入口(后台闹钟与首次打开 App 共用):
* 开启时 → 为已配置凭据的可签服务中【今日尚未成功签到】的账号各签一次。
* 返回 true 表示本次实际执行了签到(无论结果)。
*/
suspend fun runDailyIfDue(): Boolean = runMutex.withLock {
if (!enabled) return false
// 不再使用全局 lastRunDate 占位:只有真正「今日签到成功」的账号才跳过,
// 失败/限流/设备拦截等不会消费当天机会,重开 App 可再次唤起签到流程。
val results = mutableListOf<ServiceResult>()
for (service in CHECKIN_SERVICES) {
// 多账号服务遍历全部账号;无账号文档时 getDefaultAccount 同样返回 null,
// 候选为空 = 不签,与旧版「只签默认账号」的边界行为一致,不会误签旧凭据
val accounts = credentialRepository.accountsFor(service)
val candidates = accounts.ifEmpty {
listOfNotNull(credentialRepository.getDefaultAccount(service))
}
for (account in candidates) {
if (credentialRepository.statusFor(service, account.id).state ==
CredentialStatus.State.NOT_CONFIGURED
) {
continue
}
// 今日已有成功签到记录(手动签过也算)→ 跳过,不再发网络请求
if (checkinStatusStore.isCheckedToday(service, account.id)) continue
// runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目
runOne(service, account)?.let { results += it }
}
}
if (results.isNotEmpty()) {
_report.value = results
}
return true
}
/**
* 注册/取消每日零点后台闹钟(非精确 setAndAllowWhileIdle,零点后几分钟内触发)。
* 设备重启后闹钟丢失,由 BOOT_COMPLETED 广播再次调用本方法重排。
*/
fun refreshSchedule() {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, AutoCheckinReceiver::class.java)
.setAction(ACTION_DAILY_CHECKIN)
val pendingIntent = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
if (!enabled) {
alarmManager.cancel(pendingIntent)
return
}
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
nextRunTimeMillis(),
pendingIntent
)
}
/** 下一次零点(00:05,本地时区)的运行时间戳。 */
private fun nextRunTimeMillis(): Long {
val now = ZonedDateTime.now()
var next = now.toLocalDate().atTime(CHECKIN_HOUR, CHECKIN_MINUTE).atZone(now.zone)
if (!next.isAfter(now)) next = next.plusDays(1)
return next.toInstant().toEpochMilli()
}
/**
* 返回 null 表示该账号无需出现在汇报弹窗里(如 INTL 不支持签到、静默跳过)。
*/
private suspend fun runOne(
service: ServiceType,
account: ServiceAccountInfo
): ServiceResult? {
val label = account.label?.takeIf { it.isNotBlank() }
return when (service) {
ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae(account.id).fold(
onSuccess = { r ->
when (r.outcome) {
TraeRepository.CheckinOutcome.SUCCESS -> ServiceResult(
service = service,
accountLabel = label,
newlyCheckedIn = true,
checkedInToday = true,
creditsGained = r.points,
message = "",
failed = false
)
TraeRepository.CheckinOutcome.ALREADY ->
ServiceResult(service, label, false, true, 0, "", false)
// INTL 不支持签到:静默跳过,不进弹窗(避免误报「已签到」)
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> null
TraeRepository.CheckinOutcome.DEVICE_BLOCKED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_DEVICE_BLOCKED)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
}
TraeRepository.CheckinOutcome.RETRYABLE -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_RETRYABLE)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
}
TraeRepository.CheckinOutcome.FAILED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
}
}
},
onFailure = { e ->
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
failureResult(service, label, account.id, e)
}
)
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(account.id).fold(
onSuccess = { s ->
when (s.outcome) {
WorkBuddyRepository.CheckinOutcome.SUCCESS -> ServiceResult(
service = service,
accountLabel = label,
newlyCheckedIn = true,
checkedInToday = true,
creditsGained = (s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit),
message = "",
failed = false
)
WorkBuddyRepository.CheckinOutcome.ALREADY ->
ServiceResult(service, label, false, true, 0, "", false)
WorkBuddyRepository.CheckinOutcome.QUERY ->
ServiceResult(service, label, false, true, 0, "", false)
WorkBuddyRepository.CheckinOutcome.FAILED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
ServiceResult(service, label, false, false, 0, s.message, failed = true)
}
}
},
onFailure = { e ->
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
failureResult(service, label, account.id, e)
}
)
// CHECKIN_SERVICES 只含上述两者;穷尽兜底(未来加服务忘了实现时安全返回失败)
else -> null
}
}
/**
* 签到请求失败的汇报结果。
*
* 竞态兜底:签到请求在服务端已成功([RefreshBalanceUseCase.checkInTrae] 的 also 块
* 已写 CheckinStatusStore 成功记录),但与并发余额刷新争抢凭据快照导致结果被 [RepositoryError.CredentialChanged]
* 丢弃时——若本地记录确认今日已签,按「今日已签到」汇报,而不是报「凭据」错误
* (用户会看到积分实际到账却提示失败的矛盾)。
*/
private fun failureResult(
service: ServiceType,
accountLabel: String?,
accountId: String,
error: Throwable
): ServiceResult =
if (error is RepositoryError.CredentialChanged &&
checkinStatusStore.isCheckedToday(service, accountId)
) {
ServiceResult(service, accountLabel, false, true, 0, "", false)
} else {
ServiceResult(
service,
accountLabel,
false,
false,
0,
error.message.orEmpty(),
failed = true
)
}
/**
* 落一条非成功签到记录(仅当今天还没有成功记录时写入)。
* 失败/限流/设备拦截不消费当天签到机会,重开 App 仍会重试;
* 但若在一次 CredentialChanged 竞态中服务端实际已成功,保留成功记录不被覆盖。
*/
private fun recordFailure(
service: ServiceType,
accountId: String,
status: String
) {
if (!checkinStatusStore.isCheckedToday(service, accountId)) {
checkinStatusStore.record(service, accountId, status)
}
}
/** 弹窗关闭时调用,清除汇报状态(下次执行前不再重复弹)。 */
fun consumeReport() {
_report.value = null
}
companion object {
const val KEY_ENABLED = "enabled"
/** 每日自动签到闹钟的广播 action(供 [AutoCheckinReceiver] 分发)。 */
const val ACTION_DAILY_CHECKIN = "com.rainy.token.action.DAILY_AUTO_CHECKIN"
/** 每日签到目标时间:零点后 5 分钟(避开日切边界),本地时区。 */
const val CHECKIN_HOUR = 0
const val CHECKIN_MINUTE = 5
}
}
/** 支持每日签到的服务(DashboardViewModel 与自动签到共用的唯一来源,避免两处漂移)。 */
internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY)

View File

@ -0,0 +1,60 @@
package com.rainy.token.ui.dashboard.autocheckin
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.EntryPointAccessors
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* 每日自动签到后台触发点。
*
* 两种入口共用同一个接收器:
* - [AutoCheckinManager.ACTION_DAILY_CHECKIN]:AlarmManager 每日零点后发出的闹钟广播,
* 到点就为该服务的全部已配置账号签到;
* - ACTION_BOOT_COMPLETED:设备重启后闹钟会被系统清空,这里只负责重新排程,不抢签。
*
* 通过 EntryPointAccessors 获取 Hilt 单例,不依赖 @AndroidEntryPoint。
*/
class AutoCheckinReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
val appContext = context.applicationContext
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val manager = EntryPointAccessors.fromApplication(
appContext, AutoCheckinEntryPoint::class.java
).autoCheckinManager()
when (action) {
AutoCheckinManager.ACTION_DAILY_CHECKIN -> {
// 到点签到(关闭时 runDailyIfDue 自身会短路返回);
// 无论本次签到是否抛异常,都必须把一次性闹钟重排到明天
try {
manager.runDailyIfDue()
} finally {
manager.refreshSchedule()
}
}
Intent.ACTION_BOOT_COMPLETED -> {
// 重启后闹钟丢失,重新排程(今天是否签到由首次打开 App 兜底)
manager.refreshSchedule()
}
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
// 后台静默:签到/排程失败不阻塞下次闹钟,等下次打开 App 再补救
} finally {
pendingResult.finish()
}
}
}
}

View File

@ -0,0 +1,748 @@
package com.rainy.token.ui.heatmap
import android.graphics.Paint
import android.os.SystemClock
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.sp
import com.rainy.token.R
import com.rainy.token.ui.theme.inkMuted
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.first
// ── 热力图颜色 ──────────────────────────────────────────────
/** 亮色模式 6 级玫瑰粉梯度(精致粉调,最高级与主色一致) */
private val LightHeatmapColors = listOf(
Color(0xFFF3E9EE), // Level 0: 极浅暖灰
Color(0xFFFFE3EC), // Level 1: 浅樱粉
Color(0xFFFFC9D9), // Level 2: 浅玫瑰柔
Color(0xFFFFA3BC), // Level 3: 玫瑰粉
Color(0xFFF27E9F), // Level 4: 玫瑰粉深
Color(0xFFC9436C), // Level 5: 深玫瑰(主色)
)
/** 暗色模式 6 级玫瑰粉梯度 */
private val DarkHeatmapColors = listOf(
Color(0xFF3A2B33), // Level 0: 暖深灰
Color(0xFF4A3239), // Level 1
Color(0xFF643D4A), // Level 2
Color(0xFF855062), // Level 3
Color(0xFFA9627A), // Level 4
Color(0xFFE07C9A), // Level 5
)
/** 月份名称资源(按月索引 1~12,locale-aware) */
private val monthNameRes = intArrayOf(
R.string.month_1, R.string.month_2, R.string.month_3, R.string.month_4,
R.string.month_5, R.string.month_6, R.string.month_7, R.string.month_8,
R.string.month_9, R.string.month_10, R.string.month_11, R.string.month_12,
)
// ── 格式化辅助函数(HeatmapScreen 浮层与滑动查看读数条共用)──
/** 是否为繁体中文 locale(数字单位用 萬/億) */
private fun isTraditionalChinese(locale: Locale): Boolean =
locale.language == "zh" &&
(locale.country == "TW" || locale.country == "HK" || locale.country == "MO")
/**
* 将 Token 数按数量级格式化(locale-aware):
* - 中文(zh):亿级 1.9亿 / 万级 11.2万(繁体用 萬/億)
* - 其他语言:≥1e9 → 1.9B,≥1e6 → 11.2M,≥1e3 → 16.8K,否则原数字
*
* 注意:中文万级先舍入到一位小数再判断是否升亿(如 99999999 → "1.0亿",而非 "10000.0万")。
*/
internal fun formatTokenChinese(tokens: Long, locale: Locale = Locale.getDefault()): String {
if (locale.language != "zh") {
return when {
tokens >= 1_000_000_000L -> "${"%.1f".format(tokens / 1_000_000_000.0)}B"
tokens >= 1_000_000L -> "${"%.1f".format(tokens / 1_000_000.0)}M"
tokens >= 1_000L -> "${"%.1f".format(tokens / 1_000.0)}K"
else -> "$tokens"
}
}
val wan = if (isTraditionalChinese(locale)) "萬" else "万"
val yi = if (isTraditionalChinese(locale)) "億" else "亿"
return when {
tokens >= 1_0000_0000 -> {
val v = tokens / 1_0000_0000.0
if (v >= 100) "${v.roundToInt()}$yi"
else "${"%.1f".format(v)}$yi"
}
tokens >= 1_0000 -> {
val v = tokens / 1_0000.0
// 先舍入到一位小数,避免 "99999999 → 10000.0万"
val rounded = (v * 10).roundToInt() / 10.0
if (rounded >= 10000) {
// 万级溢出升亿:9999.99万 → 1.0亿
val v2 = rounded / 10000.0
if (v2 >= 100) "${v2.roundToInt()}$yi"
else "${"%.1f".format(v2)}$yi"
} else {
"${"%.1f".format(v)}$wan" // 始终保留一位小数
}
}
else -> "$tokens"
}
}
/**
* 将时间戳格式化为日期文本(locale-aware),按与数据分桶一致的时区:
* - 中文(zh):"7月9日",跨年前缀 "2024年7月9日"
* - 其他语言:按 locale 的 "MMM d",跨年 "MMM d, yyyy"(如 "Jul 9, 2024")
*
* 日浮层传 [currentYear](查看往年时带年份前缀);周浮层两端传 [selectedYear],
* 使跨年周(如 12月28日-次年1月3日)的结束日带年份前缀,避免范围倒挂。
*/
internal fun formatDateChinese(ts: Long, useUtc8: Boolean, yearContext: Int, locale: Locale = Locale.getDefault()): String {
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val date = Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate()
return if (locale.language == "zh") {
val yearPrefix = if (date.year != yearContext) "${date.year}年" else ""
"$yearPrefix${date.monthValue}月${date.dayOfMonth}日"
} else {
val fmt = if (date.year != yearContext) "MMM d, yyyy" else "MMM d"
DateTimeFormatter.ofPattern(fmt, locale).format(date)
}
}
/**
* 将周数据格式化为范围文本:"12月28日-12月31日"(不含"使用了X token",
* 句子由调用方用 stringResource 按语言拼接)。
*
* - 末周只显示到该周落在所选窗口内的有效日期([dataEndTs],数据最后一天),
* 避免出现未绘制的未来/次年空位日期;数据同源,不依赖实时时钟
* - 首列跨年(start 在上一年)时,end 与 start 不同年 → end 强制带年份前缀,避免范围倒挂
* - [RECENT_YEAR](最近 365 天窗口)时按周自身年份判断:start 不带前缀,
* end 仅在与 start 跨年时带前缀(窗口可能跨年,如 "1月5日-2026年1月3日")
*/
internal fun formatWeekRangeText(
week: HeatmapWeekData,
useUtc8: Boolean,
selectedYear: Int,
dataEndTs: Long?,
locale: Locale = Locale.getDefault(),
): String {
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate()
// 最近模式按周自身年份判断前缀;年份模式用所选年作上下文(与既有行为一致)
val startContext = if (selectedYear == RECENT_YEAR) startDate.year else selectedYear
val start = formatDateChinese(week.weekStartTs, useUtc8, startContext, locale)
val endTs = minOf(week.weekStartTs + 6L * 86_400_000L, dataEndTs ?: (week.weekStartTs + 6L * 86_400_000L))
if (locale.language == "zh") {
val endDate = Instant.ofEpochMilli(endTs).atOffset(zone).toLocalDate()
val end = if (endDate.year != startDate.year) {
"${endDate.year}年${endDate.monthValue}月${endDate.dayOfMonth}日"
} else {
formatDateChinese(endTs, useUtc8, startContext, locale)
}
return "$start-$end"
}
// 非中文:end 用 start 的年份作上下文(同 startDate.year 时无前缀;跨年时带年份)
return "$start-${formatDateChinese(endTs, useUtc8, startContext, locale)}"
}
// ── 辅助类型与函数 ──────────────────────────────────────────
/**
* 水平拖动手势的仲裁模式:
* - VIEW:慢速横向拖动 → 滑动查看模式(显示手指所在天的信息,图表锁定不滚动)
* - SCROLL:快速横向拖动或纵向拖动 → 放行给 scrollable 正常滚动
*/
private enum class DragMode { VIEW, SCROLL }
/** 慢速/快速拖动分界速度(dp/s,密度无关;低于=查看拖动,高于=滚动拖动) */
private const val VIEW_SPEED_DP_PER_S = 150f
/** 月份标签 */
private data class MonthLabel(
val weekIndex: Int,
val label: String,
)
/** 从时间戳获取星期几 (0=Sunday, 6=Saturday),按指定时区 */
private fun dayOfWeekFromTs(ts: Long, zone: ZoneOffset): Int {
val dayOfWeek = Instant.ofEpochMilli(ts).atOffset(zone).dayOfWeek.value
return dayOfWeek % 7 // Sunday=7 → 0
}
/** 从时间戳获取月份 (0=January),按指定时区 */
private fun monthFromTs(ts: Long, zone: ZoneOffset): Int {
return Instant.ofEpochMilli(ts).atOffset(zone).monthValue - 1
}
/**
* 将每日数据按周分组(参考 react-activity-calendar 的 groupByWeeks 逻辑)。
* 周起始日为周日(weekStart = 0)。
* 返回 List<List<HeatmapDayData?>>,外层为周,内层为星期 0-6,null 表示空位。
*/
private fun groupByWeeks(dailyData: List<HeatmapDayData>, zone: ZoneOffset): List<List<HeatmapDayData?>> {
if (dailyData.isEmpty()) return emptyList()
val firstDow = dayOfWeekFromTs(dailyData[0].dayTs, zone)
// 左侧填充空位,使第一周从周日开始
val padded = ArrayList<HeatmapDayData?>(firstDow + dailyData.size)
repeat(firstDow) { padded.add(null) }
padded.addAll(dailyData)
val numWeeks = (padded.size + 6) / 7
return (0 until numWeeks).map { w ->
(0 until 7).map { d ->
val idx = w * 7 + d
if (idx < padded.size) padded[idx] else null
}
}
}
/** 从按周分组的每日数据计算月份标签([monthNames] 为当前 locale 的月份名,索引 0~11) */
private fun getMonthLabels(
weeks: List<List<HeatmapDayData?>>,
zone: ZoneOffset,
monthNames: List<String>,
): List<MonthLabel> {
if (weeks.isEmpty()) return emptyList()
val rawLabels = mutableListOf<MonthLabel>()
var prevMonth = -1
for (weekIndex in weeks.indices) {
val firstDay = weeks[weekIndex].firstOrNull { it != null } ?: continue
val month = monthFromTs(firstDay.dayTs, zone)
if (weekIndex == 0 || month != prevMonth) {
rawLabels.add(MonthLabel(weekIndex, monthNames[month]))
prevMonth = month
}
}
return filterMonthLabels(rawLabels, weeks.size)
}
/**
* 过滤月份标签:跳过间距不足的标签(参考 react-activity-calendar 的 label.ts)。
* - 第一个标签:若与第二个标签间距 < minWeeks,跳过
* - 最后一个标签:若剩余周数 < minWeeks,跳过
*/
private fun filterMonthLabels(rawLabels: List<MonthLabel>, totalWeeks: Int): List<MonthLabel> {
val minWeeks = 3
return rawLabels.filterIndexed { index, label ->
when {
index == 0 -> {
rawLabels.getOrNull(1)?.let { it.weekIndex - label.weekIndex >= minWeeks } ?: false
}
index == rawLabels.lastIndex -> {
totalWeeks - label.weekIndex >= minWeeks
}
else -> true
}
}
}
// ── HeatmapCanvas Composable ───────────────────────────────
/**
* Token 活动热力图 Canvas 绘制组件。
*
* 支持三种视图:
* - [HeatmapViewMode.DAILY]:约 53 列 × 7 行(列数按年浮动 52~54),每日 Token 使用
* - [HeatmapViewMode.WEEKLY]:约 53 列 × 7 行,**离散柱状图**——列=自然周,
* 整列 7 格全部绘制:底部 barHeight 格 = 该周等级颜色(0 用量周 1 格、非零周 2~7 格
* 按 token 排名比例),其余格 = Level 0 浅色(空白格不隐藏,网格完整);
* 选中周整列加主题色描边强调;列内格子不表示日期
* - [HeatmapViewMode.CUMULATIVE]:约 53 列 × 7 行,累计 Token
*
* 三种视图列数与网格尺寸一致(约 52~54 列 × 7 行),切换视图时布局保持一致。
*
* 使用 Compose Canvas 绘制圆角矩形格子,底部显示中文月份标签,
* 支持水平滚动和格子点击。
*
* @param state UI 状态,包含数据与视图模式
* @param modifier 布局修饰符
* @param selectedWeek 当前选中的周(每周视图绘制强调边框用,null=未选中)
* @param onDayClick 每日/累计视图格子点击回调
* @param onWeekClick 每周视图格子点击回调
*/
@Composable
fun HeatmapCanvas(
state: HeatmapUiState,
modifier: Modifier = Modifier,
noDataText: String = "",
lessText: String = "",
moreText: String = "",
accessibilityDesc: String = "",
selectedWeek: HeatmapWeekData? = null,
onDayClick: (HeatmapDayData, IntOffset) -> Unit = { _, _ -> },
onWeekClick: (HeatmapWeekData, IntOffset) -> Unit = { _, _ -> },
) {
val darkTheme = isSystemInDarkTheme()
val colors = if (darkTheme) DarkHeatmapColors else LightHeatmapColors
val mutedColor = inkMuted()
val density = LocalDensity.current
// 应用内语言(LocaleManager 只覆写配置、不改变 Locale.getDefault(),故从配置取;
// 与 UsageOverviewScreen 的 date_format_md 同一做法)
val activeLocale = LocalConfiguration.current.locales[0]
// 与全局图表设置一致的时区(分桶与显示口径统一)
val zone = if (state.useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
// 格子参数
val blockSize = 11.dp
val gap = 3.dp
val cornerRadius = 3.dp
// 预计算 px 值(供 Canvas 绘制和点击检测共用)
val blockSizePx = with(density) { blockSize.toPx() }
val gapPx = with(density) { gap.toPx() }
val cellStepPx = blockSizePx + gapPx
val cornerRadiusPx = with(density) { cornerRadius.toPx() }
val isWeekly = state.viewMode == HeatmapViewMode.WEEKLY
// 选中周强调样式(主题主色描边,明暗模式均可见)
val selectionColor = MaterialTheme.colorScheme.primary
val selectionStrokePx = with(density) { 1.5.dp.toPx() }
// 滑动查看指示器(三种视图):慢速拖动或长按时"所见即所得"——手指所在格子高亮预览,
// 底部读数条显示该格数据(每日/累计=当天,每周=该周);抬起后保留最后预览,切换视图/年份时清除
var indicatorDay by remember { mutableStateOf<HeatmapDayData?>(null) }
var indicatorWeek by remember { mutableStateOf<HeatmapWeekData?>(null) }
var indicatorCol by remember { mutableStateOf(-1) }
var indicatorRow by remember { mutableStateOf(-1) }
// Canvas 在窗口中的位置(浮层锚点定位用)
var canvasWindowPos by remember { mutableStateOf(IntOffset.Zero) }
// 构建网格数据(每日/累计视图用于绘制与点击;每周视图仅用于列数与月份标签,
// 柱状图绘制/点击直接基于 weeklyData)
val gridWeeks = remember(state.dailyData, state.cumulativeData, state.viewMode, zone) {
when (state.viewMode) {
HeatmapViewMode.DAILY, HeatmapViewMode.WEEKLY -> groupByWeeks(state.dailyData, zone)
HeatmapViewMode.CUMULATIVE -> groupByWeeks(state.cumulativeData, zone)
}
}
// 月份标签(三种视图共用同一计算,位置与每日视图完全一致;月份名按当前 locale)
val monthNames = List(12) { stringResource(monthNameRes[it]) }
val monthLabels = remember(gridWeeks, zone, monthNames) {
getMonthLabels(gridWeeks, zone, monthNames)
}
val cols = gridWeeks.size
val rows = 7
// 加载中或无数据
if (state.loading || cols == 0) {
Box(
modifier = modifier
.fillMaxWidth()
.height(if (state.loading) 200.dp else 120.dp),
contentAlignment = Alignment.Center,
) {
if (state.loading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} else {
Text(noDataText.ifEmpty { "No data" }, color = mutedColor, fontSize = 14.sp)
}
}
return
}
// Canvas 尺寸计算
val canvasWidthDp = (blockSize + gap) * cols - gap
val gridHeightDp = (blockSize + gap) * rows - gap
val monthLabelHeightDp = 20.dp
val totalHeightDp = gridHeightDp + monthLabelHeightDp
val gridHeightPx = with(density) { gridHeightDp.toPx() }
val labelGapPx = with(density) { 4.dp.toPx() }
// 月份标签画笔
val monthLabelPaint = remember(mutedColor, density) {
Paint().apply {
textSize = with(density) { 12.sp.toPx() }
color = mutedColor.toArgb()
isAntiAlias = true
}
}
// 横向滚动状态(图例固定在外层,不随热力图滚动)
val scrollState = rememberScrollState()
// 打开页面 / 切换视图 / 切换年份时,横向滚动自动定位到最右(最新数据),并清除滑动查看指示器
LaunchedEffect(cols, state.viewMode, state.selectedYear) {
indicatorDay = null
indicatorWeek = null
indicatorCol = -1
indicatorRow = -1
snapshotFlow { scrollState.maxValue }.first { it > 0 }
scrollState.scrollTo(scrollState.maxValue)
}
Column(modifier = modifier) {
// ── 热力图网格 + 月份标签(可横向滚动)──
Column(
modifier = Modifier
.horizontalScroll(scrollState)
.semantics { contentDescription = accessibilityDesc.ifEmpty {
if (isWeekly) "Token activity heatmap, ${state.weeklyData.size} weeks"
else "Token activity heatmap, ${state.dailyData.size} days"
} },
) {
Canvas(
modifier = Modifier
.size(canvasWidthDp, totalHeightDp)
.onGloballyPositioned { canvasWindowPos = it.positionInWindow().round() }
// 手势仲裁(与点击共存):慢速横向拖动/长按=滑动查看(锁定图表、指示器跟手),
// 快速横向拖动或纵向拖动=放行给 scrollable 正常滚动;三种视图均启用。
// ⚠️ 必须声明在下方 detectTapGestures 之前:仲裁先收到 Main pass 事件,
// VIEW 模式先 consume 可阻止 tap 误触发(顺序承重,勿调换)
.pointerInput(state, cellStepPx, blockSizePx, gridWeeks, state.viewMode) {
// 慢速/快速分界:密度无关(dp/s),保证不同设备手感一致
val viewSpeedThresholdPxPerMs = with(density) { VIEW_SPEED_DP_PER_S.dp.toPx() } / 1000f
// 指示器更新:手指所在列/行的格子(所见即所得)。
// change.position 已是滚动内容内的本地坐标(与 detectTapGestures 命中一致),
// 不能再加 scrollState.value,否则滚动后列索引整体偏右
fun updateIndicator(pos: Offset) {
val col = (pos.x / cellStepPx).toInt().coerceIn(0, cols - 1)
val row = (pos.y / cellStepPx).toInt().coerceIn(0, rows - 1)
if (isWeekly) {
// 每周视图:整列=同一周(柱状图),读数条显示该周范围与总量
val week = state.weeklyData.getOrNull(col)
if (week != null) {
indicatorDay = null
indicatorWeek = week
indicatorCol = col
indicatorRow = row
} else {
indicatorDay = null
indicatorWeek = null
indicatorCol = -1
indicatorRow = -1
}
} else {
// 每日/累计视图:格子=一天(累计视图 tokens 为累计值)
val cell = gridWeeks.getOrNull(col)?.getOrNull(row)
if (cell != null) {
indicatorWeek = null
indicatorDay = cell
indicatorCol = col
indicatorRow = row
} else {
indicatorWeek = null
indicatorDay = null
indicatorCol = -1
indicatorRow = -1
}
}
}
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
// 模式判定(本次手势内锁定):
// - 按住不动/慢速横向拖动 → VIEW:指示器跟手、消费事件锁定图表滚动
// - 快速横向拖动或纵向主导 → SCROLL:不消费,放行给 scrollable 正常滚动
var mode: DragMode? = null
var lastPos = down.position
var lastTime = SystemClock.uptimeMillis()
var totalDx = 0f
var totalDy = 0f
val slop = viewConfiguration.touchSlop
val downTime = lastTime
// 长按阈值:按住不动(含微移未超 slop)超过系统长按时长 → 直接进入查看模式。
// 静止时系统不一定派发 move 事件,故用 withTimeoutOrNull 做超时心跳,不依赖事件频率。
// ⚠️ 此符号解析为 AwaitPointerEventScope 的框架成员(非 kotlinx 协程版),
// 勿补 import kotlinx.coroutines.withTimeoutOrNull(会被接口成员遮蔽且语义不同)
val longPressMs = viewConfiguration.longPressTimeoutMillis
// 阶段一:模式判定。位移超 slop 按帧速度判 VIEW/SCROLL;提前抬起 = 点击放行给 tap;
// 长按超时(手指未动)→ VIEW
while (mode == null) {
val remaining = longPressMs - (SystemClock.uptimeMillis() - downTime)
val event = if (remaining > 0) {
withTimeoutOrNull(remaining) { awaitPointerEvent() }
} else {
null
}
if (event == null) {
// 长按成立:按住不动(或微移 < slop)→ 查看模式
mode = DragMode.VIEW
break
}
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break // 快速抬起 = 点击,交给 detectTapGestures
val cur = change.position
val now = SystemClock.uptimeMillis()
// 帧瞬时速度:停顿后快甩的帧速度大 → 判为滚动;慢速拖动各帧速度都小 → 判为查看
val dxFrame = cur.x - lastPos.x
val dyFrame = cur.y - lastPos.y
val dtFrame = (now - lastTime).coerceAtLeast(1L)
val speedX = abs(dxFrame) / dtFrame
lastPos = cur
lastTime = now
totalDx += dxFrame
totalDy += dyFrame
// 首次超过触摸阈值时判定模式(本次手势内锁定):
// 横向主导且慢速 → VIEW(查看);否则 → SCROLL(滚动)
if (abs(totalDx) > slop || abs(totalDy) > slop) {
mode = if (abs(totalDx) > abs(totalDy) && speedX < viewSpeedThresholdPxPerMs) {
DragMode.VIEW
} else {
DragMode.SCROLL
}
if (mode == DragMode.VIEW) {
// 判决事件立即消费:否则同帧(Main pass leaf→root)会漏到父 scrollable,
// 其越过自身 slop 先滚一帧(约 0~4px 抖动)后才被 phase2 消费取消
change.consume()
}
}
}
if (mode == DragMode.VIEW) {
// 进入查看模式立即用最后已知位置更新指示器(长按超时瞬间就要显示,不等下一个事件)
updateIndicator(lastPos)
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
// 先消费再判抬起:up 若不消费会漏到 detectTapGestures 误触发 onTap 浮层
// (waitForUpOrCancellation 对 consumed up 返回 null → tap 取消),
// 与慢拖路径行为保持一致
updateIndicator(change.position)
change.consume() // 消费事件锁定图表滚动
if (!change.pressed) break // 抬起:保留最后预览的格子
}
} else if (mode == DragMode.SCROLL) {
// 滚动模式:清除上次预览的指示器,避免滚动后残留过期信息;
// 不消费事件,scrollable 正常滚动(含惯性)
indicatorDay = null
indicatorWeek = null
indicatorCol = -1
indicatorRow = -1
}
}
}
.pointerInput(state, cellStepPx, blockSizePx, gridWeeks) {
detectTapGestures(onTap = { offset ->
// 点击时清除滑动查看残留的预览(抬起后保留的指示器),避免与浮层同时显示
indicatorDay = null
indicatorWeek = null
indicatorCol = -1
indicatorRow = -1
// 点击落在格子间隙内时不触发(Minor:命中精度)
if (offset.x % cellStepPx >= blockSizePx || offset.y % cellStepPx >= blockSizePx) {
return@detectTapGestures
}
val col = (offset.x / cellStepPx).toInt()
val row = (offset.y / cellStepPx).toInt()
if (col < 0 || col >= gridWeeks.size || row < 0 || row >= rows) {
return@detectTapGestures
}
val cellCenterX = (col * cellStepPx + blockSizePx / 2f).roundToInt()
val cellCenterY = (row * cellStepPx + blockSizePx / 2f).roundToInt()
val anchor = IntOffset(canvasWindowPos.x + cellCenterX, canvasWindowPos.y + cellCenterY)
if (isWeekly) {
// 每周视图(柱状图):该列任意格子都对应同一周 → 点击触发该周浮层;
// 0 用量周也有 1 格 Level 0 浅色格,同样可点击(显示「使用了0token」)
val weekData = state.weeklyData.getOrNull(col) ?: return@detectTapGestures
onWeekClick(weekData, anchor)
} else {
// 每日/累计视图:点击 (col, row) 对应格子(空位不可点)
val cell = gridWeeks[col][row] ?: return@detectTapGestures
onDayClick(cell, anchor)
}
})
},
) {
// 绘制格子:
// - 每日/累计:7 行网格按天等级绘制,null 空位不绘制
// - 每周:离散柱状图——列=自然周,整列 7 格全部绘制:
// 底部 barHeight 格 = 该周等级颜色(柱:0 用量周 1 格 Level 0、非零周 2~7 格),
// 其余格 = Level 0 浅色(空白格不隐藏,网格完整);
// 选中周整列加主题色描边强调
if (isWeekly) {
state.weeklyData.forEachIndexed { weekIndex, weekData ->
val level = weekData.level.coerceIn(0, 5)
val barHeight = weekData.barHeight.coerceIn(1, 7)
val isSelected = weekData == selectedWeek
val x = weekIndex * cellStepPx
for (row in 0 until rows) {
val inBar = row >= rows - barHeight
val y = row * cellStepPx
drawRoundRect(
color = if (inBar) colors[level] else colors[0],
topLeft = Offset(x, y),
size = Size(blockSizePx, blockSizePx),
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
)
if (isSelected) {
drawRoundRect(
color = selectionColor,
topLeft = Offset(x, y),
size = Size(blockSizePx, blockSizePx),
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
style = Stroke(width = selectionStrokePx),
)
}
}
}
} else {
gridWeeks.forEachIndexed { weekIndex, week ->
week.forEachIndexed { dayIndex, dayData ->
if (dayData == null) return@forEachIndexed
val x = weekIndex * cellStepPx
val y = dayIndex * cellStepPx
drawRoundRect(
color = colors[dayData.level.coerceIn(0, 5)],
topLeft = Offset(x, y),
size = Size(blockSizePx, blockSizePx),
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
)
}
}
}
// 绘制月份标签(底部)
drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas
// labelY = 网格底部 + 间距 + 文字基线偏移
val labelY = gridHeightPx + labelGapPx - monthLabelPaint.ascent()
monthLabels.forEach { label ->
val x = label.weekIndex * cellStepPx
nativeCanvas.drawText(label.label, x, labelY, monthLabelPaint)
}
}
// 预览格子高亮(三种视图,所见即所得):手指所在格子加主题色边框。
// 防御性校验:指示器指向的格子数据仍存在(防未来"同一年份数据刷新"后残留过期高亮)
val indicatorValid = when {
indicatorWeek != null -> state.weeklyData.getOrNull(indicatorCol) != null
indicatorDay != null -> gridWeeks.getOrNull(indicatorCol)?.getOrNull(indicatorRow) != null
else -> false
}
if (indicatorValid &&
indicatorCol in 0 until cols && indicatorRow in 0 until rows
) {
drawRoundRect(
color = selectionColor,
topLeft = Offset(indicatorCol * cellStepPx, indicatorRow * cellStepPx),
size = Size(blockSizePx, blockSizePx),
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
style = Stroke(width = selectionStrokePx * 1.5f),
)
}
}
}
// 滑动查看读数条(三种视图):显示手指所在格子的数据(每日/累计=当天,每周=该周范围),
// 抬起后保留;固定高度占位,避免出现/消失导致图例上下跳动
Box(
modifier = Modifier
.fillMaxWidth()
.height(20.dp),
contentAlignment = Alignment.CenterStart,
) {
val d = indicatorDay
val w = indicatorWeek
val text = when {
d != null -> stringResource(
R.string.heatmap_day_used,
formatDateChinese(d.dayTs, state.useUtc8, state.currentYear, activeLocale),
formatTokenChinese(d.tokens, activeLocale),
)
w != null -> stringResource(
R.string.heatmap_week_used,
formatWeekRangeText(w, state.useUtc8, state.selectedYear, state.dailyData.lastOrNull()?.dayTs, activeLocale),
formatTokenChinese(w.tokens, activeLocale),
)
else -> null
}
if (text != null) {
Text(
text = text,
fontSize = 12.sp,
color = mutedColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.height(8.dp))
// ── 颜色图例(右下角,固定不随热力图横向滚动)──
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(lessText.ifEmpty { "Less" }, color = mutedColor, fontSize = 12.sp)
Spacer(Modifier.width(4.dp))
for (level in 0..5) {
Box(
modifier = Modifier
.size(blockSize)
.background(colors[level], RoundedCornerShape(cornerRadius)),
)
if (level < 5) {
Spacer(Modifier.width(gap))
}
}
Spacer(Modifier.width(4.dp))
Text(moreText.ifEmpty { "More" }, color = mutedColor, fontSize = 12.sp)
}
}
}

View File

@ -0,0 +1,808 @@
package com.rainy.token.ui.heatmap
import android.content.Context
import android.graphics.BitmapFactory
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.ui.theme.inkMuted
import java.io.File
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Token 活动热力图页面。
*
* Scaffold + TopAppBar 布局,包含视图切换器(每日/每周/累计)和 HeatmapCanvas。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HeatmapScreen(
onBack: () -> Unit,
viewModel: HeatmapViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// 应用内语言(LocaleManager 只覆写配置、不改变 Locale.getDefault(),故从配置取)
val activeLocale = LocalConfiguration.current.locales[0]
// ── 浮层状态 ──
var selectedDay: HeatmapDayData? by remember { mutableStateOf(null) }
var selectedWeek: HeatmapWeekData? by remember { mutableStateOf(null) }
// 浮层锚点:被点击格子中心在窗口中的位置
var popupAnchor by remember { mutableStateOf<IntOffset?>(null) }
// 浮层自身尺寸(用于锚点偏移计算)
var popupSize by remember { mutableStateOf(IntSize.Zero) }
val density = LocalDensity.current
// ── i18n 字符串 ──
val noDataText = stringResource(R.string.heatmap_no_data)
val lessText = stringResource(R.string.heatmap_legend_less)
val moreText = stringResource(R.string.heatmap_legend_more)
// ── 年份选择器状态 ──
var yearMenuExpanded by remember { mutableStateOf(false) }
val selectYearDesc = stringResource(R.string.heatmap_select_year)
// ── 个人资料状态(昵称/邮箱/头像路径,SharedPreferences 持久化)──
val context = LocalContext.current
val profilePrefs = remember { context.getSharedPreferences(PROFILE_PREFS, Context.MODE_PRIVATE) }
var nickname by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var avatarPath by remember { mutableStateOf<String?>(null) }
// 换头像后路径不变(固定文件名),用版本号强制触发头像重载
var avatarVersion by remember { mutableStateOf(0) }
var showProfileEdit by remember { mutableStateOf(false) }
var avatarMenuVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
nickname = profilePrefs.getString(KEY_NICKNAME, "").orEmpty()
email = profilePrefs.getString(KEY_EMAIL, "").orEmpty()
avatarPath = profilePrefs.getString(KEY_AVATAR_PATH, null)
}
// 系统相册选图 → 复制到应用私有目录(content URI 授权是临时的,必须保存持久化副本)
// 复制在 IO 线程执行(相册原图可达数 MB,避免阻塞主线程);
// 先写临时文件再 rename 覆盖,失败时不破坏已有头像
val scope = rememberCoroutineScope()
val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
val target = File(context.filesDir, AVATAR_FILE_NAME)
val tmp = File(context.filesDir, AVATAR_FILE_NAME + ".tmp")
val input = context.contentResolver.openInputStream(uri)
?: error("cannot open picked image")
input.use { i -> tmp.outputStream().use { o -> i.copyTo(o) } }
if (!tmp.renameTo(target)) {
tmp.delete()
error("cannot persist avatar")
}
target.absolutePath
}
}.onSuccess { path ->
avatarPath = path
avatarVersion++ // 路径相同(固定文件名),必须递增版本号触发头像重载
profilePrefs.edit().putString(KEY_AVATAR_PATH, path).apply()
}.onFailure { tmp ->
File(context.filesDir, AVATAR_FILE_NAME + ".tmp").delete()
}
}
}
LaunchedEffect(Unit) {
viewModel.load()
}
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.heatmap_title))
if (state.availableYears.isNotEmpty()) {
Spacer(Modifier.width(12.dp))
// 年份下拉选择器:默认"最近 365 天"(置顶),下方为具体年份
val selectRangeLabel = if (state.selectedYear == RECENT_YEAR) {
stringResource(R.string.heatmap_select_recent)
} else {
"${state.selectedYear}"
}
Text(
text = "$selectRangeLabel ▾",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { yearMenuExpanded = true }
.padding(horizontal = 6.dp, vertical = 4.dp)
.semantics { contentDescription = "$selectYearDesc: $selectRangeLabel" }
)
DropdownMenu(
expanded = yearMenuExpanded,
onDismissRequest = { yearMenuExpanded = false }
) {
// "最近 365 天"(默认选项,与具体年份区分:置顶 + 分隔线)
DropdownMenuItem(
text = { Text(stringResource(R.string.heatmap_select_recent)) },
trailingIcon = {
if (state.selectedYear == RECENT_YEAR) {
Icon(
Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(18.dp).semantics { selected = true },
)
}
},
onClick = {
yearMenuExpanded = false
viewModel.setYear(RECENT_YEAR)
// 切换范围时关闭浮层
selectedDay = null
selectedWeek = null
popupAnchor = null
}
)
HorizontalDivider()
state.availableYears.sortedDescending().forEach { year ->
DropdownMenuItem(
text = { Text("$year") },
trailingIcon = {
if (state.selectedYear == year) {
Icon(
Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(18.dp).semantics { selected = true },
)
}
},
onClick = {
yearMenuExpanded = false
viewModel.setYear(year)
// 切换年份时关闭浮层
selectedDay = null
selectedWeek = null
popupAnchor = null
}
)
}
}
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
// ── 个人资料卡片(页面最顶部:自定义头像 + 昵称/邮箱)──
ProfileCard(
nickname = nickname,
email = email,
avatarPath = avatarPath,
avatarVersion = avatarVersion,
onEdit = { showProfileEdit = true },
onAvatarClick = { avatarMenuVisible = true },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
)
// ── 活动洞察卡片(个人资料下方:总请求次数 + 最多请求时段 Top3)──
InsightsCard(
totalRequests = state.insights.totalRequests,
topHours = state.insights.topHours,
loading = state.loading,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
)
// ── 视图切换器 ──
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 12.dp)
) {
HeatmapViewMode.entries.forEach { mode ->
val label = when (mode) {
HeatmapViewMode.DAILY -> stringResource(R.string.heatmap_view_daily)
HeatmapViewMode.WEEKLY -> stringResource(R.string.heatmap_view_weekly)
HeatmapViewMode.CUMULATIVE -> stringResource(R.string.heatmap_view_cumulative)
}
val isSelected = state.viewMode == mode
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.onSurface else inkMuted(),
modifier = Modifier.clickable {
viewModel.setViewMode(mode)
// 切换视图时关闭浮层
selectedDay = null
selectedWeek = null
popupAnchor = null
}
)
}
}
// ── 年度统计(按所选年份,切换年份跟随变化)──
// loading 期间显示占位,避免全 0 造成"无数据"假象
val dash = "–"
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatItem(
label = stringResource(R.string.heatmap_stats_total),
value = if (state.loading) dash else formatTokenChinese(state.stats.totalTokens, activeLocale),
modifier = Modifier.weight(1f),
)
StatItem(
label = stringResource(R.string.heatmap_stats_peak),
value = if (state.loading) dash else formatTokenChinese(state.stats.peakTokens, activeLocale),
modifier = Modifier.weight(1f),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
StatItem(
label = stringResource(R.string.heatmap_stats_current_streak),
value = if (state.loading) {
dash
} else {
pluralStringResource(R.plurals.heatmap_stats_days, state.stats.currentStreak, state.stats.currentStreak)
},
modifier = Modifier.weight(1f),
)
StatItem(
label = stringResource(R.string.heatmap_stats_max_streak),
value = if (state.loading) {
dash
} else {
pluralStringResource(R.plurals.heatmap_stats_days, state.stats.maxStreak, state.stats.maxStreak)
},
modifier = Modifier.weight(1f),
)
}
}
// ── 热力图 Canvas(带 Crossfade 淡入淡出动画)──
Crossfade(
targetState = state.viewMode,
animationSpec = tween(150),
label = "heatmap_view"
) { mode ->
// 用 mode 参数构造视图快照:过渡期间新旧两个分支渲染各自模式,
// 否则两者都读最新 viewMode,动画会变成空操作
// 无障碍描述也在分支内按 mode 计算,避免淡出分支播报错误的模式
val desc = if (mode == HeatmapViewMode.WEEKLY) {
stringResource(R.string.heatmap_accessibility_desc_weekly, state.weeklyData.size)
} else {
stringResource(R.string.heatmap_accessibility_desc, state.dailyData.size)
}
HeatmapCanvas(
state = state.copy(viewMode = mode),
noDataText = noDataText,
lessText = lessText,
moreText = moreText,
accessibilityDesc = desc,
selectedWeek = selectedWeek,
onDayClick = { dayData, anchor ->
// 再次点击同一格子则关闭浮层(0 token 的天也可点击查看)
if (selectedDay == dayData) {
selectedDay = null
popupAnchor = null
} else {
selectedDay = dayData
selectedWeek = null
popupAnchor = anchor
}
},
onWeekClick = { weekData, anchor ->
if (selectedWeek == weekData) {
selectedWeek = null
popupAnchor = null
} else {
selectedWeek = weekData
selectedDay = null
popupAnchor = anchor
}
}
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
// ── 个人资料编辑对话框(昵称/邮箱均可完全自定义,留空=清除)──
if (showProfileEdit) {
var editNickname by remember { mutableStateOf(nickname) }
var editEmail by remember { mutableStateOf(email) }
ProfileEditDialog(
nickname = editNickname,
email = editEmail,
onNicknameChange = { editNickname = it },
onEmailChange = { editEmail = it },
onSave = {
nickname = editNickname
email = editEmail
profilePrefs.edit()
.putString(KEY_NICKNAME, editNickname)
.putString(KEY_EMAIL, editEmail)
.apply()
showProfileEdit = false
},
onDismiss = { showProfileEdit = false },
)
}
// ── 头像操作对话框(更换/移除)──
if (avatarMenuVisible) {
AlertDialog(
onDismissRequest = { avatarMenuVisible = false },
title = { Text(stringResource(R.string.heatmap_profile_avatar_change)) },
text = {
Column {
TextButton(onClick = {
avatarMenuVisible = false
avatarPicker.launch("image/*")
}) {
Text(stringResource(R.string.heatmap_profile_avatar_pick))
}
if (avatarPath != null) {
TextButton(onClick = {
avatarMenuVisible = false
avatarPath?.let { runCatching { File(it).delete() } }
avatarPath = null
profilePrefs.edit().remove(KEY_AVATAR_PATH).apply()
}) {
Text(stringResource(R.string.heatmap_profile_avatar_remove))
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { avatarMenuVisible = false }) {
Text(stringResource(R.string.heatmap_profile_cancel))
}
},
)
}
// ── 浮层 Popup(锚定到被点击格子的中心位置)──
val day = selectedDay
val week = selectedWeek
val anchor = popupAnchor
if (day != null || week != null) {
val useUtc8 = state.useUtc8
val popupText = when {
day != null -> stringResource(
R.string.heatmap_day_used,
formatDateChinese(day.dayTs, useUtc8, state.currentYear, activeLocale),
formatTokenChinese(day.tokens, activeLocale),
)
week != null -> stringResource(
R.string.heatmap_week_used,
formatWeekRangeText(week, useUtc8, state.selectedYear, state.dailyData.lastOrNull()?.dayTs, activeLocale),
formatTokenChinese(week.tokens, activeLocale),
)
else -> ""
}
// 浮层相对锚点的偏移:水平居中、优先显示在格子上方 8dp 处;
// 上方空间不足时翻转到下方;x/y 均 clamp 在窗口内
val gapPx = with(density) { 8.dp.toPx() }.roundToInt()
val edgePaddingPx = with(density) { 8.dp.toPx() }.roundToInt()
val configuration = LocalConfiguration.current
val windowWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }.roundToInt()
val windowHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }.roundToInt()
val maxX = (windowWidthPx - popupSize.width - edgePaddingPx).coerceAtLeast(edgePaddingPx)
val maxY = (windowHeightPx - popupSize.height - edgePaddingPx).coerceAtLeast(edgePaddingPx)
val popupOffset = if (anchor != null) {
val x = (anchor.x - popupSize.width / 2).coerceIn(edgePaddingPx, maxX)
val y = if (anchor.y - popupSize.height - gapPx >= edgePaddingPx) {
// 上方放得下:显示在格子上方
(anchor.y - popupSize.height - gapPx).coerceIn(edgePaddingPx, maxY)
} else {
// 上方放不下:翻转到格子下方
(anchor.y + gapPx).coerceIn(edgePaddingPx, maxY)
}
IntOffset(x, y)
} else {
IntOffset.Zero
}
Popup(
alignment = Alignment.TopStart,
offset = popupOffset,
onDismissRequest = {
selectedDay = null
selectedWeek = null
popupAnchor = null
},
// 不拦截图表交互:浮层不获取焦点,点击/滑动直接作用于图表,
// 点击其他格子即切换浮层,再次点击同一格子关闭
properties = PopupProperties(focusable = false)
) {
Surface(
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp,
modifier = Modifier
.padding(horizontal = 8.dp)
.onGloballyPositioned { popupSize = it.size }
) {
Text(
text = popupText,
fontSize = 14.sp,
modifier = Modifier.padding(12.dp)
)
}
}
}
}
// ── 年度统计卡片(累计/峰值/当前连续/最长连续)──
@Composable
private fun StatItem(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// ── 个人资料卡片(页面最顶部:自定义头像 + 昵称/邮箱)──
@Composable
private fun ProfileCard(
nickname: String,
email: String,
avatarPath: String?,
avatarVersion: Int,
onEdit: () -> Unit,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val emptyText = stringResource(R.string.heatmap_profile_empty)
Surface(
modifier = modifier,
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.padding(14.dp)) {
Text(
text = stringResource(R.string.heatmap_profile_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(10.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
ProfileAvatar(
avatarPath = avatarPath,
avatarVersion = avatarVersion,
nickname = nickname,
onClick = onAvatarClick,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = nickname.ifBlank { emptyText },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
text = email.ifBlank { emptyText },
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onEdit) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.heatmap_profile_edit))
}
}
}
}
}
// ── 自定义头像(圆形裁剪;未设置时显示昵称首字符占位)──
@Composable
private fun ProfileAvatar(
avatarPath: String?,
avatarVersion: Int,
nickname: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// 本地文件解码在 IO 线程;key=(路径,版本号):换头像后路径不变,靠版本号触发重载
// 先读尺寸再降采样解码(头像仅 56dp 显示,全尺寸解码相册大图会 OOM)
val avatar by produceState<ImageBitmap?>(initialValue = null, avatarPath, avatarVersion) {
value = avatarPath?.let { path ->
withContext(Dispatchers.IO) {
runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
var sample = 1
while (bounds.outWidth / sample > 512 || bounds.outHeight / sample > 512) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeFile(path, opts)?.asImageBitmap()
}.getOrNull()
}
}
}
val avatarDesc = stringResource(R.string.heatmap_profile_avatar_change)
Box(
modifier = modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onClick)
.semantics { contentDescription = avatarDesc },
contentAlignment = Alignment.Center,
) {
val bmp = avatar
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
// 按 code point 取首字符,避免 emoji(代理对)被截成半个字符显示乱码
val trimmed = nickname.trim()
val initial = if (trimmed.isEmpty()) null else String(Character.toChars(trimmed.codePointAt(0)))
if (initial != null) {
Text(
text = initial,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = inkMuted(),
)
} else {
Icon(Icons.Filled.Person, contentDescription = null, tint = inkMuted())
}
}
}
}
// ── 个人资料编辑对话框(昵称/邮箱均可完全自定义)──
@Composable
private fun ProfileEditDialog(
nickname: String,
email: String,
onNicknameChange: (String) -> Unit,
onEmailChange: (String) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.heatmap_profile_dialog_title)) },
text = {
Column {
OutlinedTextField(
value = nickname,
onValueChange = onNicknameChange,
label = { Text(stringResource(R.string.heatmap_profile_nickname)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = email,
onValueChange = onEmailChange,
label = { Text(stringResource(R.string.heatmap_profile_email)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(onClick = onSave) { Text(stringResource(R.string.heatmap_profile_save)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.heatmap_profile_cancel)) }
},
)
}
// ── 活动洞察卡片(总请求次数 + 最多请求时段 Top3,全量历史口径)──
@Composable
private fun InsightsCard(
totalRequests: Int,
topHours: List<Int>,
loading: Boolean,
modifier: Modifier = Modifier,
) {
val dash = "–"
Surface(
modifier = modifier,
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.padding(14.dp)) {
Text(
text = stringResource(R.string.heatmap_insights_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.heatmap_insights_requests),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.weight(1f),
)
Text(
text = if (loading) {
dash
} else {
pluralStringResource(R.plurals.heatmap_insights_request_count, totalRequests, totalRequests)
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(6.dp))
Text(
text = stringResource(R.string.heatmap_insights_peak_hours),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
)
Spacer(Modifier.height(6.dp))
// 固定 3 个时段槽位,不足的显示占位,避免布局跳动
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(3) { i ->
val hour = topHours.getOrNull(i)
Text(
text = if (loading || hour == null) {
dash
} else {
stringResource(R.string.heatmap_insight_hour_range, hour, hour + 1)
},
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
}
}
}
// ── 个人资料持久化 ──
private const val PROFILE_PREFS = "heatmap_profile"
private const val KEY_NICKNAME = "nickname"
private const val KEY_EMAIL = "email"
private const val KEY_AVATAR_PATH = "avatar_path"
private const val AVATAR_FILE_NAME = "heatmap_avatar.jpg"

View File

@ -0,0 +1,593 @@
package com.rainy.token.ui.heatmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.local.ChartSettingsStore
import com.rainy.token.data.local.UsageCache
import com.rainy.token.data.local.UsageRecord
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.Instant
import java.time.ZoneOffset
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Provider
// ── 公共数据类 ──────────────────────────────────────────────
/** 热力图视图模式 */
enum class HeatmapViewMode { DAILY, WEEKLY, CUMULATIVE }
/** 年份选择器中的"最近"窗口(最近 365 天)sentinel:selectedYear == RECENT_YEAR 表示最近模式 */
internal const val RECENT_YEAR = -1
/** 每日 / 累计视图的单格数据 */
data class HeatmapDayData(
val dayTs: Long, // UTC 日期时间戳(即天分桶的时间戳)
val tokens: Long, // 当天 token 数(DAILY)或累计 token 数(CUMULATIVE)
val level: Int, // 0-5 颜色等级
)
/** 每周视图的单格数据 */
data class HeatmapWeekData(
val weekStartTs: Long, // 该周起始日时间戳
val tokens: Long, // 该周 7 天累计 token 数
val level: Int, // 0-5 颜色等级
val barHeight: Int, // 柱状图高度(格数):0 用量周=1(Level 0 浅色格,可见可点),非零周=2~7(按 token 排名比例)
)
/** 年度统计指标(按所选年份的每日数据计算,切换年份跟随变化) */
data class HeatmapStats(
val totalTokens: Long = 0L, // 累计 token 数(当年全部天之和)
val peakTokens: Long = 0L, // 峰值 token 数(单日最大;当年无数据=0)
val currentStreak: Int = 0, // 当前连续天数(从最后一天往前数连续 >0 的天数;末尾为 0 的天跳过,从最后一个非零天往前数)
val maxStreak: Int = 0, // 最长连续天数(当年内连续 >0 的最大天数)
)
/**
* 活动洞察(基于全部历史记录,不随年份切换变化)。
*
* - totalRequests:总请求次数(= 记录条数,一条 UsageRecord 即一次请求)
* - topHours:请求最多的时段(按小时 0-23),降序取前 3;
* 并列时小时数小的优先;不足 3 个时列表较短
*/
data class HeatmapInsights(
val totalRequests: Int = 0,
val topHours: List<Int> = emptyList(),
)
/** UI 状态 */
data class HeatmapUiState(
val loading: Boolean = true,
val viewMode: HeatmapViewMode = HeatmapViewMode.DAILY,
val selectedYear: Int = 0, // 当前选中范围:0=尚未加载,RECENT_YEAR(-1)=最近365天,>0=具体年份
val currentYear: Int = 0, // 今年(浮层日期判断是否显示年份用)
val availableYears: List<Int> = emptyList(), // 可选年份(最早数据年份..今年,降序展示)
val dailyData: List<HeatmapDayData> = emptyList(),
val weeklyData: List<HeatmapWeekData> = emptyList(),
val cumulativeData: List<HeatmapDayData> = emptyList(),
val colorLevels: IntArray = IntArray(6), // [0, p25, p50, p75, p95, max]
val stats: HeatmapStats = HeatmapStats(), // 年度统计(累计/峰值/当前连续/最长连续)
val insights: HeatmapInsights = HeatmapInsights(), // 活动洞察(全量记录:总请求次数 + 最多请求时段 Top3)
val useUtc8: Boolean = false, // 与全局图表设置一致:true=UTC+8 分桶,false=UTC
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is HeatmapUiState) return false
return loading == other.loading &&
viewMode == other.viewMode &&
selectedYear == other.selectedYear &&
currentYear == other.currentYear &&
availableYears == other.availableYears &&
dailyData == other.dailyData &&
weeklyData == other.weeklyData &&
cumulativeData == other.cumulativeData &&
colorLevels.contentEquals(other.colorLevels) &&
stats == other.stats &&
insights == other.insights &&
useUtc8 == other.useUtc8
}
override fun hashCode(): Int {
var result = loading.hashCode()
result = 31 * result + viewMode.hashCode()
result = 31 * result + selectedYear
result = 31 * result + currentYear
result = 31 * result + availableYears.hashCode()
result = 31 * result + dailyData.hashCode()
result = 31 * result + weeklyData.hashCode()
result = 31 * result + cumulativeData.hashCode()
result = 31 * result + colorLevels.contentHashCode()
result = 31 * result + stats.hashCode()
result = 31 * result + insights.hashCode()
result = 31 * result + useUtc8.hashCode()
return result
}
}
/**
* 分位阈值与颜色等级映射。
*
* thresholds[0..5] 存 Long 阈值:
* - Level 0: value == 0
* - Level 1: 0 < value ≤ P25
* - Level 2: P25 < value ≤ P50
* - Level 3: P50 < value ≤ P75
* - Level 4: P75 < value ≤ P95
* - Level 5: value > P95
*
* 边界情况(非零天数 < 2 或所有非零值相同)时,所有非零值设为 Level 3。
*/
internal data class QuantileLevels(val thresholds: LongArray) {
fun getColorLevel(value: Long): Int {
if (value <= 0L) return 0
for (level in 1..5) {
if (value <= thresholds[level]) return level
}
return 5
}
fun toIntArray(): IntArray = IntArray(6) { idx ->
val v = thresholds[idx]
if (v > Int.MAX_VALUE) Int.MAX_VALUE else v.toInt()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is QuantileLevels) return false
return thresholds.contentEquals(other.thresholds)
}
override fun hashCode(): Int = thresholds.contentHashCode()
}
// ── ViewModel ──────────────────────────────────────────────
@HiltViewModel
class HeatmapViewModel @Inject constructor(
private val cacheProvider: Provider<UsageCache>,
private val credentialRepository: CredentialRepository,
private val chartSettingsStore: ChartSettingsStore,
) : ViewModel() {
private val _uiState = MutableStateFlow(HeatmapUiState())
val uiState: StateFlow<HeatmapUiState> = _uiState.asStateFlow()
private var loaded = false
// 全量记录缓存(仅在 load 时拉取一次,切换年份/视图时复用)
private var records: List<UsageRecord> = emptyList()
private var useUtc8 = false
// 按年缓存三视图计算结果,切换年份不必重复计算(跨线程读写,用并发容器)
private val yearResults = ConcurrentHashMap<Int, AllViewsResult>()
// 最近一次请求的年份(防止快速连续切换时旧协程后完成覆盖新选择)
private var latestRequestedYear = 0
/**
* Composable 层在 LaunchedEffect 中调用一次即可。
* 首次加载拉取全量记录并计算默认年份(今年)的三视图数据。
*/
fun load() {
if (loaded) return
loaded = true
viewModelScope.launch { loadInternal() }
}
/** 切换视图模式(仅改 State,不重新加载数据) */
fun setViewMode(mode: HeatmapViewMode) {
val result = yearResults[_uiState.value.selectedYear]
val levels = result?.let {
when (mode) {
HeatmapViewMode.DAILY -> it.dailyLevels
HeatmapViewMode.WEEKLY -> it.weeklyLevels
HeatmapViewMode.CUMULATIVE -> it.cumulativeLevels
}
}
_uiState.update { it.copy(viewMode = mode, colorLevels = levels?.toIntArray() ?: it.colorLevels) }
}
/** 切换窗口范围(年份或最近365天):用缓存的记录按所选范围重新聚合三视图 */
fun setYear(year: Int) {
// guard 用 latestRequestedYear(最后一次请求意图)而非已提交的 selectedYear:
// 避免"在途切换未提交时重复点击导致最后一次意图被丢弃"的竞态
if (year == latestRequestedYear || records.isEmpty()) return
latestRequestedYear = year
viewModelScope.launch {
val cached = yearResults[year]
// 最近 365 天窗口以计算时刻的 todayTs 为锚:页面跨天常驻时缓存已过期(窗口末日在昨天),需重算
val stale = cached != null && year == RECENT_YEAR && cached.dailyData.lastOrNull()?.dayTs != todayTsOf()
val result = if (cached == null || stale) {
withContext(Dispatchers.Default) {
computeAllViews(records, useUtc8, year).also { yearResults[year] = it }
}
} else {
cached
}
// 期间用户又切了别的范围:丢弃过期结果,避免旧协程覆盖新选择
if (year != latestRequestedYear) return@launch
val mode = _uiState.value.viewMode
val levels = when (mode) {
HeatmapViewMode.DAILY -> result.dailyLevels
HeatmapViewMode.WEEKLY -> result.weeklyLevels
HeatmapViewMode.CUMULATIVE -> result.cumulativeLevels
}
_uiState.update {
it.copy(
selectedYear = year,
dailyData = result.dailyData,
weeklyData = result.weeklyData,
cumulativeData = result.cumulativeData,
colorLevels = levels.toIntArray(),
stats = computeStats(result.dailyData),
)
}
}
}
/** 当前时区下的今天自然日零点(缓存新鲜度校验用) */
private fun todayTsOf(): Long {
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
return startOfDayTs(System.currentTimeMillis(), zone)
}
// ── 内部逻辑 ────────────────────────────────────────────
private suspend fun workspaceId(): String? {
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
}
private suspend fun loadInternal() {
try {
val wid = workspaceId() ?: run {
_uiState.update { it.copy(loading = false) }
return
}
val cache = cacheProvider.get()
// 与全局图表设置一致:读取 UTC+8 / UTC 分桶偏好
useUtc8 = chartSettingsStore.useUtc8Flow.first()
// 拉取全量记录 + 计算可选年份(在 IO 线程,避免主线程遍历大列表)
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val currentYear = Instant.now().atOffset(zone).year
val (fetched, availableYears) = withContext(Dispatchers.Default) {
val all = cache.getRecords(wid)
all to computeAvailableYears(all, zone, currentYear)
}
records = fetched
latestRequestedYear = RECENT_YEAR
// 活动洞察基于全量历史记录(不随年份变化),在后台线程计算(万级记录仅数 ms)
val insights = withContext(Dispatchers.Default) { computeInsights(fetched, useUtc8) }
// 所有重操作放在 Dispatchers.Default 上;默认窗口 = 最近 365 天(RECENT_YEAR)
val result = withContext(Dispatchers.Default) {
computeAllViews(fetched, useUtc8, RECENT_YEAR).also { yearResults[RECENT_YEAR] = it }
}
// 重操作完成后按当前视图模式写入对应的分位阈值(加载期间可能已切换视图)
val mode = _uiState.value.viewMode
val levels = when (mode) {
HeatmapViewMode.DAILY -> result.dailyLevels
HeatmapViewMode.WEEKLY -> result.weeklyLevels
HeatmapViewMode.CUMULATIVE -> result.cumulativeLevels
}
_uiState.update {
it.copy(
loading = false,
selectedYear = RECENT_YEAR,
currentYear = currentYear,
availableYears = availableYears,
useUtc8 = useUtc8,
dailyData = result.dailyData,
weeklyData = result.weeklyData,
cumulativeData = result.cumulativeData,
colorLevels = levels.toIntArray(),
stats = computeStats(result.dailyData),
insights = insights,
)
}
} catch (e: Exception) {
_uiState.update { it.copy(loading = false) }
}
}
/** 可选年份列表:最早有数据的年份 ~ 今年;无数据时只有今年 */
private fun computeAvailableYears(records: List<UsageRecord>, zone: ZoneOffset, currentYear: Int): List<Int> {
val minYear = records.minOfOrNull { Instant.ofEpochMilli(it.timeCreated).atOffset(zone).year }
?: currentYear
return (minYear..currentYear).toList()
}
// ── 聚合计算 ────────────────────────────────────────────
companion object {
private const val DAY_MS = 86_400_000L
/**
* 按指定窗口从全部记录计算三种视图数据 + 颜色等级阈值。
*
* 窗口类型由 [year] 决定:
* - [RECENT_YEAR](-1):最近 365 天(今天往前 364 天 ~ 今天,含今天共 365 天),默认窗口
* - 今年(year == 当前自然年):1月1日 ~ 今天
* - 往年:1月1日 ~ 12月31日(完整自然年)
* - 周视图:周日对齐,第一列为窗口首日所在周(可能含窗口前空位),
* 最后一列为结束日所在周(最近/今年=今天所在周;往年可能跨到次年 1 月初),
* 列数按窗口浮动(约 52~54 列),不丢数据
* - 三种视图的分位数均按所选窗口独立计算(颜色随窗口数据变化是预期行为)
*
* @param useUtc8 true=按 UTC+8 零点分桶(与全局图表设置一致),false=按 UTC 零点分桶
* @param year 目标自然年,或 [RECENT_YEAR] 表示最近 365 天
*/
internal fun computeAllViews(records: List<UsageRecord>, useUtc8: Boolean = false, year: Int): AllViewsResult {
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val now = System.currentTimeMillis()
val todayTs = startOfDayTs(now, zone)
val currentYear = Instant.ofEpochMilli(now).atOffset(zone).year
val isCurrentYear = year == currentYear
val isRecent = year == RECENT_YEAR
// 1. 窗口边界(该时区下的自然日零点)
val windowStartTs = if (isRecent) {
todayTs - 364 * DAY_MS
} else {
Instant.parse("${year}-01-01T00:00:00Z")
.atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
}
val windowEndTs = if (isRecent || isCurrentYear) {
todayTs
} else {
Instant.parse("${year}-12-31T00:00:00Z")
.atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
}
val totalDays = ((windowEndTs - windowStartTs) / DAY_MS).toInt() + 1
// 2. 按天分桶聚合 token 数(按所选时区的自然日零点切日)
val dayTokenMap = HashMap<Long, Long>()
for (r in records) {
val dayTs = startOfDayTs(r.timeCreated, zone)
if (dayTs < windowStartTs || dayTs > windowEndTs) continue
val tokens = r.inputTokens + r.cacheReadTokens + r.reasoningTokens + r.outputTokens
dayTokenMap.merge(dayTs, tokens, Long::plus)
}
// 3. 构建完整的每日数据列表(窗口首日 ~ 结束日)
val dailyRaw = ArrayList<HeatmapDayData>(totalDays)
for (i in 0 until totalDays) {
val ts = windowStartTs + i * DAY_MS
val tokens = dayTokenMap[ts] ?: 0L
dailyRaw.add(HeatmapDayData(dayTs = ts, tokens = tokens, level = 0))
}
// 4. 计算每日视图颜色等级
val dailyLevels = computeQuantileLevels(dailyRaw.map { it.tokens })
val dailyData = dailyRaw.map { it.copy(level = dailyLevels.getColorLevel(it.tokens)) }
// 5. 每周视图:周日对齐,第一列=窗口首日所在周,最后一列=结束日所在周
val firstWeekStart = startOfWeekTs(windowStartTs, zone)
val lastWeekStart = startOfWeekTs(windowEndTs, zone)
val (weeklyData, weeklyLevels) = buildWeeklyData(dailyRaw, firstWeekStart, lastWeekStart)
// 6. 累计视图:从窗口首日到结束日的累计总和
var cumulativeSum = 0L
val cumulativeRaw = ArrayList<HeatmapDayData>(totalDays)
for (d in dailyRaw) {
cumulativeSum += d.tokens
cumulativeRaw.add(HeatmapDayData(dayTs = d.dayTs, tokens = cumulativeSum, level = 0))
}
val cumulativeLevels = computeQuantileLevels(cumulativeRaw.map { it.tokens })
val cumulativeData = cumulativeRaw.map { it.copy(level = cumulativeLevels.getColorLevel(it.tokens)) }
// 保存三种视图各自的分位阈值,供切换视图时更新图例
return AllViewsResult(dailyData, weeklyData, cumulativeData, dailyLevels, weeklyLevels, cumulativeLevels)
}
/**
* 构建每周视图数据:周日对齐,从 firstWeekStart 到 lastWeekStart(含)每周一列。
*
* 每列聚合该周落在 [yearStartTs, yearEndTs] 范围内的天数(dailyRaw 只含年内天数,
* 首列跨前一年 12 月的部分、末列跨次年 1 月初的部分天然不计入),
* 周总量决定该列统一的热度等级;渲染层(HeatmapCanvas)按 barHeight
* 从底部向上绘制柱状图(0 用量周=1 格浅色,非零周=2~7 格)。
*/
private fun buildWeeklyData(
dailyRaw: List<HeatmapDayData>,
firstWeekStart: Long,
lastWeekStart: Long,
): Pair<List<HeatmapWeekData>, QuantileLevels> {
val numWeeks = ((lastWeekStart - firstWeekStart) / (7L * DAY_MS)).toInt() + 1
val weeks = ArrayList<HeatmapWeekData>(numWeeks)
for (w in 0 until numWeeks) {
val ws = firstWeekStart + w * 7L * DAY_MS
val we = ws + 7L * DAY_MS
var weekTokens = 0L
for (d in dailyRaw) {
if (d.dayTs >= ws && d.dayTs < we) weekTokens += d.tokens
}
weeks.add(HeatmapWeekData(
weekStartTs = ws,
tokens = weekTokens,
level = 0,
barHeight = 1,
))
}
// 颜色等级:6 级分位数(供颜色与图例使用)
val weekLevels = computeQuantileLevels(weeks.map { it.tokens })
// 柱高:非零周按 token 去重排序的排名比例映射 2~7 格(排名不同高度必不同),
// 0 用量周固定 1 格(Level 0 浅色,可见可点击)
val barHeights = computeBarHeights(weeks.map { it.tokens })
return weeks.mapIndexed { i, w ->
w.copy(level = weekLevels.getColorLevel(w.tokens), barHeight = barHeights[i])
} to weekLevels
}
/**
* 计算每周视图的柱状图高度(格数)。
*
* - 0 用量周:1 格(Level 0 浅色格,作为「空白周」的可见标记,不隐藏)
* - 非零周:按 token 值去重排序,排名比例映射到 2~7 格:
* height = 2 + rankIndex * 5 / (uniqueCount - 1)(rankIndex 0-based)
* 唯一非零值只有 1 个时该周为 7 格(满柱)
*
* 用排名比例而非分位档,保证不同量级的周(如 3.7亿 vs 10.7亿)高度不同。
*/
private fun computeBarHeights(tokens: List<Long>): IntArray {
val uniqueNonZero = tokens.filter { it > 0L }.distinct().sorted()
val n = uniqueNonZero.size
return IntArray(tokens.size) { i ->
val t = tokens[i]
when {
t <= 0L -> 1
n == 1 -> 7
else -> 2 + uniqueNonZero.indexOf(t) * 5 / (n - 1)
}
}
}
/**
* 计算年度统计指标(基于所选年份的完整日序列,dailyRaw 含 0 token 的天)。
*
* - totalTokens:全部天之和
* - peakTokens:单日最大 token(当年无数据=0)
* - currentStreak:从最后一天往前数连续 >0 的天数;结尾为 0 的天(如当天尚未产生 token)跳过,
* 从最后一个非零天往前数(全部为 0 则=0)
* - maxStreak:当年内连续 >0 的最大天数
*/
internal fun computeStats(dailyData: List<HeatmapDayData>): HeatmapStats {
var total = 0L
var peak = 0L
var maxStreak = 0
var run = 0
for (d in dailyData) {
total += d.tokens
if (d.tokens > peak) peak = d.tokens
if (d.tokens > 0L) run++ else run = 0
if (run > maxStreak) maxStreak = run
}
// 当前连续天数:最后一天即使为 0(当天尚未产生 token)也计入连续——跳过结尾的 0,
// 从最后一个非零天往前数连续 >0 的天数;全部为 0 时=0
var currentStreak = 0
var i = dailyData.size - 1
while (i >= 0 && dailyData[i].tokens == 0L) i--
while (i >= 0 && dailyData[i].tokens > 0L) {
currentStreak++
i--
}
return HeatmapStats(total, peak, currentStreak, maxStreak)
}
/**
* 计算活动洞察(基于全部历史记录,不随年份变化)。
*
* - totalRequests:总请求次数 = 记录条数(一条 UsageRecord 即一次请求)
* - topHours:请求最多的时段(按小时 0-23),降序取前 3;
* 并列时小时数小的优先;不足 3 个时列表较短;无数据时为空列表
*
* 小时按 useUtc8 对应的时区(UTC+8 / UTC)计算,与热力图分桶口径一致。
*/
internal fun computeInsights(records: List<UsageRecord>, useUtc8: Boolean = false): HeatmapInsights {
if (records.isEmpty()) return HeatmapInsights()
val offsetMs = if (useUtc8) 8 * 3_600_000L else 0L
val hourCounts = IntArray(24)
for (r in records) {
// 纯算术取小时(避免逐条分配 Instant/OffsetDateTime):
// (时间戳 + 时区偏移) 对一天取余 → 所在小时(ts 恒为正,无负数取模问题)
val hour = (((r.timeCreated + offsetMs) % 86_400_000L) / 3_600_000L).toInt()
hourCounts[hour]++
}
val topHours = hourCounts.indices
.sortedWith(compareByDescending<Int> { hourCounts[it] }.thenBy { it })
.filter { hourCounts[it] > 0 }
.take(3)
return HeatmapInsights(totalRequests = records.size, topHours = topHours)
}
/** 返回时间戳在指定时区下的自然日零点(epoch 毫秒) */
private fun startOfDayTs(ts: Long, zone: ZoneOffset): Long {
return Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
}
/** 返回时间戳在指定时区下所在周的周日零点(0=Sunday) */
private fun startOfWeekTs(ts: Long, zone: ZoneOffset): Long {
val dow = dayOfWeekFromTs(ts, zone)
return startOfDayTs(ts, zone) - dow * DAY_MS
}
/** 时间戳在指定时区下的星期几 (0=Sunday, 6=Saturday) */
private fun dayOfWeekFromTs(ts: Long, zone: ZoneOffset): Int {
val dow = Instant.ofEpochMilli(ts).atOffset(zone).dayOfWeek.value
return dow % 7 // Sunday=7 → 0
}
// ── 分位数颜色等级计算 ──────────────────────────────
/**
* 使用 nearest-rank 分位数法计算 P25/P50/P75/P95 阈值。
*
* 边界情况:
* - 非零天数 < 2 → 全部设为 Level 3
* - 所有非零值相同 → 全部设为 Level 3
*
* 返回 [QuantileLevels],内部 thresholds 为 LongArray(6):
* index 0 = 0(Level 0),index 1-5 分别存 P25/P50/P75/P95/MaxValue。
*/
internal fun computeQuantileLevels(values: List<Long>): QuantileLevels {
// 收集所有非零值
val nonZero = values.filter { it > 0L }
// 边界:非零值 < 2 或全部相同 → 全部设为 Level 3
if (nonZero.size < 2 || nonZero.toSet().size == 1) {
// thresholds[3] = Long.MAX_VALUE 使得 0 < value ≤ MAX → Level 3
val t = LongArray(6)
t[3] = Long.MAX_VALUE
return QuantileLevels(t)
}
val sorted = nonZero.sorted()
val n = sorted.size
val p25 = nearestRank(sorted, n, 0.25)
val p50 = nearestRank(sorted, n, 0.50)
val p75 = nearestRank(sorted, n, 0.75)
val p95 = nearestRank(sorted, n, 0.95)
// LongArray(6): [0, p25, p50, p75, p95, MaxValue]
val thresholds = LongArray(6)
thresholds[0] = 0L
thresholds[1] = p25
thresholds[2] = p50
thresholds[3] = p75
thresholds[4] = p95
thresholds[5] = Long.MAX_VALUE
return QuantileLevels(thresholds)
}
private fun nearestRank(sorted: List<Long>, n: Int, percentile: Double): Long {
val rank = (Math.ceil(n * percentile).toInt() - 1).coerceIn(0, n - 1)
return sorted[rank]
}
}
}
// ── 辅助类型 ──────────────────────────────────────────────
/** computeAllViews 返回值 */
internal data class AllViewsResult(
val dailyData: List<HeatmapDayData>,
val weeklyData: List<HeatmapWeekData>,
val cumulativeData: List<HeatmapDayData>,
val dailyLevels: QuantileLevels,
val weeklyLevels: QuantileLevels,
val cumulativeLevels: QuantileLevels,
)

View File

@ -0,0 +1,503 @@
package com.rainy.token.ui.servicedetail
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.rainy.token.R
import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.ui.theme.inkMuted
import java.util.Locale
/**
* Sub2API 面板管理卡:Key 列表 / 新建 / 分组 / 兑换 / 低余额提醒。
*
* 首页 Sub2API 卡片与 Sub2API 详情页共用(有面板 Token 时才能加载;
* API Key 模式只读余额,进入后提示先登录面板)。
*
* 交互细节:
* - Key 列表只展示掩码(sk-…后缀),完整 Key 通过「复制」按钮复制并 Toast 反馈;
* - 新建 Key 成功后弹窗展示完整 Key,避免把明文直接铺在长消息里;
* - 兑换/切换分组等操作结果以 Toast + 行内文案双重反馈。
*/
@Composable
internal fun Sub2ApiPanelManagement(
balance: ServiceBalance?,
keysState: PanelKeysState,
groups: List<Sub2ApiRepository.PanelGroup>,
actionMessage: String?,
createdKey: String? = null,
onLoadKeys: () -> Unit,
onLoadGroups: () -> Unit,
onCreateKey: (String, Long?) -> Unit,
onSwitchGroup: (Long, Long) -> Unit,
onDeleteKey: (Long) -> Unit,
onRedeem: (String) -> Unit,
onConsumeMessage: () -> Unit,
onConsumeCreatedKey: () -> Unit = {},
/** 弹窗场景由 AlertDialog 的 title 槽展示标题,可关闭内部标题避免重复 */
showHeader: Boolean = true
) {
val context = LocalContext.current
var showCreate by remember { mutableStateOf(false) }
var showRedeem by remember { mutableStateOf(false) }
var switchForKeyId by remember { mutableStateOf<Long?>(null) }
// 待删除的 Key:先弹确认框(删除不可逆,避免列表里误触)
var deleteForKeyId by remember { mutableStateOf<Long?>(null) }
val groupMap = remember(groups) { groups.associateBy { it.id } }
val copiedToast = stringResource(R.string.sub2_panel_copied)
// 进入卡片即自动拉取一次 Key 列表与分组(Idle 态),省掉一次手动点击
LaunchedEffect(Unit) {
if (keysState is PanelKeysState.Idle) {
onLoadKeys()
onLoadGroups()
}
}
// 最近一次操作结果:用于行内保留展示(Toast 消费后消息会被置空,这里留一份快照)
var lastResult by remember { mutableStateOf<String?>(null) }
// 操作结果(新建/兑换/切组)用 Toast 提醒:避免结果消息藏在长页面里看不见。
// 消费后再置空,保证下一次「同样的结果」(如连续两次切换分组成功)仍能再次提示。
LaunchedEffect(actionMessage) {
val msg = actionMessage ?: return@LaunchedEffect
lastResult = msg
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
onConsumeMessage()
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
if (showHeader) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.sub2_panel_manage_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = { onLoadKeys(); onLoadGroups() }) {
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.action_refresh))
}
}
}
// showHeader=false 时不渲染任何标题/刷新行:
// 弹窗场景由调用方把标题与刷新按钮放在同一行(见 DashboardScreen 的 title 槽),
// 这里再放一行会让刷新按钮掉到标题下面,视觉上多出一条空行。
(actionMessage ?: lastResult)?.let { msg ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = msg.take(160) + if (msg.length > 160) "…" else "",
style = MaterialTheme.typography.bodySmall,
color = if (msg.startsWith("已创建") || msg.startsWith("分组已") || msg.startsWith("兑换成功"))
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
}
val amount = balance?.amount ?: 0.0
if (amount > 0.0 && amount < 5.0) {
Spacer(modifier = Modifier.height(10.dp))
Text(
text = stringResource(
R.string.sub2_panel_low_balance,
String.format(Locale.getDefault(), "%.2f", amount)
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f))
.padding(horizontal = 10.dp, vertical = 8.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
// 主要操作(新建 Key / 兑换卡密)固定在列表上方:
// Key 数量多时列表很长,放在底部会被埋在滚动区尽头,不易发现。
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { onLoadGroups(); showCreate = true }, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.sub2_panel_create))
}
OutlinedButton(onClick = { showRedeem = true }, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.sub2_panel_redeem))
}
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.sub2_panel_keys_header),
style = MaterialTheme.typography.labelLarge,
color = inkMuted()
)
Spacer(modifier = Modifier.height(4.dp))
when (keysState) {
is PanelKeysState.Idle -> {
OutlinedButton(onClick = onLoadKeys, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.sub2_panel_load_keys))
}
}
is PanelKeysState.Loading -> {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.common_loading), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
is PanelKeysState.Error -> {
Column {
Text(keysState.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
TextButton(onClick = onLoadKeys) { Text(stringResource(R.string.action_retry)) }
}
}
is PanelKeysState.Success -> {
if (keysState.keys.isEmpty()) {
Text(stringResource(R.string.sub2_panel_no_keys), style = MaterialTheme.typography.bodySmall, color = inkMuted())
} else {
keysState.keys.forEach { key ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.sub2_panel_key_id, key.name, key.id),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// API Key 只显示掩码,避免页面展示过长明文
Text(
text = buildString {
append(maskKey(key.key))
append(" · ")
append(
key.groupId?.let { groupMap[it]?.name }
?: stringResource(R.string.sub2_panel_default_group)
)
append(" · ")
append(key.status)
append(" · ")
append(
key.lastUsedAt?.let { stringResource(R.string.sub2_panel_last_used, it.take(10)) }
?: stringResource(R.string.sub2_panel_never_used)
)
},
style = MaterialTheme.typography.labelSmall,
color = inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// 三个操作紧凑排布(用户反馈「间隔有点宽」)。
// 注意:Material3 的 TextButton 会把 58dp 最小宽度与内边距加在
// 其内层节点上,外层传 defaultMinSize(0)/contentPadding(0) 压不下去
//(实测按钮可点区仍有 232px),所以这里直接用可点击 Text。
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.action_copy),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { copyToClipboard(context, key.key, copiedToast) }
.padding(horizontal = 2.dp, vertical = 4.dp)
)
if (groups.isNotEmpty()) {
Text(
text = stringResource(R.string.sub2_panel_group_column),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { switchForKeyId = key.id }
.padding(horizontal = 2.dp, vertical = 4.dp)
)
}
// 删除:用图标而非文字,避免一行挤下三个按钮在窄屏折行
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { deleteForKeyId = key.id }
.padding(2.dp)
.size(18.dp)
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
}
}
}
}
}
}
// 删除确认弹窗:删除不可逆,二次确认后才发请求
deleteForKeyId?.let { keyId ->
val targetName = (keysState as? PanelKeysState.Success)
?.keys?.firstOrNull { it.id == keyId }?.name
AlertDialog(
onDismissRequest = { deleteForKeyId = null },
title = { Text(stringResource(R.string.sub2_panel_delete_confirm_title), fontWeight = FontWeight.Bold) },
text = {
Text(
stringResource(
R.string.sub2_panel_delete_confirm_body,
targetName ?: ("#" + keyId)
)
)
},
confirmButton = {
Button(
onClick = {
onDeleteKey(keyId)
deleteForKeyId = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = { deleteForKeyId = null }) { Text(stringResource(R.string.action_cancel)) }
}
)
}
// 新建 Key 弹窗
if (showCreate) {
var name by remember { mutableStateOf("") }
var groupId by remember { mutableStateOf<Long?>(null) }
AlertDialog(
onDismissRequest = { showCreate = false },
title = { Text(stringResource(R.string.sub2_panel_create_title), fontWeight = FontWeight.Bold) },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.sub2_panel_key_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = stringResource(R.string.sub2_panel_group_optional),
style = MaterialTheme.typography.labelMedium,
color = inkMuted()
)
Spacer(modifier = Modifier.height(2.dp))
if (groups.isEmpty()) {
Text(
text = stringResource(R.string.sub2_panel_no_groups),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
} else {
groups.forEach { g ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = g.name + (if (g.rateMultiplier != 1.0) "(x" + String.format(Locale.getDefault(), "%.2f", g.rateMultiplier) + ")" else ""),
style = MaterialTheme.typography.bodySmall,
color = if (groupId == g.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
TextButton(onClick = { groupId = if (groupId == g.id) null else g.id }) {
Text(
text = if (groupId == g.id) stringResource(R.string.action_cancel) else stringResource(R.string.sub2_panel_group_column),
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
}
},
confirmButton = {
Button(
onClick = { onCreateKey(name.trim(), groupId); showCreate = false },
enabled = name.isNotBlank()
) { Text(stringResource(R.string.sub2_panel_create)) }
},
dismissButton = {
TextButton(onClick = { showCreate = false }) { Text(stringResource(R.string.action_cancel)) }
}
)
}
// 新建成功:弹窗展示完整 Key,方便一次性复制(避免明文铺在长消息里)
createdKey?.let { key ->
AlertDialog(
onDismissRequest = onConsumeCreatedKey,
title = { Text(stringResource(R.string.sub2_panel_create_success_title), fontWeight = FontWeight.Bold) },
text = {
Column {
Text(
text = stringResource(R.string.sub2_panel_create_success_hint),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
Spacer(modifier = Modifier.height(8.dp))
SelectionContainer {
Text(
text = key,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(10.dp)
)
}
}
},
confirmButton = {
Button(onClick = { copyToClipboard(context, key, copiedToast); onConsumeCreatedKey() }) {
Text(stringResource(R.string.sub2_panel_copy_and_close))
}
},
dismissButton = {
TextButton(onClick = onConsumeCreatedKey) { Text(stringResource(R.string.action_close)) }
}
)
}
// 兑换弹窗
if (showRedeem) {
var code by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showRedeem = false },
title = { Text(stringResource(R.string.sub2_panel_redeem), fontWeight = FontWeight.Bold) },
text = {
OutlinedTextField(
value = code,
onValueChange = { code = it },
label = { Text(stringResource(R.string.sub2_panel_redeem_code_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
Button(
onClick = { onRedeem(code.trim()); showRedeem = false },
enabled = code.isNotBlank() && code.length >= 6
) { Text(stringResource(R.string.sub2_panel_redeem)) }
},
dismissButton = {
TextButton(onClick = { showRedeem = false }) { Text(stringResource(R.string.action_cancel)) }
}
)
}
// 切换分组弹窗
switchForKeyId?.let { keyId ->
AlertDialog(
onDismissRequest = { switchForKeyId = null },
title = { Text(stringResource(R.string.sub2_panel_switch_group), fontWeight = FontWeight.Bold) },
text = {
Column {
if (groups.isEmpty()) {
Text(
text = stringResource(R.string.sub2_panel_no_groups),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
} else {
groups.forEach { g ->
TextButton(onClick = { onSwitchGroup(keyId, g.id); switchForKeyId = null }) {
Text(
text = g.name + (if (g.rateMultiplier != 1.0) "(x" + String.format(Locale.getDefault(), "%.2f", g.rateMultiplier) + ")" else ""),
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { switchForKeyId = null }) { Text(stringResource(R.string.action_cancel)) }
}
)
}
}
/**
* API Key 展示掩码:与面板网页保持一致的观感(sk-xxxx...yyyy)。
* 列表接口虽然返回完整 key(实测 67 字符),但页面上不该铺满明文,
* 复制按钮复制的仍是完整值(见 copyToClipboard(context, key.key, …))。
*/
private fun maskKey(key: String): String = when {
key.length <= 12 -> key
else -> key.take(7) + "..." + key.takeLast(4)
}
/** 复制到剪贴板并 Toast 反馈(用户感知「复制按钮」有效)。 */
private fun copyToClipboard(context: Context, text: String, toast: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("API Key", text))
Toast.makeText(context, toast, Toast.LENGTH_SHORT).show()
}

View File

@ -0,0 +1,713 @@
package com.rainy.token.ui.servicedetail
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.rainy.token.R
import com.rainy.token.domain.model.Sub2DailyUsage
import com.rainy.token.domain.model.Sub2ModelStat
import com.rainy.token.domain.model.Sub2UsageDetail
import com.rainy.token.ui.theme.inkMuted
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Sub2API 用量明细卡(替代原来的 GenericExtrasCard)。
*
* 数据来自 [Sub2UsageDetail](网关 /v1/usage 的 model_stats + daily_usage)。
* 提供:
* 1. 时间窗口筛选(今天 / 近7天 / 近30天 / 近90天 / 全部)——按日期对 daily_usage 切片;
* 2. 模型筛选(全部 / 指定模型)——选中模型或切换时间窗口时,分模型统计按所选周期
* 重新向网关拉取(model_stats 受 start_date/end_date 控制),不再只显示全时间累计;
* 3. 窗口内汇总(请求数 / Tokens / 花费)+ 每日花费柱状图(高度封顶)+ 每日明细列表。
*
* @param windowStatsState 非「全部」窗口下的分模型统计异步状态(由 ViewModel 拉取)。
* @param onLoadWindowModelStats 按窗口日期范围拉取分模型统计的回调;null 时跳过(旧用法)。
*
* 兼容:detail 为空(旧缓存/网关未返回明细)时回退到 [GenericExtrasCard]。
*/
@Composable
internal fun Sub2ApiUsageCard(
state: State,
windowStatsState: PanelWindowStatsState = PanelWindowStatsState.Idle,
onLoadWindowModelStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null,
/** 「今天」窗口的按小时趋势;非空时用小时柱替代按天柱(按天只有一根柱没有信息量)。 */
hourlyTrend: List<Sub2DailyUsage> = emptyList(),
onLoadHourlyTrend: ((LocalDate) -> Unit)? = null,
/** 「按 Key」tab 的数据状态(按当前时间周期查询面板使用记录)。 */
keyStatsState: PanelKeyStatsState = PanelKeyStatsState.Idle,
onLoadKeyStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null
) {
val balance = when (state) {
is State.Fresh -> state.data
is State.Stale -> state.data
is State.Error -> state.cached
else -> null
} ?: return
val detail = balance.sub2UsageDetail
if (detail == null || detail.isEmpty) {
// 无结构化明细:回退旧通用卡(旧缓存/网关未返回 model_stats/daily_usage)
GenericExtrasCard(state)
return
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = stringResource(R.string.sub2_usage_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.sub2_usage_subtitle),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
Spacer(modifier = Modifier.height(12.dp))
// 默认选中「今天」(用户反馈:进入详情页应直接看当天用量)
var window by remember { mutableStateOf(Sub2Window.TODAY) }
// key=detail:刷新后模型列表变化时重置选中态,避免「每日区隐藏+无模型说明」的悬空状态
var selectedModel by remember(detail) { mutableStateOf<String?>(null) }
// 明细分区 tab:按模型 / 按 Key
var detailTab by remember { mutableStateOf(DetailTab.MODEL) }
// ── 时间窗口选择 ──
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(Sub2Window.entries) { w ->
FilterChip(
selected = window == w,
onClick = { window = w },
label = { Text(stringResource(w.labelRes)) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
}
}
val days = remember(detail, window) { window.slice(detail.dailyUsage) }
val rangeLabel = remember(days) { formatRange(days) }
// 「今天」改为按小时展示:只有一根按天柱子看不出分布,故拉当天小时趋势
LaunchedEffect(window) {
if (window == Sub2Window.TODAY) {
val (from, _) = window.range()
onLoadHourlyTrend?.invoke(from)
}
}
// 小时数据只在「今天」窗口且拿到 ≥2 个小时桶时才用。
// 实测面板可能把整天聚合成单个 "00:00" 桶(时区/活动量原因),
// 此时按小时展示两端刻度都是 00:00,反而不如按天清楚,故回退按天。
val useHourly = window == Sub2Window.TODAY && hourlyTrend.size >= 2
val chartSeries = if (useHourly) hourlyTrend else days
// 非「全部」窗口:按所选时间周期向网关拉取分模型统计(model_stats 受 start_date/end_date 控制)
LaunchedEffect(window) {
if (onLoadWindowModelStats != null && window != Sub2Window.ALL) {
val (from, to) = window.range()
onLoadWindowModelStats(from, to)
}
}
// 「按 Key」tab 打开或切换时间周期时,按当前周期拉取按 Key 用量
LaunchedEffect(window, detailTab) {
if (onLoadKeyStats != null && detailTab == DetailTab.KEY) {
val (from, to) = window.range()
onLoadKeyStats(from, to)
}
}
if (rangeLabel != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = rangeLabel,
style = MaterialTheme.typography.labelMedium,
color = inkMuted()
)
}
// 窗口分模型统计:「全部」用网关返回的全量 model_stats,其余按当前周期拉取结果
// Key 模式下后端口径为「历史累计」时 allTime=true,下面的小标题会如实标注,避免被误读为周期数据
// 「全部」窗口本身就用网关全量 model_stats,不需要再挂「累计」后缀;只在按周期拉取时才可能命中累计口径
val windowStatsAllTime = window != Sub2Window.ALL &&
(windowStatsState as? PanelWindowStatsState.Success)?.allTime == true
val windowModelStats = if (window == Sub2Window.ALL) detail.modelStats
else (windowStatsState as? PanelWindowStatsState.Success)?.stats.orEmpty()
// 切换窗口后若原选中模型在当前周期没有数据,自动取消选中,避免「无模型说明 + 日图隐藏」的悬空态
LaunchedEffect(window, windowModelStats) {
val model = selectedModel
if (model != null && windowModelStats.isNotEmpty() &&
windowModelStats.none { it.model == model }
) {
selectedModel = null
}
}
// 选中模型 → 汇总/列表均切换为该模型在当前周期内的累计;每日块隐藏(无模型维度,避免误导)
val selectedStat = remember(detail, selectedModel, windowModelStats) {
windowModelStats.firstOrNull { it.model == selectedModel }
}
if (selectedStat != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.sub2_model_sel_summary, selectedStat.model),
style = MaterialTheme.typography.labelMedium,
color = inkMuted()
)
}
// ── 汇总三格 ──
if (days.isNotEmpty() || selectedStat != null) {
Spacer(modifier = Modifier.height(10.dp))
val sumRequests = selectedStat?.requests ?: days.sumOf { it.requests }
val sumTokens = selectedStat?.totalTokens ?: days.sumOf { it.totalTokens }
val sumCost = selectedStat?.cost ?: days.sumOf { it.cost }
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
StatCell(
label = stringResource(R.string.sub2_stat_requests),
value = compactNum(sumRequests),
modifier = Modifier.weight(1f)
)
StatCell(
label = stringResource(R.string.sub2_stat_tokens),
value = compactNum(sumTokens),
modifier = Modifier.weight(1f)
)
StatCell(
label = stringResource(R.string.sub2_stat_cost),
value = formatCost(sumCost),
modifier = Modifier.weight(1f)
)
}
}
// ── 趋势区域:按天(或「今天」按小时)展示 ──
// 说明:daily_usage 是账户整体、无模型维度,因此选中单个模型时这张图仍然是
// 「账户整体趋势」——加一行标注说明口径,而不是像以前那样整块隐藏
//(隐藏会让用户以为柱状图坏了)。
run {
val isHourly = useHourly
if (chartSeries.isEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.sub2_no_daily),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
} else {
Spacer(modifier = Modifier.height(14.dp))
if (selectedModel != null) {
// 选中模型时明确标注:趋势图仍是账户整体口径,避免误读为该模型趋势
Text(
text = stringResource(R.string.sub2_trend_account_scope),
style = MaterialTheme.typography.labelSmall,
color = inkMuted()
)
Spacer(modifier = Modifier.height(6.dp))
}
DailyCostBars(chartSeries)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = shortDate(chartSeries.first().date),
style = MaterialTheme.typography.labelSmall,
color = inkMuted()
)
Text(
text = shortDate(chartSeries.last().date),
style = MaterialTheme.typography.labelSmall,
color = inkMuted()
)
}
// 每日/每小时明细列表
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.sub2_daily_header),
style = MaterialTheme.typography.labelLarge,
color = inkMuted()
)
Spacer(modifier = Modifier.height(6.dp))
if (isHourly) {
// 「今天」按小时:倒序展示每小时明细
chartSeries.reversed().forEachIndexed { index, hour ->
DailyRow(hour)
if (index != chartSeries.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
)
}
}
} else {
days.reversed().forEachIndexed { index, day ->
DailyRow(day)
if (index != days.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
)
}
}
}
}
}
// ── 明细分区:「按模型 / 按 Key」两个 tab(数据都随所选时间周期变化)──
val statsLoading = window != Sub2Window.ALL &&
(windowStatsState is PanelWindowStatsState.Idle || windowStatsState is PanelWindowStatsState.Loading)
val statsError = (windowStatsState as? PanelWindowStatsState.Error)?.message
if (detail.modelStats.isNotEmpty() || window != Sub2Window.ALL) {
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Spacer(modifier = Modifier.height(10.dp))
// tab 切换:按模型 / 按 Key(纯文字 tab,非按钮)
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
Text(
text = stringResource(R.string.sub2_tab_by_model),
style = MaterialTheme.typography.labelLarge,
fontWeight = if (detailTab == DetailTab.MODEL) FontWeight.SemiBold else FontWeight.Normal,
color = if (detailTab == DetailTab.MODEL) MaterialTheme.colorScheme.primary else inkMuted(),
modifier = Modifier.clickable { detailTab = DetailTab.MODEL }
)
Text(
text = stringResource(R.string.sub2_tab_by_key),
style = MaterialTheme.typography.labelLarge,
fontWeight = if (detailTab == DetailTab.KEY) FontWeight.SemiBold else FontWeight.Normal,
color = if (detailTab == DetailTab.KEY) MaterialTheme.colorScheme.primary else inkMuted(),
modifier = Modifier.clickable { detailTab = DetailTab.KEY }
)
}
Spacer(modifier = Modifier.height(12.dp))
if (detailTab == DetailTab.KEY) {
KeyUsageSection(keyStatsState)
} else {
// 模型筛选 chips(按当前周期内有数据的模型;「全部」时用网关全量)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item {
FilterChip(
selected = selectedModel == null,
onClick = { selectedModel = null },
label = { Text(stringResource(R.string.sub2_model_all)) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
}
items(windowModelStats) { stat ->
FilterChip(
selected = selectedModel == stat.model,
onClick = { selectedModel = stat.model },
label = { Text(stat.model) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
when {
statsLoading -> Text(
text = stringResource(R.string.common_loading),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
statsError != null -> Text(
text = "✗ " + statsError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
windowModelStats.isEmpty() -> Text(
text = stringResource(R.string.sub2_no_model_data),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
else -> {
val shown = windowModelStats.filter { selectedModel == null || it.model == selectedModel }
shown.forEachIndexed { index, stat ->
ModelRow(stat, highlighted = selectedModel == stat.model)
if (index != shown.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
)
}
}
}
}
}
}
}
}
}
/** 明细分区 tab:按模型 / 按 Key。 */
private enum class DetailTab { MODEL, KEY }
/** 按 Key 用量区域:加载中/错误/空态/列表。 */
@Composable
private fun KeyUsageSection(state: PanelKeyStatsState) {
when (state) {
is PanelKeyStatsState.Idle,
is PanelKeyStatsState.Loading -> Text(
text = stringResource(R.string.common_loading),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
is PanelKeyStatsState.Error -> Text(
text = "✗ " + state.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
is PanelKeyStatsState.Success -> {
if (state.stats.isEmpty()) {
Text(
text = stringResource(R.string.sub2_no_key_data),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
} else {
state.stats.forEachIndexed { index, stat ->
KeyUsageRow(stat)
if (index != state.stats.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
)
}
}
}
}
}
}
/** Key 一行:名称(+分组) + 请求/tokens + cost;未使用的 Key 灰显。 */
@Composable
private fun KeyUsageRow(stat: PanelKeyStatsState.KeyStat) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stat.name.ifBlank { "#" + stat.keyId },
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (stat.unused) inkMuted() else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = buildString {
stat.groupName?.takeIf { it.isNotBlank() }?.let { append(it).append(" · ") }
if (stat.unused) append(stringResource(R.string.sub2_key_unused))
else append(
stringResource(
R.string.sub2_model_meta,
compactNum(stat.requests),
compactNum(stat.totalTokens)
)
)
},
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = if (stat.unused) "-" else formatCost(stat.cost),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = if (stat.unused) inkMuted() else MaterialTheme.colorScheme.onSurface
)
}
}
/** 时间窗口:按日期切片(近 N 天 = 今天及之前 N-1 天),不再按行数。 */
private enum class Sub2Window(val labelRes: Int) {
TODAY(R.string.sub2_window_today),
DAYS_7(R.string.sub2_window_7d),
DAYS_30(R.string.sub2_window_30d),
DAYS_90(R.string.sub2_window_90d),
ALL(R.string.sub2_window_all);
fun slice(all: List<Sub2DailyUsage>): List<Sub2DailyUsage> {
val today = LocalDate.now()
val startDay = when (this) {
TODAY -> today
DAYS_7 -> today.minusDays(6)
DAYS_30 -> today.minusDays(29)
DAYS_90 -> today.minusDays(89)
ALL -> all.firstOrNull()?.let { runCatching { LocalDate.parse(it.date) }.getOrNull() } ?: today
}
// 按日期补零:网关只返回有用量的天(实测确认),filter 会日期跳空,
// 逐日补齐 cost=0 空行保证柱状图/列表连续。ALL 从最早数据日补到今天。
val byDate = all.associateBy { it.date }
val out = mutableListOf<Sub2DailyUsage>()
var d = startDay
while (!d.isAfter(today)) {
val key = d.toString()
out.add(byDate[key] ?: Sub2DailyUsage(date = key, requests = 0, totalTokens = 0, cost = 0.0))
d = d.plusDays(1)
}
return out
}
/** 窗口对应日期范围 [start, end](end 恒为今天;「全部」与主页一致取近 90 天)。 */
fun range(): Pair<LocalDate, LocalDate> {
val today = LocalDate.now()
return when (this) {
TODAY -> today to today
DAYS_7 -> today.minusDays(6) to today
DAYS_30 -> today.minusDays(29) to today
DAYS_90 -> today.minusDays(89) to today
ALL -> today.minusDays(89) to today
}
}
}
@Composable
private fun StatCell(label: String, value: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 10.dp, vertical = 10.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = inkMuted()
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1
)
}
}
/** 每日花费迷你柱状图:按窗口内每天的 cost 等比画柱(全零/无数据时显示空态文案)。 */
@Composable
private fun DailyCostBars(days: List<Sub2DailyUsage>) {
val maxCost = days.maxOfOrNull { it.cost }?.takeIf { it > 0 }
if (maxCost == null) {
// 全零或全空:空态提示(避免空白灰框)
Box(
modifier = Modifier
.fillMaxWidth()
.height(88.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.sub2_no_cost),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
return
}
// 长周期(如近90天)柱数多:收窄柱间距避免 3dp×N 间距溢出屏幕导致柱子消失/错乱
val barSpacing = when (days.size) {
in 0..15 -> 3.dp
in 16..44 -> 2.dp
else -> 1.dp
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(88.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
.padding(horizontal = 6.dp, vertical = 10.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(barSpacing)
) {
days.forEach { day ->
// 每日用量栏高度限制(0.95 最高),避免个别峰值日把柱子顶满、图形失真;
// 零消耗日给 0.02f 最小占位,保证日期刻度对齐
val fraction = ((day.cost / maxCost).toFloat()).coerceIn(0.02f, 0.95f)
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom
) {
Box(
modifier = Modifier
.fillMaxWidth(0.6f)
.fillMaxHeight(fraction)
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
.background(
Brush.verticalGradient(
listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.primaryContainer
)
)
)
)
}
}
}
}
/** 每日一行:日期(MM-dd)+ 请求数 + tokens + cost。 */
@Composable
private fun DailyRow(day: Sub2DailyUsage) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = shortDate(day.date),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1.2f)
)
Text(
text = compactNum(day.requests),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
Text(
text = compactNum(day.totalTokens),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
Text(
text = formatCost(day.cost),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(0.8f),
textAlign = TextAlign.End
)
}
}
/** 模型一行:名称 + 请求/tokens + cost;选中模型时加粗高亮。 */
@Composable
private fun ModelRow(stat: Sub2ModelStat, highlighted: Boolean = false) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stat.model,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Medium,
color = if (highlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
maxLines = 1
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(
R.string.sub2_model_meta,
compactNum(stat.requests),
compactNum(stat.totalTokens)
),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
Text(
text = formatCost(stat.cost),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = if (highlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
}
/** "MM-dd" 短日期;"HH:00" 小时刻度原样返回;解析失败原样返回。 */
private fun shortDate(iso: String): String {
// 小时桶("14:00")不是日期,直接展示,别走 LocalDate.parse
if (iso.length == 5 && iso[2] == ':') return iso
return runCatching {
LocalDate.parse(iso).format(DateTimeFormatter.ofPattern("MM-dd", Locale.US))
}.getOrDefault(iso)
}
/** 窗口起止标签:近 N 天 → "09-01 ~ 09-30",今天 → 只显示一天,全部 → null。 */
private fun formatRange(days: List<Sub2DailyUsage>): String? {
if (days.isEmpty()) return null
val first = days.first().date
val last = days.last().date
return if (first == last) shortDate(first) else shortDate(first) + " ~ " + shortDate(last)
}
/** 花费金额统一 "$x.xx"(0 显示 $0.00,钱不显示占位符)。 */
private fun formatCost(v: Double): String = "$" + String.format(Locale.US, "%.2f", v)
/** 1000 → "1.0K",1000000 → "1.0M",0 → "-"。 */
private fun compactNum(v: Long): String = when {
v <= 0 -> "-"
v >= 1_000_000 -> String.format(Locale.US, "%.1fM", v / 1_000_000.0)
v >= 1_000 -> String.format(Locale.US, "%.1fK", v / 1_000.0)
else -> v.toString()
}

View File

@ -0,0 +1,342 @@
package com.rainy.token.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.domain.model.ServiceAccountInfo
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.resolve
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
/**
* 账号管理页(Trae / WorkBuddy 多账号)。
*
* - 列出该服务全部账号(默认账号带角标);
* - 行内菜单:设为默认 / 重命名 / 删除;
* - 「添加账号」导航到凭据编辑器(新建模式)。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountManagementScreen(
service: ServiceType,
onBack: () -> Unit,
onEditAccount: (ServiceType, String?) -> Unit,
viewModel: AccountManagementViewModel = hiltViewModel()
) {
// 重新进入页面(从凭据编辑页保存/删除后返回)时刷新账号列表
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.refresh()
}
LaunchedEffect(service) { viewModel.bind(service) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// 行菜单:记录哪个账号的菜单展开
var menuAccountId by remember { mutableStateOf<String?>(null) }
// 重命名对话框
var renamingAccount by remember { mutableStateOf<ServiceAccountInfo?>(null) }
var renameInput by remember { mutableStateOf("") }
// 删除确认对话框
var deletingAccount by remember { mutableStateOf<ServiceAccountInfo?>(null) }
LaunchedEffect(uiState.message) {
uiState.message?.let { msg ->
snackbarHostState.showSnackbar(msg.resolve(context))
viewModel.consumeMessage()
}
}
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.account_manager_title, service.displayName)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) { data -> Snackbar(snackbarData = data) } }
) { innerPadding ->
if (uiState.loading && uiState.accounts.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = StrawberryPink)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (uiState.accounts.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Text(
text = stringResource(R.string.account_empty_hint),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted,
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
items(uiState.accounts, key = { it.id }) { account ->
val ordinal = accountOrdinal(uiState.accounts, account)
AccountRow(
account = account,
displayName = account.label?.takeIf { it.isNotBlank() }
?: stringResource(R.string.account_fallback_name, ordinal),
menuExpanded = menuAccountId == account.id,
onMenuToggle = {
menuAccountId = if (menuAccountId == account.id) null else account.id
},
onMenuDismiss = { menuAccountId = null },
onEdit = { onEditAccount(service, account.id) },
onSetDefault = {
menuAccountId = null
viewModel.setDefault(account.id)
},
onRename = {
menuAccountId = null
renamingAccount = account
renameInput = account.label.orEmpty()
},
onDelete = {
menuAccountId = null
deletingAccount = account
}
)
}
item {
Button(
onClick = { onEditAccount(service, null) },
modifier = Modifier
.fillMaxWidth()
.height(46.dp),
shape = RoundedCornerShape(14.dp),
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = StrawberryPink,
contentColor = Color.White
)
) {
Text(stringResource(R.string.account_add))
}
}
}
}
}
// 重命名对话框
renamingAccount?.let { account ->
AlertDialog(
onDismissRequest = { renamingAccount = null },
title = { Text(stringResource(R.string.account_rename_dialog_title)) },
text = {
OutlinedTextField(
value = renameInput,
onValueChange = { renameInput = it },
label = { Text(stringResource(R.string.account_name_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = {
viewModel.rename(account.id, renameInput)
renamingAccount = null
}
) {
Text(stringResource(R.string.action_confirm), color = StrawberryPink)
}
},
dismissButton = {
TextButton(onClick = { renamingAccount = null }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
// 删除确认对话框
deletingAccount?.let { account ->
AlertDialog(
onDismissRequest = { deletingAccount = null },
title = { Text(stringResource(R.string.account_delete_title)) },
text = { Text(stringResource(R.string.account_delete_confirm)) },
confirmButton = {
TextButton(
onClick = {
viewModel.delete(account.id)
deletingAccount = null
}
) {
Text(stringResource(R.string.action_delete_confirmed), color = StrawberryPink)
}
},
dismissButton = {
TextButton(onClick = { deletingAccount = null }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
}
@Composable
private fun AccountRow(
account: ServiceAccountInfo,
displayName: String,
menuExpanded: Boolean,
onMenuToggle: () -> Unit,
onMenuDismiss: () -> Unit,
onEdit: () -> Unit,
onSetDefault: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onEdit() },
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
if (account.isDefault) {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.account_default_badge),
style = MaterialTheme.typography.labelSmall,
color = StrawberryPink,
maxLines = 1,
softWrap = false,
modifier = Modifier
.padding(horizontal = 6.dp, vertical = 2.dp)
.background(
color = StrawberryPink.copy(alpha = 0.12f),
shape = RoundedCornerShape(8.dp)
)
)
}
}
}
Box {
IconButton(onClick = onMenuToggle) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.account_actions),
tint = InkMuted
)
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = onMenuDismiss) {
if (!account.isDefault) {
DropdownMenuItem(
text = { Text(stringResource(R.string.account_set_default)) },
onClick = onSetDefault
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.account_rename)) },
onClick = onRename
)
DropdownMenuItem(
text = { Text(stringResource(R.string.account_delete_title)) },
onClick = onDelete
)
}
}
}
}
}
/** 同服务内账号序号(1 起,用于“账号 N”兜底显示)。 */
private fun accountOrdinal(accounts: List<ServiceAccountInfo>, account: ServiceAccountInfo): Int =
accounts.indexOf(account) + 1

View File

@ -0,0 +1,97 @@
package com.rainy.token.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.R
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.ServiceAccountInfo
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* 账号管理页 ViewModel(Trae / WorkBuddy 多账号)。
*
* 「添加账号」由页面回调导航到凭据编辑器(新建模式),首个账号保存时自动设为默认;
* 本页负责列出现有账号、设默认、重命名、删除。
*/
@HiltViewModel
class AccountManagementViewModel @Inject constructor(
private val credentialRepository: CredentialRepository
) : ViewModel() {
private val _service = MutableStateFlow<ServiceType?>(null)
val service: StateFlow<ServiceType?> = _service.asStateFlow()
private val _uiState = MutableStateFlow(AccountManagementUiState())
val uiState: StateFlow<AccountManagementUiState> = _uiState.asStateFlow()
private var loadGeneration = 0
fun bind(service: ServiceType) {
if (_service.value == service) return
_service.value = service
refresh()
}
fun refresh() {
val type = _service.value ?: return
val generation = ++loadGeneration
viewModelScope.launch {
_uiState.update { it.copy(loading = true) }
val accounts = credentialRepository.accountsFor(type)
if (generation != loadGeneration) return@launch
_uiState.update {
it.copy(
loading = false,
accounts = accounts,
// 账号列表可能为空(用户刚删光),保持消息残留会误导,这里清掉
message = if (accounts.isEmpty()) null else it.message
)
}
}
}
fun setDefault(accountId: String) {
val type = _service.value ?: return
viewModelScope.launch {
credentialRepository.setDefaultAccount(type, accountId)
_uiState.update { it.copy(message = UiText.Resource(R.string.msg_account_default_set)) }
refresh()
}
}
fun rename(accountId: String, label: String) {
val type = _service.value ?: return
viewModelScope.launch {
credentialRepository.renameAccount(type, accountId, label.trim())
_uiState.update { it.copy(message = UiText.Resource(R.string.msg_account_renamed)) }
refresh()
}
}
fun delete(accountId: String) {
val type = _service.value ?: return
viewModelScope.launch {
credentialRepository.removeAccount(type, accountId)
_uiState.update { it.copy(message = UiText.Resource(R.string.msg_account_deleted)) }
refresh()
}
}
fun consumeMessage() {
_uiState.update { it.copy(message = null) }
}
}
data class AccountManagementUiState(
val loading: Boolean = false,
val accounts: List<ServiceAccountInfo> = emptyList(),
val message: UiText? = null
)

View File

@ -1,5 +1,7 @@
package com.rainy.token.ui.settings
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@ -16,6 +19,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@ -25,16 +29,24 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceType
@ -42,8 +54,16 @@ import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.StatusChip
import com.rainy.token.ui.components.StatusLevel
import com.rainy.token.ui.components.StatusStyle
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import com.rainy.token.R
import com.rainy.token.ui.components.AppTips
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.util.LocaleManager
/**
* 设置页(雨晴风格重做版)。
@ -58,9 +78,18 @@ import com.rainy.token.ui.theme.StrawberryPink
fun SettingsScreen(
onBack: () -> Unit,
onEditCredential: (ServiceType) -> Unit,
onManageAccounts: (ServiceType) -> Unit = {},
onOpenTips: () -> Unit = {},
onOpenDebugLog: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel()
) {
// 重新进入设置页时刷新凭据状态(从凭据编辑页保存/删除后返回)
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.refresh()
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showLanguageDialog by remember { mutableStateOf(false) }
Scaffold(
containerColor = Color.Transparent,
@ -68,7 +97,7 @@ fun SettingsScreen(
TopAppBar(
title = {
Text(
"设置",
stringResource(R.string.title_settings),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
@ -77,7 +106,7 @@ fun SettingsScreen(
IconButton(onClick = onBack) {
Icon(
Icons.Filled.ArrowBack,
contentDescription = "返回",
contentDescription = stringResource(R.string.action_back),
tint = StrawberryPink
)
}
@ -107,19 +136,38 @@ fun SettingsScreen(
) {
item {
Text(
text = "凭据管理",
text = stringResource(R.string.title_credentials),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = InkMuted,
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
)
}
items(uiState.credentialStatuses, key = { it.service.name }) { status ->
items(uiState.credentialStatuses, key = { it.status.service.name }) { line ->
CredentialStatusCard(
status = status,
onClick = { onEditCredential(status.service) }
status = line.status,
accountCount = line.accountCount,
onClick = {
if (ServiceType.isMultiAccount(line.status.service)) {
onManageAccounts(line.status.service)
} else {
onEditCredential(line.status.service)
}
}
)
}
item {
LanguageCard(onClick = { showLanguageDialog = true })
}
item {
TipsCard(onClick = { onOpenTips() })
}
item {
DebugLogCard(onClick = { onOpenDebugLog() })
}
item {
UpdateCard()
}
item {
Spacer(modifier = Modifier.padding(top = 8.dp))
AboutCard()
@ -127,10 +175,113 @@ fun SettingsScreen(
}
}
}
if (showLanguageDialog) {
LanguageDialog(onDismiss = { showLanguageDialog = false })
}
}
@Composable
private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit) {
private fun LanguageCard(onClick: () -> Unit) {
val context = LocalContext.current
val current = LocaleManager.getLocaleCode(context)
val labelRes = when (current) {
"zh" -> R.string.app_locale_zh
"zh-Hant" -> R.string.app_locale_zh_hant
"en" -> R.string.app_locale_en
else -> R.string.app_locale_follow_system
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🌐",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.title_language),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
}
@Composable
private fun LanguageDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val current = LocaleManager.getLocaleCode(context)
val options: List<Pair<String?, Int>> = listOf(
null to R.string.app_locale_follow_system,
"zh" to R.string.app_locale_zh,
"zh-Hant" to R.string.app_locale_zh_hant,
"en" to R.string.app_locale_en
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_language_title)) },
text = {
Column {
options.forEach { (code, labelRes) ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
LocaleManager.saveLocale(context, code)
onDismiss()
// Android 13+:framework 设置语言后系统自动重建 Activity;
// 旧系统才需要手动 recreate 让资源按新语言重新解析
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
(context as? Activity)?.recreate()
}
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
if (current == code) {
Text("✓", color = StrawberryPink, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
@Composable
private fun CredentialStatusCard(
status: CredentialStatus,
accountCount: Int = 0,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
@ -152,7 +303,11 @@ private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit)
fontWeight = FontWeight.SemiBold
)
Text(
text = stateLabel(status.state),
text = if (accountCount > 0) {
stringResource(R.string.account_count_line, stateLabel(status.state), accountCount)
} else {
stateLabel(status.state)
},
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
@ -163,6 +318,142 @@ private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit)
}
}
@Composable
private fun UpdateCard(
viewModel: UpdateViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = stringResource(R.string.title_update),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.padding(top = 8.dp))
Text(
text = stringResource(
R.string.update_current_version,
state.currentVersionName,
state.currentVersionCode
),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
Spacer(modifier = Modifier.padding(top = 12.dp))
when (val phase = state.phase) {
is UpdateViewModel.Phase.Idle -> Unit
is UpdateViewModel.Phase.Checking -> {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(18.dp).height(18.dp),
color = StrawberryPink,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.update_checking),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
}
}
is UpdateViewModel.Phase.Latest -> {
Text(
text = stringResource(R.string.update_latest),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
Spacer(modifier = Modifier.padding(top = 8.dp))
TextButton(onClick = { viewModel.checkForUpdate() }) {
Text(stringResource(R.string.update_check))
}
}
is UpdateViewModel.Phase.Available -> {
Text(
text = stringResource(R.string.update_available, phase.info.versionName),
style = MaterialTheme.typography.bodyMedium,
color = StrawberryPink,
fontWeight = FontWeight.SemiBold
)
if (phase.info.notes.isNotBlank()) {
Spacer(modifier = Modifier.padding(top = 4.dp))
Text(
text = phase.info.notes,
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
}
Spacer(modifier = Modifier.padding(top = 12.dp))
Button(onClick = { viewModel.downloadAndInstall(phase.info) }) {
Text(stringResource(R.string.update_download))
}
}
is UpdateViewModel.Phase.Downloading -> {
LinearProgressIndicator(
progress = { phase.progress },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.padding(top = 4.dp))
Text(
text = stringResource(
R.string.update_downloading,
(phase.progress * 100).toInt()
),
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
}
is UpdateViewModel.Phase.Ready -> {
Text(
text = stringResource(R.string.update_download_finished),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
Spacer(modifier = Modifier.padding(top = 8.dp))
Button(onClick = { viewModel.install(phase.file) }) {
Text(stringResource(R.string.update_install))
}
}
is UpdateViewModel.Phase.NeedInstallPermission -> {
Text(
text = stringResource(R.string.update_install_permission_tip),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.padding(top = 8.dp))
Button(onClick = {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + context.packageName)
)
runCatching { context.startActivity(intent) }
}) {
Text(stringResource(R.string.update_enable_permission))
}
}
is UpdateViewModel.Phase.Error -> {
Text(
text = stringResource(R.string.update_failed, phase.message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.padding(top = 8.dp))
TextButton(onClick = { viewModel.checkForUpdate() }) {
Text(stringResource(R.string.update_check))
}
}
}
}
}
}
@Composable
private fun AboutCard() {
Card(
@ -173,13 +464,13 @@ private fun AboutCard() {
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = "关于 RainyToken",
text = stringResource(R.string.title_about),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.padding(top = 8.dp))
Text(
text = "统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。",
text = stringResource(R.string.about_description),
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
@ -187,16 +478,91 @@ private fun AboutCard() {
}
}
@Composable
private fun stateLabel(state: CredentialStatus.State): String = when (state) {
CredentialStatus.State.NOT_CONFIGURED -> "未配置 · 点击添加"
CredentialStatus.State.OK -> "已配置 · 凭据有效"
CredentialStatus.State.EXPIRED -> "已过期 · 点击重新配置"
CredentialStatus.State.WARNING -> "需要重新验证"
CredentialStatus.State.NOT_CONFIGURED -> stringResource(R.string.status_not_configured_add)
CredentialStatus.State.OK -> stringResource(R.string.status_ok_valid)
CredentialStatus.State.EXPIRED -> stringResource(R.string.status_expired_reconfigure)
CredentialStatus.State.WARNING -> stringResource(R.string.status_need_reverify)
}
private fun stateToChip(state: CredentialStatus.State): StatusStyle = when (state) {
CredentialStatus.State.NOT_CONFIGURED -> StatusStyle("未配置", StatusLevel.WARNING)
CredentialStatus.State.OK -> StatusStyle("已配置", StatusLevel.OK)
CredentialStatus.State.EXPIRED -> StatusStyle("已过期", StatusLevel.ERROR)
CredentialStatus.State.WARNING -> StatusStyle("需重登", StatusLevel.WARNING)
CredentialStatus.State.NOT_CONFIGURED -> StatusStyle(R.string.status_not_configured, StatusLevel.WARNING)
CredentialStatus.State.OK -> StatusStyle(R.string.status_configured, StatusLevel.OK)
CredentialStatus.State.EXPIRED -> StatusStyle(R.string.status_expired, StatusLevel.ERROR)
CredentialStatus.State.WARNING -> StatusStyle(R.string.status_relogin, StatusLevel.WARNING)
}
@Composable
private fun TipsCard(onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = StrawberryPink.copy(alpha = 0.08f)),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "💡",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.title_tips),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = StrawberryPink
)
Text(
text = stringResource(R.string.tips_view_all, AppTips.tips.size),
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
}
@Composable
private fun DebugLogCard(onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🔍",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.title_debug_log),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = InkMuted
)
Text(
text = stringResource(R.string.debug_log_desc),
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
}

View File

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -32,8 +33,16 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(loading = true) }
val statuses = credentialRepository.statusForAll()
val lines = statuses.map { status ->
val accountCount = if (ServiceType.isMultiAccount(status.service)) {
credentialRepository.accountsFor(status.service).size
} else {
0
}
CredentialStatusLine(status = status, accountCount = accountCount)
}
_uiState.update {
it.copy(loading = false, credentialStatuses = statuses)
it.copy(loading = false, credentialStatuses = lines)
}
}
}
@ -41,5 +50,11 @@ class SettingsViewModel @Inject constructor(
data class SettingsUiState(
val loading: Boolean = false,
val credentialStatuses: List<CredentialStatus> = emptyList()
val credentialStatuses: List<CredentialStatusLine> = emptyList()
)
/** 凭据状态行:单账号服务 [accountCount]=0;多账号服务为账号数(0 表示尚未配置)。 */
data class CredentialStatusLine(
val status: CredentialStatus,
val accountCount: Int = 0
)

View File

@ -0,0 +1,98 @@
package com.rainy.token.ui.settings
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.BuildConfig
import com.rainy.token.data.repository.UpdateRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/** 设置页更新模块:检查更新 → 下载 APK → 拉起系统安装器。 */
@HiltViewModel
class UpdateViewModel @Inject constructor(
private val updateRepository: UpdateRepository,
@ApplicationContext private val context: Context
) : ViewModel() {
sealed interface Phase {
object Idle : Phase
object Checking : Phase
object Latest : Phase
data class Available(val info: UpdateRepository.UpdateInfo) : Phase
data class Downloading(val progress: Float) : Phase
data class Ready(val file: File) : Phase
object NeedInstallPermission : Phase
data class Error(val message: String) : Phase
}
data class UiState(
val phase: Phase = Phase.Idle,
val currentVersionName: String = BuildConfig.VERSION_NAME,
val currentVersionCode: Int = BuildConfig.VERSION_CODE
)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
checkForUpdate()
}
fun checkForUpdate() {
_uiState.update { it.copy(phase = Phase.Checking) }
viewModelScope.launch {
updateRepository.checkForUpdate().fold(
onSuccess = { info ->
_uiState.update {
it.copy(phase = if (info == null) Phase.Latest else Phase.Available(info))
}
},
onFailure = { e ->
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "unknown")) }
}
)
}
}
fun downloadAndInstall(info: UpdateRepository.UpdateInfo) {
_uiState.update { it.copy(phase = Phase.Downloading(0f)) }
viewModelScope.launch {
updateRepository.downloadApk(info) { downloaded, total ->
val progress = if (total > 0) downloaded.toFloat() / total else 0f
_uiState.update { it.copy(phase = Phase.Downloading(progress)) }
}.fold(
onSuccess = { file -> install(file) },
onFailure = { e ->
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "unknown")) }
}
)
}
}
fun install(file: File) {
if (context.packageManager.canRequestPackageInstalls()) {
_uiState.update { it.copy(phase = Phase.Ready(file)) }
val authority = context.packageName + ".fileprovider"
val uri = FileProvider.getUriForFile(context, authority, file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }.onFailure { e ->
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "cannot start installer")) }
}
} else {
_uiState.update { it.copy(phase = Phase.NeedInstallPermission) }
}
}
}

View File

@ -3,40 +3,41 @@ package com.rainy.token.ui.theme
import androidx.compose.ui.graphics.Color
/**
* 雨晴风格配色(RainyStyle Palette)。
* 雨晴风格配色(RainyStyle Palette)— 精致粉调。
*
* 视觉基调:元气樱粉 / 草莓粉主调,搭配纯白卡片与暖灰文本。
* 视觉基调:低饱和樱粉白背景 + 纯白卡片 + 深玫瑰主色点缀,
* 通过克制用色与留白呈现精致感,保留"雨晴粉"品牌识别度。
* 状态色:草绿(正常)、暖橙(注意)、玫红(异常)。
*
* 背景使用樱粉渐变(#FFF0F5 → #FFD1DC),主元素采用草莓粉
* (#FF85A2 / #FF6B8E),与白色形成层次。
* 背景使用柔和粉白渐变(#FFF9FB → #FDEFF4),主元素采用深玫瑰
* (#C9436C / #B3305B),与白色形成优雅层次。
*/
// ─── 品牌主色 ───
val CherryPinkLight = Color(0xFFFFF0F5) // 樱粉浅(背景起始)
val CherryPinkDeep = Color(0xFFFFD1DC) // 樱粉深(背景结束)
val StrawberryPink = Color(0xFFFF85A2) // 草莓粉(主色 / 强调)
val StrawberryPinkDark = Color(0xFFFF6B8E)// 草莓粉深(按下态 / 主按钮)
val StrawberryPinkSoft = Color(0xFFFFB3C6)// 草莓粉柔(hover / 副按钮)
val CherryPinkLight = Color(0xFFFFF9FB) // 粉白(背景起始)
val CherryPinkDeep = Color(0xFFFDEFF4) // 浅樱粉(背景结束)
val StrawberryPink = Color(0xFFC9436C) // 深玫瑰(主色 / 强调,白底对比度 ≥ 4.5:1)
val StrawberryPinkDark = Color(0xFFB3305B)// 深玫瑰按下态(主按钮)
val StrawberryPinkSoft = Color(0xFFFFC9D8)// 浅玫瑰柔(容器 / 选中背景)
// ─── 中性色 ───
val PureWhite = Color(0xFFFFFFFF)
val SnowWhite = Color(0xFFFAF6F8) // 卡片次级背景
val InkWarm = Color(0xFF3D2C35) // 暖黑(主文本)
val InkMuted = Color(0xFF7A6B72) // 暖灰(次要文本)
val InkOutline = Color(0xFFD9CFD3) // 暖灰(分隔线 / 描边)
val SnowWhite = Color(0xFFFAF5F7) // 卡片次级背景
val InkWarm = Color(0xFF3A2B31) // 暖黑(主文本)
val InkMuted = Color(0xFF7A6870) // 暖灰(次要文本,白底 ≥ 4.5:1)
val InkOutline = Color(0xFFE8DCE1) // 暖灰(分隔线 / 描边)
// ─── 状态色 ───
val StatusGreen = Color(0xFF66BB6A) // 正常
val StatusGreen = Color(0xFF4CAF50) // 正常
val StatusOrange = Color(0xFFFFA726) // 注意 / 需重登
val StatusRed = Color(0xFFE91E63) // 错误 / 异常
val StatusRed = Color(0xFFD64A6E) // 错误 / 异常(玫红,与粉调统一)
val StatusBlue = Color(0xFF64B5F6) // 信息(Stale 缓存)
// ─── 暗色模式 ───
val DarkBackground = Color(0xFF1F1419) // 暖深背景
val DarkSurface = Color(0xFF2A1F25) // 卡片
val DarkOnSurface = Color(0xFFEFE0E5) // 卡片文本
val DarkPrimary = Color(0xFFFFB3C6) // 暗色下主色更亮
val DarkInkWarm = Color(0xFFEFE0E5) // 暗色下暖黑→浅粉白
val DarkInkMuted = Color(0xFFC9B8BE) // 暗色下暖灰→浅灰粉
val DarkInkOutline = Color(0xFF4D3A42) // 暗色下描边
val DarkBackground = Color(0xFF1A1417) // 暖深背景
val DarkSurface = Color(0xFF251C21) // 卡片
val DarkOnSurface = Color(0xFFF2E6EB) // 卡片文本
val DarkPrimary = Color(0xFFFFA9BF) // 暗色下主色更亮
val DarkInkWarm = Color(0xFFF2E6EB) // 暗色下暖黑→浅粉白
val DarkInkMuted = Color(0xFFCBB8C0) // 暗色下暖灰→浅灰粉
val DarkInkOutline = Color(0xFF4C3B44) // 暗色下描边

View File

@ -13,21 +13,22 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
/**
* 雨晴风格主题。
* 雨晴风格主题(精致粉调)。
*
* - **不使用** dynamicColor(系统主题色),固定使用雨晴樱粉品牌色。
* - **不使用** dynamicColor(系统主题色),固定使用雨晴玫瑰粉品牌色。
* 理由:dynamicColor 会让 APP 视觉随系统变化,破坏品牌一致性;
* 玩家一眼看到 APP icon + 粉色调就知道是 RainyToken 系列。
* - 暗色模式:暖深背景 + 亮草莓粉强调,跟 Light 模式同色系不同明度。
* - 主色:深玫瑰 #C9436C;容器色:浅玫瑰粉;背景:低饱和粉白渐变。
* - 暗色模式:暖深背景 + 亮玫瑰粉强调,跟 Light 模式同色系不同明度。
*/
private val LightColors = lightColorScheme(
primary = StrawberryPink,
onPrimary = PureWhite,
primaryContainer = StrawberryPinkSoft,
onPrimaryContainer = InkWarm,
secondary = StrawberryPinkDark,
onPrimaryContainer = Color(0xFF5C112C),
secondary = Color(0xFFB7698B),
onSecondary = PureWhite,
secondaryContainer = Color(0xFFFFE4EC),
secondaryContainer = Color(0xFFFFE7EE),
onSecondaryContainer = InkWarm,
tertiary = StatusGreen,
onTertiary = PureWhite,
@ -38,32 +39,32 @@ private val LightColors = lightColorScheme(
surfaceVariant = SnowWhite,
onSurfaceVariant = InkMuted,
outline = InkOutline,
outlineVariant = Color(0xFFEFE0E5),
outlineVariant = Color(0xFFF2E7EB),
error = StatusRed,
onError = PureWhite
)
private val DarkColors = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkBackground,
primaryContainer = StrawberryPinkDark,
onPrimaryContainer = PureWhite,
onPrimary = Color(0xFF4A1025),
primaryContainer = Color(0xFF6B2C42),
onPrimaryContainer = DarkOnSurface,
secondary = StrawberryPink,
onSecondary = DarkBackground,
secondaryContainer = Color(0xFF4A2E3A),
onSecondaryContainer = DarkOnSurface,
tertiary = StatusGreen,
onTertiary = DarkBackground,
tertiary = Color(0xFF7BCF9E),
onTertiary = Color(0xFF0D2B1C),
background = DarkBackground,
onBackground = DarkOnSurface,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = Color(0xFF352329),
onSurfaceVariant = Color(0xFFC9B8BE),
outline = Color(0xFF4D3A42),
outlineVariant = Color(0xFF3A2A30),
error = Color(0xFFFF6B8E),
onError = PureWhite
surfaceVariant = Color(0xFF32262C),
onSurfaceVariant = Color(0xFFCBB8C0),
outline = Color(0xFF4C3B44),
outlineVariant = Color(0xFF3B2C33),
error = Color(0xFFFF8194),
onError = Color(0xFF4A1025)
)
@Composable

View File

@ -0,0 +1,308 @@
package com.rainy.token.ui.webview
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.ui.components.UiText
import com.rainy.token.ui.components.asString
import com.rainy.token.ui.theme.StrawberryPink
/**
* Codex OAuth PKCE 登录页面。
*
* 两种模式:
* - WEBVIEW:APP 内 WebView 打开 OpenAI 授权页,拦截 localhost:1455 回调
* - HEADLESS:显示授权 URL + 复制按钮 + 在浏览器打开 + 粘贴回调 URL 输入框
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CodexOAuthScreen(
onBack: () -> Unit,
onSuccess: () -> Unit,
viewModel: CodexOAuthViewModel = hiltViewModel()
) {
LaunchedEffect(Unit) { viewModel.start(CodexOAuthMode.HEADLESS) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(uiState.loginSucceeded) {
if (uiState.loginSucceeded) onSuccess()
}
BackHandler(enabled = !uiState.loginSucceeded) { onBack() }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.oauth_screen_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when {
uiState.exchanging -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(color = StrawberryPink)
Text(
stringResource(R.string.oauth_exchanging),
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
uiState.error != null && uiState.mode == CodexOAuthMode.WEBVIEW -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
uiState.error!!.asString(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
Text(stringResource(R.string.action_retry), color = StrawberryPink)
}
}
}
// 无头模式:显示授权 URL + 粘贴回调
uiState.mode == CodexOAuthMode.HEADLESS && uiState.authUrl.isNotEmpty() -> {
HeadlessOAuthContent(
authUrl = uiState.authUrl,
error = uiState.error,
onCopyUrl = { copyToClipboard(context, uiState.authUrl) },
onOpenInBrowser = { openInBrowser(context, uiState.authUrl) },
onSubmit = { url -> viewModel.submitCallbackUrl(url) },
onRetry = { viewModel.start(CodexOAuthMode.HEADLESS) }
)
}
// WebView 模式
uiState.authUrl.isNotEmpty() -> {
AndroidView(
factory = { ctx ->
WebView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val url = request?.url?.toString()
if (url != null && viewModel.onUrlChanged(url)) {
return true
}
return false
}
}
webChromeClient = WebChromeClient()
loadUrl(uiState.authUrl)
}
},
modifier = Modifier.fillMaxSize()
)
}
else -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(color = StrawberryPink)
Text(
stringResource(R.string.oauth_preparing),
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
}
}
/**
* 无头模式 UI:授权 URL + 操作按钮 + 回调 URL 粘贴输入框
*/
@Composable
private fun HeadlessOAuthContent(
authUrl: String,
error: UiText?,
onCopyUrl: () -> Unit,
onOpenInBrowser: () -> Unit,
onSubmit: (String) -> Unit,
onRetry: () -> Unit
) {
var callbackUrl by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(R.string.oauth_headless_title),
style = MaterialTheme.typography.titleMedium,
color = StrawberryPink
)
Text(
text = stringResource(R.string.oauth_steps),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
// 授权 URL 预览
OutlinedTextField(
value = authUrl,
onValueChange = {},
label = { Text(stringResource(R.string.oauth_auth_url_label)) },
readOnly = true,
minLines = 3,
maxLines = 5,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onOpenInBrowser,
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.action_open_in_browser))
}
OutlinedButton(
onClick = onCopyUrl,
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.action_copy_link))
}
}
Text(
text = stringResource(R.string.oauth_paste_callback_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
textAlign = TextAlign.Center
)
OutlinedTextField(
value = callbackUrl,
onValueChange = { callbackUrl = it },
label = { Text(stringResource(R.string.oauth_callback_url_label)) },
placeholder = { Text(stringResource(R.string.oauth_callback_url_placeholder)) },
minLines = 2,
maxLines = 6,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { onSubmit(callbackUrl) },
modifier = Modifier.fillMaxWidth(),
enabled = callbackUrl.isNotBlank()
) {
Text(stringResource(R.string.action_submit_callback))
}
if (error != null) {
Text(
text = error.asString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp)
)
TextButton(onClick = onRetry) {
Text(stringResource(R.string.action_regenerate_link), color = StrawberryPink)
}
}
}
}
private fun copyToClipboard(context: Context, text: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("auth_url", text))
}
private fun openInBrowser(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}

View File

@ -0,0 +1,169 @@
package com.rainy.token.ui.webview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.repository.CodexOAuthHelper
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import javax.inject.Inject
enum class CodexOAuthMode { WEBVIEW, HEADLESS }
/**
* Codex OAuth PKCE 登录 ViewModel。
*
* 两种模式:
* - WEBVIEW:APP 内 WebView 打开授权页,拦截 localhost:1455 回调
* - HEADLESS:生成授权 URL 让用户复制到外部浏览器,登录后粘贴回调 URL 回来
*/
@HiltViewModel
class CodexOAuthViewModel @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(CodexOAuthUiState())
val uiState: StateFlow<CodexOAuthUiState> = _uiState.asStateFlow()
private val TAG = "CodexOAuth"
fun start(mode: CodexOAuthMode = CodexOAuthMode.WEBVIEW) {
val pkce = CodexOAuthHelper.generatePkce()
val state = CodexOAuthHelper.generateState()
val authUrl = CodexOAuthHelper.buildAuthUrl(pkce.codeChallenge, state)
DebugLog.i(TAG, "OAuth 流程启动 (${mode.name}),构建授权 URL")
_uiState.update {
CodexOAuthUiState(
mode = mode,
authUrl = authUrl,
codeVerifier = pkce.codeVerifier,
expectedState = state
)
}
}
/**
* WebView 拦截到 URL 变化时调用。
* 如果 URL 匹配 callback,返回 true 表示已处理。
*/
fun onUrlChanged(url: String?): Boolean {
if (url == null) return false
if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) return false
handleCallbackUrl(url)
return true
}
/**
* 无头模式:用户粘贴回调 URL 后调用。
*/
fun submitCallbackUrl(url: String) {
handleCallbackUrl(url.trim())
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
/**
* 处理回调 URL:解析 code/state,交换 token,保存凭据。
*/
private fun handleCallbackUrl(url: String) {
if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) {
_uiState.update {
it.copy(
error = UiText.Resource(
R.string.error_oauth_url_prefix,
listOf(CodexOAuthHelper.CALLBACK_PREFIX)
)
)
}
return
}
val uri = android.net.Uri.parse(url)
val code = uri.getQueryParameter("code")
val receivedState = uri.getQueryParameter("state")
val error = uri.getQueryParameter("error")
if (error != null) {
val errorDesc = uri.getQueryParameter("error_description") ?: error
DebugLog.e(TAG, "OAuth 授权失败: $errorDesc")
_uiState.update {
it.copy(error = UiText.Resource(R.string.error_oauth_auth_failed, listOf(errorDesc)))
}
return
}
if (receivedState != _uiState.value.expectedState) {
DebugLog.e(TAG, "OAuth state 不匹配(CSRF 防护)")
_uiState.update { it.copy(error = UiText.Resource(R.string.error_oauth_state_mismatch)) }
return
}
if (code.isNullOrBlank()) {
DebugLog.e(TAG, "OAuth 回调缺少 code 参数")
_uiState.update { it.copy(error = UiText.Resource(R.string.error_oauth_missing_code)) }
return
}
_uiState.update { it.copy(exchanging = true, error = null) }
val verifier = _uiState.value.codeVerifier ?: return
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
CodexOAuthHelper.exchangeCode(okHttpClient, code, verifier)
}
if (result == null) {
DebugLog.e(TAG, "token 交换失败")
_uiState.update {
it.copy(
exchanging = false,
error = UiText.Resource(R.string.error_oauth_token_exchange)
)
}
return@launch
}
val accountId = CodexOAuthHelper.extractAccountId(result.idToken, result.accessToken)
val expiresAt = System.currentTimeMillis() + result.expiresIn * 1000L
DebugLog.i(TAG, "OAuth 登录成功,accountId=$accountId,expiresAt=$expiresAt")
val cred = Credential.CodexCredential(
service = ServiceType.CODEX,
accessToken = result.accessToken,
refreshToken = result.refreshToken ?: "",
accountId = accountId ?: "",
expiresAt = expiresAt,
lastVerifiedAt = System.currentTimeMillis()
)
credentialRepository.save(cred)
_uiState.update { it.copy(exchanging = false, loginSucceeded = true) }
}
}
}
data class CodexOAuthUiState(
val mode: CodexOAuthMode = CodexOAuthMode.WEBVIEW,
val authUrl: String = "",
val codeVerifier: String? = null,
val expectedState: String? = null,
val exchanging: Boolean = false,
val loginSucceeded: Boolean = false,
val error: UiText? = null
)

View File

@ -13,11 +13,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@ -26,12 +29,18 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.asString
/**
* 通用 WebView 登录容器。计划 4.1:
@ -44,17 +53,34 @@ import com.rainy.token.domain.service.ServiceType
@Composable
fun WebViewLoginScreen(
service: ServiceType,
accountId: String? = null,
onBack: () -> Unit,
onLoginSucceeded: (ServiceType) -> Unit,
onLoginSucceeded: (ServiceType, String?) -> Unit,
/** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */
configuredBaseUrl: String? = null,
viewModel: WebViewLoginViewModel = hiltViewModel()
) {
LaunchedEffect(service) { viewModel.bind(service) }
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
LaunchedEffect(service, configuredBaseUrl, accountId) {
viewModel.bind(service, configuredBaseUrl, accountId)
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
// 登录成功自动回调
// 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填)
LaunchedEffect(uiState.loginSucceeded) {
if (uiState.loginSucceeded) {
onLoginSucceeded(service)
onLoginSucceeded(service, uiState.loginAccountId)
}
}
// 自动识别服务(WorkBuddy 轮询超时、bindWorkBuddy 网络失败等)没有手动确认弹窗兜底,
// 错误通过 Snackbar 呈现,避免用户静默卡在登录页。
// key 不含 pendingManualConfirm:错误文本不变时不重复提示(弹窗关闭不回放),且保持互斥。
val errorText = uiState.error?.asString()
LaunchedEffect(errorText, uiState.loginSucceeded) {
if (errorText != null && !uiState.pendingManualConfirm && !uiState.loginSucceeded) {
snackbarHostState.showSnackbar(errorText)
}
}
@ -63,12 +89,13 @@ fun WebViewLoginScreen(
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("登录 ${service.displayName}") },
title = { Text(stringResource(R.string.title_webview_login, service.displayName)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "返回")
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
)
@ -97,9 +124,54 @@ fun WebViewLoginScreen(
// 且 TLS fingerprinting 也会识别 WebView。此入口主要作为 fallback,
// 主流程走"手动粘贴 Cookie"路线。
// 注入 JS 桥:面板登录后把 localStorage 里的登录态回传原生层,
// 实现「网页登录一次即自动配置凭据」,无需手工粘贴 token
addJavascriptInterface(
LoginProbeBridge { json ->
viewModel.onLocalStorageProbed(parseProbeJson(json))
},
"AndroidBridge"
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: android.webkit.WebResourceRequest?
): Boolean {
val url = request?.url?.toString() ?: return false
return handleOverrideUrl(url)
}
@Deprecated("deprecated in API 24, kept for older devices")
override fun shouldOverrideUrlLoading(
view: WebView?,
url: String?
): Boolean {
url ?: return false
return handleOverrideUrl(url)
}
fun handleOverrideUrl(url: String): Boolean {
// Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt
if (url.startsWith("http://127.0.0.1:18080/authorize")) {
DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}")
viewModel.onTraeCallback(url)
return true
}
return false
}
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}")
super.onPageStarted(view, url, favicon)
}
override fun onPageFinished(view: WebView?, url: String?) {
url?.let { viewModel.onPageFinished(it) }
url ?: return
// 登录成功后面板会写入 localStorage.auth_token,抓到即自动保存
view?.evaluateJavascript(viewModel.probeScript, null)
viewModel.onPageFinished(url)
DebugLog.i("TraeLogin", "pageFinished: ${url.toSafeLogUrl()}")
}
}
webChromeClient = WebChromeClient()
@ -109,13 +181,12 @@ fun WebViewLoginScreen(
modifier = Modifier.fillMaxSize()
)
} else {
Column(modifier = Modifier
.fillMaxSize()
.padding(16.dp)) {
Text(
text = "未配置登录 URL",
style = MaterialTheme.typography.bodyLarge
)
// loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
@ -125,23 +196,61 @@ fun WebViewLoginScreen(
if (uiState.pendingManualConfirm && !uiState.loginSucceeded) {
AlertDialog(
onDismissRequest = { viewModel.dismissManualPrompt() },
title = { Text("完成登录了吗?") },
title = { Text(stringResource(R.string.dialog_login_done_title)) },
text = {
Text(
uiState.error ?: "如果你已经在页面上完成登录但页面没有跳转," +
"可点击下方按钮让我抓取 Cookie 并保存登录态。"
uiState.error?.asString() ?: stringResource(R.string.dialog_login_done_body)
)
},
confirmButton = {
Button(onClick = { viewModel.confirmLoginManually() }) {
Text("我已登录,保存")
Text(stringResource(R.string.action_login_save))
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissManualPrompt() }) {
Text("继续等待")
Text(stringResource(R.string.action_keep_waiting))
}
}
)
}
}
/**
* JS → 原生桥:页面脚本通过 window.AndroidBridge.onProbe(json) 回传 localStorage 登录态。
*
* 用 @JavascriptInterface 注解的方法只允许传基本类型,这里统一走 JSON 字符串,
* 解析失败按空处理(页面脚本也可能被站点 CSP 拦掉)。
*/
private class LoginProbeBridge(private val onProbe: (String) -> Unit) {
@android.webkit.JavascriptInterface
fun onProbe(json: String) {
runCatching { onProbe(json) }
}
}
/** 解析探测脚本回传的 JSON(扁平 string→string 结构)。 */
private fun parseProbeJson(json: String): Map<String, String> = runCatching {
val obj = org.json.JSONObject(json)
buildMap {
obj.keys().forEach { k ->
val v = obj.optString(k, "")
if (v.isNotEmpty()) put(k, v)
}
}
}.getOrDefault(emptyMap())
/**
* 脱敏日志 URL:只保留 scheme://host/path,去掉 query/fragment(OAuth 回调的
* userJwt/userInfo 等敏感参数不落调试日志,与 ViewModel 侧“只记参数名”口径一致)。
* 解析失败时回退为截断原文。
*/
private fun String.toSafeLogUrl(maxLen: Int = 200): String {
val parsed = runCatching { android.net.Uri.parse(this) }.getOrNull()
val safe = parsed?.let { u ->
val host = u.host ?: ""
buildString {
append(u.scheme ?: "").append("://").append(host).append(u.path.orEmpty())
}
}
return if (safe.isNullOrBlank()) take(maxLen) else safe
}

View File

@ -1,10 +1,32 @@
package com.rainy.token.ui.webview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.repository.WebViewSessionSaver
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -13,38 +35,356 @@ import kotlinx.coroutines.flow.update
import javax.inject.Inject
/**
* WebView 登录容器 ViewModel。计划 4.1:
* - 持有目标 URL(来自 ServiceConfig)
* - 提供 [onLoginSuccess] 回调:抓 Cookie → 加密存 SecureStorage
* - 跟踪"用户手动确认登录"状态(N 秒超时未识别成功时显示按钮)
* WebView 登录容器 ViewModel。
*
* 支持三种服务的软件内登录:
* - **Trae**:打开 trae.cn 授权页,用户登录后拦截 `http://127.0.0.1:18080/authorize`
* 回调,解析 `userJwt` 中的 JWT 并保存为 [Credential.TraeCredential]。
* - **WorkBuddy**:调用 copilot.tencent.com 设备授权 API 获取 QR 码 URL,
* 在 WebView 中展示二维码,后台轮询直到用户扫码授权,保存为
* [Credential.WorkBuddyCredential]。
* - **Sub2API**:打开自托管面板登录页,注入 JS 读取 localStorage.auth_token。
*/
@HiltViewModel
class WebViewLoginViewModel @Inject constructor(
private val sessionSaver: WebViewSessionSaver
private val sessionSaver: WebViewSessionSaver,
private val credentialRepository: CredentialRepository,
private val okHttpClient: OkHttpClient
) : ViewModel() {
private val _uiState = MutableStateFlow(WebViewLoginUiState())
val uiState: StateFlow<WebViewLoginUiState> = _uiState.asStateFlow()
fun bind(service: ServiceType) {
/** WorkBuddy 轮询协程,bind 新服务时取消。 */
private var workBuddyPollingJob: Job? = null
/** 目标账号(多账号服务);null = 新建账号。 */
private var accountId: String? = null
fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: String? = null) {
workBuddyPollingJob?.cancel()
workBuddyPollingJob = null
this.accountId = accountId?.takeIf { it.isNotBlank() }
val config = ServiceConfigProvider.get(service)
_uiState.update {
it.copy(
service = service,
loginUrl = config.loginUrl
loginUrl = "",
loginSucceeded = false,
error = null,
pendingManualConfirm = false
)
}
when (service) {
ServiceType.TRAE -> bindTrae()
ServiceType.WORKBUDDY -> bindWorkBuddy()
ServiceType.OPENCODE_GO -> bindOpenCodeGo()
else -> bindSub2Api(service, config, configuredBaseUrl)
}
}
// ─── OpenCode Go ────────────────────────────────────────────────────────
/**
* 成功 URL 模式匹配(计划 4.1:精确匹配 + fallback)。
*
* 默认启发:URL 跳转到非登录域 + 路径不包含 /auth/login/signin 等关键词,
* 即认为登录完成。具体规则各服务可在 [ServiceConfigProvider] 扩展。
* OpenCode Go 应用内登录:WebView 打开 opencode.ai/auth(GitHub / Google OAuth)。
* 登录后用户会落到 dashboard(`https://opencode.ai/workspace/{workspaceId}/go`),
* 此时从 URL 提取 workspaceId、从 CookieManager 提取 `auth` cookie,
* 两者即 [OpenCodeGoRepository.fetchBalance] 所需的全部凭据,自动落库免手抄。
*/
private fun bindOpenCodeGo() {
_uiState.update {
it.copy(loginUrl = "https://opencode.ai/auth", trustedHosts = setOf("opencode.ai"))
}
}
/** dashboard URL → 提取 workspaceId 并连同 auth cookie 保存;成功返回 true。 */
private fun trySaveOpenCodeSession(url: String): Boolean {
val workspaceId = OPENCODE_WORKSPACE_REGEX.find(url)?.groupValues?.get(1) ?: return false
val cookieString = android.webkit.CookieManager.getInstance().getCookie(url) ?: return false
val authCookie = cookieString.split(";")
.mapNotNull { entry ->
val parts = entry.trim().split("=", limit = 2)
if (parts.size != 2 || parts[0] != "auth") null else parts[1].trim()
}
.firstOrNull { it.isNotBlank() } ?: return false
viewModelScope.launch {
val saved = sessionSaver.saveOpenCodeGoSession(
authCookie = authCookie,
workspaceId = workspaceId
)
if (saved != null) {
_uiState.update {
it.copy(loginSucceeded = true, savedSession = saved, pendingManualConfirm = false, error = null)
}
}
}
return true
}
// ─── Trae ──────────────────────────────────────────────────────────────
private fun bindTrae() {
val traceId = randomHex(16)
val machineId = randomHex(32)
// 设备 ID:claim 签到接口按 x-device-id 做设备级每日去重/风控,要求与 OAuth
// 会话绑定的设备一致。这里持久化生成每机唯一 ID(首次生成后跨登录复用),
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
val deviceId = traeDeviceId()
val callbackUrl = "http://127.0.0.1:18080/authorize"
val authUrl = buildString {
append("https://www.trae.cn/authorization?")
append("login_version=1&auth_from=solo&login_channel=native_ide&")
append("auth_type=local&client_id=en1oxy7wnw8j9n&redirect=0&")
append("login_trace_id=$traceId&")
append("auth_callback_url=").append(java.net.URLEncoder.encode(callbackUrl, "UTF-8")).append("&")
append("machine_id=$machineId&")
append("device_id=$deviceId&")
append("x_device_id=$deviceId&")
append("x_machine_id=$machineId&")
append("x_device_brand=PC&x_device_type=PC&")
append("x_os_version=1.0&x_app_version=0.1.43&")
append("x_app_type=stable&plugin_version=2.3.62834")
}
DebugLog.i("TraeLogin", "bindTrae: 授权URL已生成 len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
_uiState.update { it.copy(loginUrl = authUrl) }
}
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */
private fun traeDeviceId(): String {
val prefs = com.rainy.token.RainyTokenApplication.appContext
.getSharedPreferences("trae_login", android.content.Context.MODE_PRIVATE)
prefs.getString("device_id", null)?.takeIf { it.isNotBlank() }?.let { return it }
val id = randomDigits(19)
prefs.edit().putString("device_id", id).apply()
return id
}
/**
* Trae 授权回调拦截。URL 形如:
* `http://127.0.0.1:18080/authorize?userJwt=<JSON>&userInfo=<JSON>&...`
*
* `userJwt` 是 JSON 字符串,含 `{ Token, RefreshToken, ClientID, TokenExpireAt, ... }`。
* 提取 `Token`(即 Cloud-IDE-JWT)保存为 [Credential.TraeCredential]。
*/
fun onTraeCallback(url: String) {
val service = _uiState.value.service ?: return
if (service != ServiceType.TRAE) return
if (_uiState.value.loginSucceeded) return
val parsed = url.toHttpUrlOrNull() ?: run {
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
return
}
// 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志)
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}")
// Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串
val userJwtRaw = parsed.queryParameter("userJwt")
?: parsed.queryParameter("user_jwt")
?: run {
DebugLog.w("TraeLogin", "onTraeCallback: 无 userJwt/user_jwt 参数")
return
}
val userJwt = try {
Json.parseToJsonElement(userJwtRaw).jsonObject
} catch (e: Exception) {
DebugLog.w("TraeLogin", "onTraeCallback: userJwt JSON 解析失败: ${e.message}")
return
}
val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
if (token.isNullOrBlank()) {
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但 userJwt 无 Token(userJwtKeys=${userJwt.keys})")
return
}
DebugLog.i("TraeLogin", "onTraeCallback: userJwt 解析成功 token非空=${token.isNotBlank()}")
// 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
// 注意不能用 userJwt.ClientID——那是固定客户端标识(en1oxy7wnw8j9n),所有
// 用户相同,作 x-device-id 会被服务端按设备风控拒绝(「参与用户太多」)。
val deviceId = traeDeviceId()
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
val userId = parsed.queryParameter("userInfo")?.let { raw ->
runCatching {
val u = Json.parseToJsonElement(raw).jsonObject
u["userId"]?.jsonPrimitive?.contentOrNull
?: u["user_id"]?.jsonPrimitive?.contentOrNull
?: u["id"]?.jsonPrimitive?.contentOrNull
?: u["accountId"]?.jsonPrimitive?.contentOrNull
}.getOrNull()
}
DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (userId.isNullOrBlank()) "null" else "ok"}")
viewModelScope.launch {
// 刚成功登录的凭据视为已验证:lastVerifiedAt 记为当前时间,
// 否则首页/列表会显示"未验证/未登录"直到进详情页拉余额后才正常
val credential = Credential.TraeCredential(
service = ServiceType.TRAE,
jwt = token,
region = "CN",
deviceId = deviceId,
userId = userId?.takeIf { it.isNotBlank() },
lastVerifiedAt = System.currentTimeMillis()
)
val effectiveId = credentialRepository.save(credential, accountId)
_uiState.update {
it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null, loginAccountId = effectiveId.ifBlank { null })
}
DebugLog.i("TraeLogin", "onTraeCallback: 凭据已保存,登录完成")
}
}
// ─── WorkBuddy ─────────────────────────────────────────────────────────
private fun bindWorkBuddy() {
viewModelScope.launch {
try {
val request = Request.Builder()
.url("https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI")
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody("application/json".toMediaTypeOrNull()))
.build()
val (body, ok) = withContext(Dispatchers.IO) {
val resp = okHttpClient.newCall(request).execute()
val s = resp.body?.string().orEmpty()
s to resp.isSuccessful
}
if (!ok || body.isBlank()) {
_uiState.update {
it.copy(error = UiText.Resource(R.string.error_network_generic))
}
return@launch
}
val json = Json.parseToJsonElement(body).jsonObject
val data = json["data"]?.jsonObject
val state = data?.get("state")?.jsonPrimitive?.contentOrNull
val authURL = data?.get("authUrl")?.jsonPrimitive?.contentOrNull
?: data?.get("authURL")?.jsonPrimitive?.contentOrNull
if (state.isNullOrBlank() || authURL.isNullOrBlank()) {
_uiState.update {
it.copy(error = UiText.Resource(R.string.error_network_generic))
}
return@launch
}
_uiState.update { it.copy(loginUrl = authURL) }
workBuddyPollingJob = viewModelScope.launch { pollWorkBuddyToken(state) }
} catch (_: Exception) {
_uiState.update {
it.copy(error = UiText.Resource(R.string.error_network_generic))
}
}
}
}
/** 每 2 秒轮询一次 token 接口,120 秒超时。 */
private suspend fun pollWorkBuddyToken(state: String) {
val timeoutMs = 120_000L
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < timeoutMs) {
if (_uiState.value.loginSucceeded) return
try {
val request = Request.Builder()
.url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state")
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
.get()
.build()
val body = withContext(Dispatchers.IO) {
val resp = okHttpClient.newCall(request).execute()
val s = resp.body?.string().orEmpty()
resp.close()
s
}
val json = Json.parseToJsonElement(body).jsonObject
val code = json["code"]?.jsonPrimitive?.intOrNull ?: -1
if (code == 0) {
val data = json["data"]?.jsonObject
val accessToken = data?.get("accessToken")?.jsonPrimitive?.contentOrNull
?: data?.get("access_token")?.jsonPrimitive?.contentOrNull
val refreshToken = data?.get("refreshToken")?.jsonPrimitive?.contentOrNull
?: data?.get("refresh_token")?.jsonPrimitive?.contentOrNull
val expiresIn = data?.get("expiresIn")?.jsonPrimitive?.longOrNull
?: data?.get("expires_in")?.jsonPrimitive?.longOrNull ?: 7200L
if (!accessToken.isNullOrBlank()) {
val expiresAt = System.currentTimeMillis() + expiresIn * 1000
// 刚成功登录的凭据视为已验证(见 Trae 同款注释)
val credential = Credential.WorkBuddyCredential(
service = ServiceType.WORKBUDDY,
accessToken = accessToken,
refreshToken = refreshToken ?: "",
expiresAt = expiresAt,
lastVerifiedAt = System.currentTimeMillis()
)
val effectiveId = credentialRepository.save(credential, accountId)
_uiState.update {
it.copy(
loginSucceeded = true,
pendingManualConfirm = false,
error = null,
loginAccountId = effectiveId.ifBlank { null }
)
}
return
}
}
} catch (_: Exception) {
// 网络异常时继续轮询,直到超时
}
delay(2000)
}
_uiState.update { it.copy(error = UiText.Resource(R.string.error_login_timeout)) }
}
// ─── Sub2API (existing) ────────────────────────────────────────────────
private fun bindSub2Api(
service: ServiceType,
config: com.rainy.token.domain.service.ServiceConfig,
configuredBaseUrl: String?
) {
_uiState.update {
it.copy(service = service, loginUrl = config.loginUrl, trustedHosts = emptySet())
}
viewModelScope.launch {
val base = configuredBaseUrl
?: (credentialRepository.get(service) as? Credential.Sub2ApiCredential)?.baseUrl
val hosts = buildSet {
base?.toHttpUrlOrNull()?.host?.let { add(it) }
config.loginUrl.toHttpUrlOrNull()?.host?.let { add(it) }
}
_uiState.update {
it.copy(
loginUrl = config.loginUrl.ifBlank { base.orEmpty() },
trustedHosts = hosts
)
}
}
}
// ─── Page lifecycle ────────────────────────────────────────────────────
fun onPageFinished(url: String) {
val current = _uiState.value
if (current.loginSucceeded) return
// OpenCode:URL 到 dashboard(/workspace/{id}/go)即自动提取凭据,无 dashboard
// (如停在 /auth 登录页)则静默等待,不打扰用户操作。
if (_uiState.value.service == ServiceType.OPENCODE_GO) {
if (!trySaveOpenCodeSession(url) && !looksLikeLoggedInPage(url)) {
_uiState.update { it.copy(pendingManualConfirm = false) }
}
return
}
// Trae 的登录完成由 OAuth 回调(127.0.0.1:18080/authorize?userJwt=...,onTraeCallback)
// 自动判定,onPageFinished 的 URL 关键词启发式对 Trae 不适用:授权页 URL 含 /auth,
// 会在用户尚在页面操作时就误弹「登录完成?」提示;且 saveSession 的 cookie 抓取
// 对 JWT 凭据无效,反而可能覆盖已保存的凭据。Trae 流程静默等待回调即可,
// 想放弃登录由 BackHandler 返回。
if (_uiState.value.service == ServiceType.TRAE) return
// WorkBuddy 的登录完成由 token 轮询(pollWorkBuddyToken)自动判定:扫码页面 URL
// 含 /authorize,关键词启发式会误弹「登录完成?」;且确认后走 cookie 抓取会把
// 错误的 SessionCredential 保存到 WORKBUDDY 服务。静默等待轮询结果即可,
// 轮询成功自动落库并回调返回,超时由 pollWorkBuddyToken 置 error。
if (_uiState.value.service == ServiceType.WORKBUDDY) return
if (looksLikeLoggedInPage(url)) {
saveSession(url)
} else {
@ -53,6 +393,17 @@ class WebViewLoginViewModel @Inject constructor(
}
fun confirmLoginManually() {
val service = _uiState.value.service ?: return
// 防御性守卫:Trae/WorkBuddy/OpenCode 均通过各自的自动识别(回调/轮询/URL 正则)
// 完成登录,不应用 cookie 抓取兜底覆盖凭据;未来若有新入口误将 pendingManualConfirm
// 置位,这里直接短路,避免把错误的 SessionCredential 写入这些服务。
if (service == ServiceType.TRAE ||
service == ServiceType.WORKBUDDY ||
service == ServiceType.OPENCODE_GO
) {
dismissManualPrompt()
return
}
val url = _uiState.value.loginUrl
saveSession(url)
}
@ -61,40 +412,138 @@ class WebViewLoginViewModel @Inject constructor(
_uiState.update { it.copy(pendingManualConfirm = false) }
}
fun onLocalStorageProbed(values: Map<String, String>) {
val service = _uiState.value.service ?: return
if (_uiState.value.loginSucceeded) return
if (!isValidProbe(values[SUB2_NONCE_KEY])) return
if (service != ServiceType.SUB2API) return
val token = values[SUB2_AUTH_TOKEN_KEY].orEmpty()
if (token.isBlank()) return
val origin = values[SUB2_ORIGIN_KEY].orEmpty()
if (!isTrustedOrigin(origin)) return
viewModelScope.launch {
val saved = sessionSaver.saveSub2ApiPanelSession(
baseUrl = origin.ifBlank { _uiState.value.loginUrl },
authToken = token,
siteIcon = values[SUB2_ICON_KEY],
email = values[SUB2_EMAIL_KEY],
accountId = this@WebViewLoginViewModel.accountId
)
if (saved != null) {
_uiState.update {
it.copy(
loginSucceeded = true,
pendingManualConfirm = false,
error = null,
// 回填实际写入的账号 ID:新建账号流程返回编辑页时保持编辑该账号,
// 避免用户再次点保存产生重复账号
loginAccountId = saved.accountId.ifBlank { null }
)
}
}
}
}
private fun isTrustedOrigin(origin: String): Boolean {
val probeHost = origin.toHttpUrlOrNull()?.host ?: return false
val allowed = _uiState.value.trustedHosts
if (allowed.isEmpty()) return false
return allowed.any { it.equals(probeHost, ignoreCase = true) }
}
private val probeNonce: String = java.util.UUID.randomUUID().toString().replace("-", "")
val probeScript: String get() = buildProbeScript(probeNonce)
fun isValidProbe(nonce: String?): Boolean =
nonce != null && nonce.isNotEmpty() && nonce == probeNonce
private fun saveSession(url: String) {
val service = _uiState.value.service ?: return
val saved = sessionSaver.saveFromCookieManager(service = service, url = url)
if (saved != null) {
_uiState.update {
it.copy(
loginSucceeded = true,
savedSession = saved,
pendingManualConfirm = false
)
}
} else {
_uiState.update {
it.copy(
error = "未抓到 Cookie,请确认已登录",
pendingManualConfirm = true
)
viewModelScope.launch {
val saved = sessionSaver.saveFromCookieManager(service = service, url = url)
if (saved != null) {
_uiState.update {
it.copy(
loginSucceeded = true,
savedSession = saved,
pendingManualConfirm = false
)
}
} else {
_uiState.update {
it.copy(
error = UiText.Resource(R.string.error_cookie_not_found),
pendingManualConfirm = true
)
}
}
}
}
private fun looksLikeLoggedInPage(url: String): Boolean {
val u = url.lowercase()
// 简单启发:URL 不再包含登录路径关键字
val loginKeywords = listOf("/auth", "/login", "/signin", "/oauth")
return loginKeywords.none { u.contains(it) }
}
// ─── Helpers ───────────────────────────────────────────────────────────
private fun randomHex(length: Int): String {
val bytes = ByteArray(length / 2)
java.security.SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private fun randomDigits(length: Int): String {
val sb = StringBuilder(length)
val rnd = java.security.SecureRandom()
repeat(length) { sb.append(rnd.nextInt(10)) }
return sb.toString()
}
override fun onCleared() {
workBuddyPollingJob?.cancel()
super.onCleared()
}
}
private const val SUB2_AUTH_TOKEN_KEY = "auth_token"
private const val SUB2_ORIGIN_KEY = "origin"
private const val SUB2_ICON_KEY = "site_icon"
private const val SUB2_EMAIL_KEY = "email"
private const val SUB2_NONCE_KEY = "nonce"
/** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */
private val OPENCODE_WORKSPACE_REGEX = Regex("""/workspace/([^/?#]+)/go""")
internal fun buildProbeScript(nonce: String): String = """
(function () {
try {
var out = {};
var t = window.localStorage.getItem('auth_token');
if (t) out.auth_token = t;
var u = window.localStorage.getItem('auth_user');
if (u) {
try { out.email = (JSON.parse(u) || {}).email || ''; } catch (e) {}
}
var icon = document.querySelector('link[rel~="icon"]');
if (icon && icon.href) out.site_icon = icon.href;
out.origin = window.location.origin;
out.nonce = '$nonce';
if (window.AndroidBridge && window.AndroidBridge.onProbe) {
window.AndroidBridge.onProbe(JSON.stringify(out));
}
} catch (e) {}
})();
"""
data class WebViewLoginUiState(
val service: ServiceType? = null,
val loginUrl: String = "",
val trustedHosts: Set<String> = emptySet(),
val loginSucceeded: Boolean = false,
val savedSession: Credential.SessionCredential? = null,
val pendingManualConfirm: Boolean = false,
val error: String? = null
)
val error: UiText? = null,
/** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */
val loginAccountId: String? = null
)

View File

@ -0,0 +1,118 @@
package com.rainy.token.ui.webview
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import com.rainy.token.R
/**
* 通用 WebView 页面容器。用于在 App 内打开外部操作页(如 DeepSeek 充值页)。
*
* - 复用 [WebViewLoginScreen] 的 WebView 模式:JS/DOM 存储开启,适配移动视口;
* - 页面加载后用页面标题替换顶栏标题(失败则保持传入标题);
* - 返回键优先回退页内历史,无历史时退出页面。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WebViewPageScreen(
url: String,
title: String,
onBack: () -> Unit
) {
var webView: WebView? = remember { null }
var pageTitle by remember { mutableStateOf(title) }
// 顶栏返回与系统返回保持一致:优先回退页内历史,无历史才退出
val goBack: () -> Unit = {
if (webView?.canGoBack() == true) {
webView?.goBack()
} else {
onBack()
}
}
BackHandler(onBack = goBack)
// 页面销毁时释放 WebView 原生资源,避免内存泄漏;先移除父级再 destroy,规避
// "Calling destroy while still attached" 的极端时序崩溃
DisposableEffect(Unit) {
onDispose {
webView?.let { view ->
view.parent?.let { (it as? ViewGroup)?.removeView(view) }
view.destroy()
}
webView = null
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(pageTitle) },
navigationIcon = {
IconButton(onClick = goBack) {
Icon(
Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back)
)
}
}
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
AndroidView(
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
// 用网页真实标题替换占位标题(忽略空白标题)
view?.title?.takeIf { it.isNotBlank() }?.let { loaded ->
if (loaded != pageTitle) pageTitle = loaded
}
}
}
webChromeClient = WebChromeClient()
webView = this
loadUrl(url)
}
},
modifier = Modifier.fillMaxSize()
)
}
}
}

Some files were not shown because too many files have changed in this diff Show More