Compare commits

..

62 Commits
main ... 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
101 changed files with 13743 additions and 604 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/
.DS_Store
/build
app/build/

View File

@ -5,7 +5,7 @@
[![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 Go, Codex / ChatGPT, and Ollama balances and usage quotas. Pink-toned brand UI, with companion home screen widgets.
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.
---
@ -59,7 +59,7 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
| ➕ **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 · Strawberry Pink #FF85A2 · Sakura Pink #FFD1DC |
| 🎀 **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 |
@ -72,7 +72,7 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
Download the latest APK from [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases).
> ⚠️ You need a DeepSeek API Key, OpenCode Go login credentials, CommandCode Go API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Cookie to fetch data.
> ⚠️ 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.
---

View File

@ -5,7 +5,7 @@
[![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 Go、Codex / ChatGPT、Ollama 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode、Codex / ChatGPT、Ollama 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Family tools.
---
@ -59,7 +59,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
| 🎀 **雨晴粉主题** | 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 动态获取 |
@ -72,7 +72,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode Go API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Cookie 才能拉取数据。
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Cookie 才能拉取数据。
---

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
versionCode = 13
versionName = "1.6.3"
versionCode = 14
versionName = "1.6.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -190,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)

View File

@ -4,6 +4,7 @@
<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"
@ -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

@ -15,7 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
*
* 布局策略:
* - 外层只套 RainyTokenTheme(统一品牌色 + 字体)
* - 再套 RainyBackground(樱粉渐变背景,全局共享)
* - 再套 RainyBackground(精致粉调渐变背景,全局共享)
* - NavHost 在背景之上,每个页面自己用 Scaffold 处理 TopAppBar 和 padding
* - **不**在外层再套 Scaffold —— 避免双 Scaffold 嵌套的 padding 计算混乱
*/

View File

@ -21,7 +21,11 @@ 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>,
@ -31,6 +35,9 @@ class BalanceCache(
// 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 decode(raw)
@ -38,18 +45,46 @@ class BalanceCache(
suspend fun get(service: ServiceType): CachedBalance? = getAll()[service]
suspend fun put(service: ServiceType, balance: ServiceBalance) {
/** 读取缓存:无 [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
}
putCached(
service = service,
cachedBalance = CachedBalance(
balance = balance,
fetchedAt = System.currentTimeMillis()
)
)
val cached = CachedBalance(balance = balance, fetchedAt = System.currentTimeMillis())
if (accountId.isNullOrBlank()) {
putCached(service, cached)
} else {
putAccountCached(service, accountId, cached)
}
}
/** 恢复一份已有缓存并保留原 fetchedAt;仅供凭据测试安全回滚使用。 */
@ -61,8 +96,58 @@ class BalanceCache(
}
}
/** 删除单个服务缓存。凭据新增、替换或删除时由 CredentialRepository 调用。 */
/** 以账号复合键写入缓存(保留原 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
@ -76,10 +161,32 @@ class BalanceCache(
}
}
suspend fun clear() {
dataStore.edit { it.remove(cacheKey) }
/** 删除单个账号的缓存(默认账号的旧结构镜像由调用方另行清理)。 */
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)
it.remove(accountsCacheKey)
}
}
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 {
@ -96,6 +203,22 @@ class BalanceCache(
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
@ -113,4 +236,4 @@ data class CachedBalance(
/** 顶层 DataStore 委托。文件名对应计划 7.1。 */
val Context.balanceCacheDataStore: DataStore<Preferences> by preferencesDataStore(
name = "balance_cache"
)
)

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

@ -19,7 +19,7 @@ import java.io.IOException
import javax.inject.Singleton
/**
* CommandCode Go 余额仓库。
* CommandCode 余额仓库。
*
* 调 JSON API 获取月度配额余额 + 用量窗口信息:
* GET https://api.commandcode.ai/alpha/billing/credits

View File

@ -22,7 +22,7 @@ import java.util.TimeZone
import javax.inject.Singleton
/**
* CommandCode Go 用量记录仓库。
* CommandCode 用量记录仓库。
*
* 调 JSON API 分页抓取 usage 记录:
* GET https://api.commandcode.ai/internal/usage?limit=50

View File

@ -3,11 +3,14 @@ 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 java.security.MessageDigest
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.currentCoroutineContext
@ -19,6 +22,12 @@ import kotlinx.coroutines.sync.withLock
*
* 所有凭据变更都经过 [mutationMutex] 串行化,并维护进程内 revision。网络刷新先取得
* [CredentialSnapshot];请求结束后只有快照仍为当前版本时,暂存的凭据与余额才会提交。
*
* 多账号服务(Trae / WorkBuddy):
* - 账号元数据([ServiceAccountInfo])存 `accounts_{storageKey}`(加密 DataStore);
* - 每个账号的凭据独立存 `credential_{storageKey}_{accountId}`;
* - 旧版本单凭据存 `credential_{storageKey}`,首次读取时自动迁移为默认账号
* (固定 id = [LEGACY_DEFAULT_ACCOUNT_ID]),旧键保留作为兼容回退。
*/
@Singleton
class CredentialRepository @Inject constructor(
@ -29,7 +38,11 @@ class CredentialRepository @Inject constructor(
internal data class CredentialSnapshot(
val credential: Credential,
val revision: Long,
val fingerprint: String
val fingerprint: String,
/** 快照所属账号;单账号服务为 null。 */
val accountId: String? = null,
/** 创建快照时调用方请求的账号 ID(多账号服务可能是 null=默认账号),用于会话内精确匹配。 */
val requestedAccountId: String? = accountId
) {
val service: ServiceType get() = credential.service
}
@ -37,7 +50,19 @@ class CredentialRepository @Inject constructor(
internal data class LocalState(
val status: CredentialStatus,
val fingerprint: String?,
val cachedBalance: CachedBalance?
val cachedBalance: CachedBalance?,
/** 当前凭据对象(首页卡片可能需要读取如 Sub2API 站点 Logo 等附加字段)。 */
val credential: Credential? = null
)
/** 按账号展开的本地状态(多账号服务一个账号一条;单账号服务一条 [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(
@ -47,69 +72,232 @@ class CredentialRepository @Inject constructor(
)
private val mutationMutex = Mutex()
private val revisions = mutableMapOf<ServiceType, Long>()
private val cacheRollbackEntries = mutableMapOf<ServiceType, CacheRollbackEntry>()
private val revisions = mutableMapOf<String, Long>()
private val cacheRollbackEntries = mutableMapOf<String, CacheRollbackEntry>()
private fun keyFor(service: ServiceType): String = "credential_${service.storageKey}"
private fun accountDocKeyFor(service: ServiceType): String = "accounts_${service.storageKey}"
suspend fun save(credential: Credential) {
/** 凭据存储键:单账号(或兼容回退)为 `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
}
)
}
}
/**
* 删除单个账号:删除该账号凭据 + 独立缓存;若删除的是默认账号则提升第一个
* 剩余账号为默认;最后一个账号删除后整个服务回到未配置状态。
*/
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
return session.snapshot.accountId ?: ""
}
mutationMutex.withLock {
val service = credential.service
val current = getUnlocked(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)
bumpRevision(service, effectiveId)
if (cacheIdentityChanged) {
cacheRollbackEntries[service] = CacheRollbackEntry(
cacheRollbackEntries[key] = CacheRollbackEntry(
testedFingerprint = credentialFingerprint(credential)!!,
previousIdentity = currentIdentity,
cachedBalance = if (current != null) balanceCache.get(service) else null
cachedBalance = if (current != null) {
accountCacheFor(service, effectiveId)
} else {
null
}
)
balanceCache.remove(service)
removeCacheFor(service, effectiveId)
} else {
cacheRollbackEntries.remove(service)
cacheRollbackEntries.remove(key)
}
putUnlocked(credential)
putUnlocked(credential, effectiveId)
return effectiveId ?: ""
}
}
suspend fun get(service: ServiceType): Credential? {
suspend fun get(service: ServiceType, accountId: String? = null): Credential? {
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
if (session.snapshot.service == service) {
return session.credentialForRead()
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) }
return mutationMutex.withLock { getUnlocked(service, accountId) }
}
internal suspend fun readLocalState(service: ServiceType): LocalState =
internal suspend fun readLocalState(
service: ServiceType,
accountId: String? = null
): LocalState =
mutationMutex.withLock {
val credential = getUnlocked(service)
localStateOf(service, credential, balanceCache.get(service))
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 cached = balanceCache.getAll()
val accountStates = readAccountStatesUnlocked()
val legacyCached = balanceCache.getAll()
ServiceType.entries.associateWith { service ->
val credential = getUnlocked(service)
localStateOf(service, credential, cached[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 {
bumpRevision(service)
cacheRollbackEntries.remove(service)
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)
secureStorage.remove(keyFor(service))
}
}
@ -122,12 +310,13 @@ class CredentialRepository @Inject constructor(
previous: Credential?
): Boolean = mutationMutex.withLock {
val service = testedSnapshot.service
val current = getUnlocked(service) ?: return@withLock false
val accountId = testedSnapshot.accountId
val current = getUnlocked(service, accountId) ?: return@withLock false
if (
!snapshotMatches(
snapshotRevision = testedSnapshot.revision,
snapshotFingerprint = testedSnapshot.fingerprint,
currentRevision = revisionFor(service),
currentRevision = revisionFor(service, accountId),
currentFingerprint = credentialFingerprint(current)
)
) return@withLock false
@ -135,35 +324,54 @@ class CredentialRepository @Inject constructor(
val currentIdentity = cacheIdentityFingerprint(current)
val previousIdentity = cacheIdentityFingerprint(previous)
val identityChanged = currentIdentity != previousIdentity
val rollbackEntry = cacheRollbackEntries[service]?.takeIf {
val rollbackEntry = cacheRollbackEntries[revisionKey(service, accountId)]?.takeIf {
it.testedFingerprint == testedSnapshot.fingerprint &&
it.previousIdentity == previousIdentity
}
bumpRevision(service)
bumpRevision(service, accountId)
if (identityChanged) {
balanceCache.remove(service)
removeCacheFor(service, accountId)
}
if (previous == null) {
secureStorage.remove(keyFor(service))
secureStorage.remove(keyFor(service, accountId))
} else {
require(previous.service == service) { "回滚凭据服务不匹配" }
putUnlocked(previous)
putUnlocked(previous, accountId)
}
if (identityChanged) {
rollbackEntry?.cachedBalance?.let { balanceCache.putCached(service, it) }
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(service)
cacheRollbackEntries.remove(revisionKey(service, accountId))
true
}
internal suspend fun snapshot(service: ServiceType): CredentialSnapshot? =
internal suspend fun snapshot(
service: ServiceType,
accountId: String? = null
): CredentialSnapshot? =
mutationMutex.withLock {
val credential = getUnlocked(service) ?: return@withLock null
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),
fingerprint = credentialFingerprint(credential)!!
revision = revisionFor(service, resolvedId),
fingerprint = credentialFingerprint(credential)!!,
accountId = resolvedId,
requestedAccountId = accountId?.takeIf { it.isNotBlank() }
)
}
@ -183,11 +391,11 @@ class CredentialRepository @Inject constructor(
val hasPendingWrites = pendingCredential != null || pendingBalance != null
val snapshot = session.snapshot
val current = getUnlocked(snapshot.service) ?: return@withLock false
val service = snapshot.service
val accountId = snapshot.accountId
val current = getUnlocked(service, accountId) ?: return@withLock false
val currentFingerprint = credentialFingerprint(current)
// 一键激活等只读会话不应被同凭据刷新造成的 revision 变化误伤;但凭据真正
// 替换/删除后,旧账户的成功响应和失败信息都必须丢弃。
if (!hasPendingWrites) {
return@withLock currentFingerprint == snapshot.fingerprint
}
@ -195,7 +403,7 @@ class CredentialRepository @Inject constructor(
val snapshotStillCurrent = snapshotMatches(
snapshotRevision = snapshot.revision,
snapshotFingerprint = snapshot.fingerprint,
currentRevision = revisionFor(snapshot.service),
currentRevision = revisionFor(service, accountId),
currentFingerprint = currentFingerprint
)
val canMergeRotatedCredential = pendingCredential != null &&
@ -211,22 +419,33 @@ class CredentialRepository @Inject constructor(
cacheIdentityFingerprint(current) != cacheIdentityFingerprint(finalCredential) &&
!sameLineage
bumpRevision(snapshot.service)
bumpRevision(service, accountId)
if (cacheIdentityChanged) {
balanceCache.remove(snapshot.service)
removeCacheFor(service, accountId)
}
if (pendingCredential != null) {
putUnlocked(pendingCredential)
putUnlocked(pendingCredential, accountId)
}
if (pendingBalance != null) {
balanceCache.put(snapshot.service, pendingBalance)
cacheRollbackEntries.remove(snapshot.service)
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): CredentialStatus {
val credential = get(service) ?: return CredentialStatus(
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
@ -244,6 +463,74 @@ class CredentialRepository @Inject constructor(
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?,
@ -268,31 +555,167 @@ class CredentialRepository @Inject constructor(
return LocalState(
status = status,
fingerprint = credentialFingerprint(credential),
cachedBalance = cached
cachedBalance = cached,
credential = credential
)
}
private suspend fun putUnlocked(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 = keyFor(credential.service),
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 getUnlocked(service: ServiceType): Credential? =
secureStorage.get(
key = keyFor(service),
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 fun revisionFor(service: ServiceType): Long = revisions[service] ?: 0L
/** 删除某账号(或单账号服务)关联的余额缓存;默认账号会一并清理旧结构镜像。 */
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 bumpRevision(service: ServiceType) {
revisions[service] = revisionFor(service) + 1L
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
@ -350,6 +773,23 @@ class CredentialRepository @Inject constructor(
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)
@ -396,11 +836,33 @@ class CredentialRepository @Inject constructor(
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(
@ -437,4 +899,4 @@ class CredentialRepository @Inject constructor(
return String(chars)
}
}
}
}

View File

@ -19,6 +19,9 @@ internal class RefreshWriteSession(
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
@ -72,4 +75,7 @@ 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

@ -9,6 +9,9 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except
class InvalidCredential(detail: String? = null, cause: Throwable? = null) :
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
/** 服务端 CheckLogin 确认登录态已失效(未登录或 JWT 过期),需重新登录 */
class SessionExpired : RepositoryError("登录已过期,请重新登录")
/** 请求期间凭据被保存、替换或删除;旧结果已安全丢弃。 */
class CredentialChanged : RepositoryError("凭据已变更,本次请求结果已丢弃")

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

@ -17,6 +17,10 @@ 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
@ -122,7 +126,7 @@ object NetworkModule {
): OpenCodeGoRepository = OpenCodeGoRepository(okHttpClient, credentialRepository, balanceCache)
/**
* CommandCode Go 用量仓库。
* CommandCode 用量仓库。
*/
@Provides
@Singleton
@ -132,7 +136,7 @@ object NetworkModule {
): CommandCodeUsageRepository = CommandCodeUsageRepository(okHttpClient, credentialRepository)
/**
* CommandCode Go 仓库:API Key 认证,调 JSON API。
* CommandCode 仓库:API Key 认证,调 JSON API。
*/
@Provides
@Singleton
@ -176,6 +180,49 @@ object NetworkModule {
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

@ -62,6 +62,83 @@ sealed class Credential {
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

@ -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,
@ -66,6 +73,29 @@ object ServiceConfigProvider {
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,13 +15,27 @@ enum class ServiceType(
val storageKey: String
) {
OPENCODE_GO("OpenCode Go", "opencode_go"),
COMMANDCODE_GO("CommandCode Go", "commandcode_go"),
COMMANDCODE_GO("CommandCode", "commandcode_go"),
DEEPSEEK("DeepSeek", "deepseek"),
CODEX("Codex / ChatGPT", "codex"),
OLLAMA("Ollama", "ollama");
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

@ -3,11 +3,15 @@ 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
@ -26,14 +30,21 @@ import kotlinx.coroutines.withContext
*/
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 commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
private val codexRepositoryProvider: Provider<CodexRepository>,
private val ollamaRepositoryProvider: Provider<OllamaRepository>
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> =
withCredentialSession(service) {
suspend operator fun invoke(
service: ServiceType,
accountId: String? = null
): Result<ServiceBalance> =
withCredentialSession(service, accountId) {
when (service) {
ServiceType.DEEPSEEK -> retryOnTransientError {
deepSeekRepositoryProvider.get().fetchBalance()
@ -50,6 +61,15 @@ class RefreshBalanceUseCase @Inject constructor(
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)
}
}
}
@ -77,11 +97,86 @@ class RefreshBalanceUseCase @Inject constructor(
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)
val snapshot = credentialRepository.snapshot(service, accountId)
?: return Result.failure(RepositoryError.InvalidCredential())
val session = RefreshWriteSession(snapshot)

View File

@ -6,7 +6,7 @@ import javax.inject.Inject
import javax.inject.Provider
/**
* CommandCode Go 用量同步 UseCase。
* CommandCode 用量同步 UseCase。
*
* 游标协议:每页返回 (记录列表, 下一页游标)。
* - cursor=null → 最新页

View File

@ -18,12 +18,14 @@ 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
@ -36,6 +38,7 @@ 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
@ -49,13 +52,18 @@ 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
/**
* 应用导航图。
@ -69,15 +77,36 @@ import com.rainy.token.ui.webview.WebViewLoginScreen
object Routes {
const val DASHBOARD = "dashboard"
const val SETTINGS = "settings"
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}"
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 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"
@ -92,13 +121,20 @@ 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) : 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
@ -190,12 +226,33 @@ private fun CompactNavHost() {
}
) {
composable(Routes.DASHBOARD) {
val rechargeLabel = stringResource(R.string.action_recharge)
DashboardScreen(
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
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) }
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) {
@ -254,11 +311,22 @@ private fun CompactNavHost() {
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = guardedPop,
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) },
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)
}
@ -273,40 +341,101 @@ private fun CompactNavHost() {
}
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,
accountId = accountId,
onBack = guardedPop,
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
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,
accountId = accountId,
onBack = guardedPop,
onLoginSucceeded = { 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,
accountId = accountId,
onBack = guardedPop,
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }
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)
}
@ -332,6 +461,8 @@ private fun ExpandedLayout() {
prevPane = detailPane
}
val rechargeLabel = stringResource(R.string.action_recharge)
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
@ -340,10 +471,18 @@ private fun ExpandedLayout() {
) {
DashboardScreen(
onOpenSettings = { detailPane = DetailPane.Settings },
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
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
)
}
@ -362,6 +501,9 @@ private fun ExpandedLayout() {
ExpandedDetailPane(
pane = detailPane,
onClose = { detailPane = DetailPane.Empty },
onSwitchAccount = { svc, acc ->
detailPane = DetailPane.ServiceDetail(svc, acc)
},
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
)
}
@ -372,6 +514,7 @@ private fun ExpandedLayout() {
private fun ExpandedDetailPane(
pane: DetailPane,
onClose: () -> Unit,
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
onCredentialEditClosed: () -> Unit = {}
) {
when (pane) {
@ -384,12 +527,45 @@ private fun ExpandedDetailPane(
}
}
is DetailPane.ServiceDetail -> {
ServiceDetailScreen(
service = pane.type,
onBack = onClose,
onConfigureCredential = { onClose() },
onStartWebViewLogin = { }
)
// 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()
@ -453,6 +629,124 @@ private fun ExpandedDetailPane(
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 时
@ -475,12 +769,25 @@ private fun ExpandedDetailPane(
SettingsScreen(
onBack = onClose,
onEditCredential = { type ->
settingsNavController.navigate(Routes.credentialEdit(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() })
}
@ -495,14 +802,23 @@ private fun ExpandedDetailPane(
}
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
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
CredentialEditScreen(
service = type,
accountId = accountId,
onBack = { settingsNavController.popBackStack() },
onStartWebViewLogin = { svc ->
settingsNavController.navigate(Routes.webviewLogin(svc))
onStartWebViewLogin = { svc, acc ->
settingsNavController.navigate(Routes.webviewLogin(svc, acc))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
@ -510,13 +826,29 @@ private fun ExpandedDetailPane(
}
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
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
WebViewLoginScreen(
service = type,
accountId = accountId,
onBack = { settingsNavController.popBackStack() },
onLoginSucceeded = { 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

@ -39,6 +39,8 @@ 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)
@ -103,8 +105,8 @@ fun DebugLogScreen(onBack: () -> Unit) {
items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry ->
val levelColor = when (entry.level) {
DebugLog.Level.INFO -> InkMuted
DebugLog.Level.WARN -> Color(0xFFFFA726)
DebugLog.Level.ERROR -> Color(0xFFE91E63)
DebugLog.Level.WARN -> StatusOrange
DebugLog.Level.ERROR -> StatusRed
}
Card(
modifier = Modifier.fillMaxWidth(),

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

@ -13,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
@ -42,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(
@ -57,12 +59,12 @@ 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(
painter = painterResource(id = R.drawable.ic_opencode_go_logo),
contentDescription = "CommandCode Go",
contentDescription = "CommandCode",
modifier = Modifier.size((size * 0.85).dp, ((size * 0.85f * 30f / 54f)).dp)
)
}
@ -117,5 +119,70 @@ fun ServiceIcon(
)
}
}
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
@ -44,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(

View File

@ -37,7 +37,7 @@ import com.rainy.token.ui.theme.StrawberryPink
import java.util.Locale
/**
* CommandCode Go 用量统计主卡片 —— 风格与 UsageStatsCard(OCGO)完全一致。
* CommandCode 用量统计主卡片 —— 风格与 UsageStatsCard(OCGO)完全一致。
* 仅展示核心指标:输入 Token(含 Cache)、输出 Token、总花费,
* 外加同步按钮和"查看详情"入口。详细统计在 CCGO 专属详情页。
*/

View File

@ -19,22 +19,27 @@ 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.offset
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.verticalScroll
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.shape.CircleShape
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.DateRange
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@ -43,6 +48,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@ -80,18 +86,24 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.BuildConfig
import com.rainy.token.R
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance
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.SiteIcon
import com.rainy.token.ui.components.StatusChip
import com.rainy.token.ui.components.StatusLevel
import com.rainy.token.ui.components.StatusStyle
import com.rainy.token.ui.components.AppTips
import com.rainy.token.ui.components.asString
import com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.PureWhite
import com.rainy.token.ui.theme.inkWarm
import com.rainy.token.ui.theme.StrawberryPink
import android.app.AppOpsManager
import android.appwidget.AppWidgetManager
@ -121,20 +133,32 @@ import java.util.Locale
@Composable
fun DashboardScreen(
onOpenSettings: () -> Unit,
onOpenService: (ServiceType) -> Unit,
onOpenApiManagement: () -> Unit,
onOpenService: (ServiceType, String?) -> Unit,
onOpenUsageDetail: () -> Unit,
onOpenCcgoUsageDetail: () -> Unit = {},
onOpenHeatmap: () -> Unit = {},
onOpenRecharge: (ServiceType) -> Unit = {},
viewModel: DashboardViewModel = hiltViewModel()
) {
// 从设置页返回时重新读取本地凭据状态 + 缓存(不自动发起网络请求)
// 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的
// 新账号(刚登录/导入),ViewModel 会自动拉一次余额让卡片立刻有数据
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.reloadLocalState()
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 自动签到汇报(每日首次打开 App 签到后弹一次)
val autoCheckinReport by viewModel.autoCheckinReport.collectAsStateWithLifecycle()
// Sub2API 面板管理弹窗数据源
val panelKeysState by viewModel.panelKeysState.collectAsStateWithLifecycle()
val panelGroups by viewModel.panelGroups.collectAsStateWithLifecycle()
val panelActionMessage by viewModel.panelActionMessage.collectAsStateWithLifecycle()
val recentlyCreatedKey by viewModel.recentlyCreatedKey.collectAsStateWithLifecycle()
val context = LocalContext.current
var showAddWidgetConfirm by remember { mutableStateOf(false) }
// 首页 Sub2API 卡片「面板管理」弹窗开关(内嵌与详情页同一个面板管理组件)
var showSub2PanelManagement by remember { mutableStateOf(false) }
// 一次性"长按拖拽排序"提示
var showDragHint by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@ -150,8 +174,14 @@ fun DashboardScreen(
?.split(',')
?.filter { it.isNotBlank() }
.orEmpty()
// 兼容历史排序 id:
// - 最早的格式 "service:{storageKey}"(单账号服务)→ "{storageKey}:"
// - 迁移后旧格式 "{storageKey}:" / "{storageKey}:default" 以及任意 "{storageKey}:{账号id}"
// (多账号服务)→ 服务组 id "group:{storageKey}"(叠放卡组)
// - 其余(用量卡/热力图卡等)原样保留
val migrated = saved.mapNotNull { id -> migrateOrderId(id) }.distinct()
cardOrder.clear()
cardOrder.addAll(saved)
cardOrder.addAll(migrated)
}
// 全局刷新触发器——每次 dashboard 刷新完成后 +1,UsageStatsCard 据此同步用量数据
@ -188,6 +218,45 @@ fun DashboardScreen(
)
}
// 每日自动签到结果汇报:「xx 已签到,积分增加 x」/ 已签 / 失败原因
if (autoCheckinReport != null) {
AlertDialog(
onDismissRequest = viewModel::consumeAutoCheckinReport,
title = { Text(stringResource(R.string.auto_checkin_dialog_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
autoCheckinReport!!.forEach { r ->
val serviceName = r.accountLabel?.let {
r.service.displayName + " · " + it
} ?: r.service.displayName
Text(
text = when {
r.failed && r.message.isNotBlank() ->
stringResource(R.string.auto_checkin_line_failed, serviceName, r.message)
r.failed ->
stringResource(R.string.auto_checkin_line_failed_generic, serviceName)
r.newlyCheckedIn && r.creditsGained > 0 ->
stringResource(R.string.auto_checkin_line_gained, serviceName, r.creditsGained)
// 今日确实已签(成功或服务端回报已签)→ 「今日已签到」
r.checkedInToday ->
stringResource(R.string.auto_checkin_line_already, serviceName)
// 其余(静默跳过等)不出现在汇报里,兜底走失败提示
else ->
stringResource(R.string.auto_checkin_line_failed_generic, serviceName)
},
style = MaterialTheme.typography.bodyMedium
)
}
}
},
confirmButton = {
TextButton(onClick = viewModel::consumeAutoCheckinReport) {
Text(stringResource(R.string.action_confirm), color = StrawberryPink)
}
}
)
}
Scaffold(
containerColor = Color.Transparent,
topBar = {
@ -224,6 +293,13 @@ fun DashboardScreen(
tint = StrawberryPink
)
}
IconButton(onClick = onOpenApiManagement) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = stringResource(R.string.api_mgmt_title),
tint = StrawberryPink
)
}
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Filled.Settings,
@ -245,7 +321,8 @@ fun DashboardScreen(
.fillMaxSize()
.padding(innerPadding)
) {
if (uiState.cards.isEmpty()) {
// 仅初始加载时显示转圈;加载完即使没有已配置服务也渲染(展示用量卡片 + 空态引导)
if (uiState.loading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
@ -269,21 +346,62 @@ fun DashboardScreen(
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 随机小技巧提示(每次启动随机一条)
// 随机小技巧提示(每次启动随机一条)+ 最右侧自动签到开关
val tipTextRes = remember { AppTips.randomHintRes() }
Text(
text = "💡 " + stringResource(tipTextRes),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.padding(horizontal = 4.dp)
)
val autoCheckinEnabled by viewModel.autoCheckinEnabled.collectAsStateWithLifecycle()
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)
) {
Text(
text = "💡 " + stringResource(tipTextRes),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.auto_checkin_switch_label),
style = MaterialTheme.typography.labelMedium,
color = inkWarm(),
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.width(2.dp))
Switch(
checked = autoCheckinEnabled,
onCheckedChange = viewModel::setAutoCheckinEnabled,
colors = SwitchDefaults.colors(
checkedThumbColor = PureWhite,
checkedTrackColor = StrawberryPink,
checkedBorderColor = StrawberryPink,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
uncheckedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
}
val items = rememberDashboardItems(
cards = uiState.cards,
order = cardOrder,
onOpenSettings = onOpenSettings,
onOpenUsageDetail = onOpenUsageDetail,
onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
onOpenService = onOpenService,
onOpenHeatmap = onOpenHeatmap,
onOpenSub2PanelManagement = { accountId ->
viewModel.openSub2Panel(accountId)
showSub2PanelManagement = true
},
onOpenRecharge = onOpenRecharge,
refreshTrigger = usageSyncTrigger
)
DraggableDashboardCards(
@ -343,6 +461,70 @@ fun DashboardScreen(
}
}
}
// Sub2API 面板管理弹窗
// 滚动说明:AlertDialog 的 text 槽是固定高度且**不自带滚动容器**,内容超过上限会被直接裁掉
// (表现为「打开后滚不动、下面的 Key 看不到」)。这里给 text 槽加 verticalScroll + 高度上限,
// 让整个面板管理内容(Key 列表/新建/分组/兑换)可以正常上下滚动。
if (showSub2PanelManagement) {
val scrollState = rememberScrollState()
val maxDialogHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp
// 面板弹窗按当前选中的账号取余额:默认账号 accountId=null,多账号为具体 ID;
// 找不到匹配卡(如刚新建账号尚无余额)时回退到第一张 Sub2API 卡,避免显示空。
val panelAccountId by viewModel.sub2PanelAccountId.collectAsStateWithLifecycle()
val panelBalanceCard = uiState.cards.firstOrNull {
it.service == ServiceType.SUB2API && it.accountId == panelAccountId
} ?: uiState.cards.firstOrNull { it.service == ServiceType.SUB2API }
AlertDialog(
onDismissRequest = { showSub2PanelManagement = false },
// 标题与刷新按钮同一行:刷新重新拉取 Key 列表与分组
title = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.sub2_panel_manage_title),
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { viewModel.loadPanelKeys(); viewModel.loadPanelGroups() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.action_refresh)
)
}
}
},
text = {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxDialogHeight)
.verticalScroll(scrollState)
) {
com.rainy.token.ui.servicedetail.Sub2ApiPanelManagement(
balance = panelBalanceCard?.cachedBalance?.balance,
keysState = panelKeysState,
groups = panelGroups,
actionMessage = panelActionMessage,
createdKey = recentlyCreatedKey,
onLoadKeys = { viewModel.loadPanelKeys() },
onLoadGroups = { viewModel.loadPanelGroups() },
onCreateKey = viewModel::createPanelKey,
onSwitchGroup = viewModel::switchPanelGroup,
onDeleteKey = viewModel::deletePanelKey,
onRedeem = viewModel::redeemPanelCode,
onConsumeMessage = viewModel::consumePanelActionMessage,
onConsumeCreatedKey = viewModel::consumeRecentlyCreatedKey,
showHeader = false
)
}
},
confirmButton = {
TextButton(onClick = { showSub2PanelManagement = false }) {
Text(stringResource(R.string.action_close))
}
}
)
}
}
private const val DASHBOARD_ORDER_PREFS = "dashboard_card_order"
@ -361,27 +543,56 @@ private data class DashboardHomeItem(
private fun rememberDashboardItems(
cards: List<DashboardCardUi>,
order: List<String>,
onOpenSettings: () -> Unit,
onOpenUsageDetail: () -> Unit,
onOpenCcgoUsageDetail: () -> Unit,
onOpenService: (ServiceType) -> Unit,
onOpenService: (ServiceType, String?) -> Unit,
onOpenHeatmap: () -> Unit,
onOpenSub2PanelManagement: (String?) -> Unit,
onOpenRecharge: (ServiceType) -> Unit,
refreshTrigger: Int
): List<DashboardHomeItem> {
val defaultItems = buildList {
add(DashboardHomeItem(USAGE_OCGO_CARD_ID) {
UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger)
})
add(DashboardHomeItem(USAGE_CCGO_CARD_ID) {
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger)
})
add(DashboardHomeItem(HEATMAP_CARD_ID) {
HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap)
})
// 未配置任何服务时的引导卡片(首页仅显示已配置的服务)
if (cards.isEmpty()) {
add(DashboardHomeItem("empty_hint") {
EmptyServicesHintCard(onOpenSettings = onOpenSettings)
})
}
// 用量卡与热力图卡只在对应服务已配置时出现:
// 未配置 OCGO/CCGO 时不应展示它们的用量卡(此前无条件添加,导致未配置也占位)。
val configuredServices = cards.map { it.service }.toSet()
val ocgoConfigured = ServiceType.OPENCODE_GO in configuredServices
val ccgoConfigured = ServiceType.COMMANDCODE_GO in configuredServices
if (ocgoConfigured) {
add(DashboardHomeItem(USAGE_OCGO_CARD_ID) {
UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger)
})
}
if (ccgoConfigured) {
add(DashboardHomeItem(USAGE_CCGO_CARD_ID) {
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger)
})
}
// 热力图统计的是 OCGO 数据,未配置 OCGO 时同样不展示
if (ocgoConfigured) {
add(DashboardHomeItem(HEATMAP_CARD_ID) {
HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap)
})
}
// 服务卡片:每个账号一张普通卡(同服务多账号即多张连续卡片,不叠放)
cards.forEach { card ->
add(DashboardHomeItem("service:${card.service.storageKey}") {
// 多账号卡片用“账号 N”兜底显示名(同服务内默认账号在前,按展示顺序编号)
add(DashboardHomeItem(card.key) {
DashboardCard(
card = card,
onClick = { onOpenService(card.service) },
accountLabel = if (card.accountId != null) {
card.accountLabel?.takeIf { it.isNotBlank() }
?: stringResource(R.string.account_fallback_name, accountOrdinal(cards, card))
} else {
null
},
onClick = { onOpenService(card.service, card.accountId) },
onOpenUsageDetail = when (card.service) {
ServiceType.OPENCODE_GO -> onOpenUsageDetail
ServiceType.COMMANDCODE_GO -> onOpenCcgoUsageDetail
@ -389,15 +600,71 @@ private fun rememberDashboardItems(
},
// Token 活动热力图入口仅 OCGO 服务卡片显示(数据只统计 OCGO)
onOpenHeatmap = if (card.service == ServiceType.OPENCODE_GO) onOpenHeatmap else null,
// Sub2API 面板管理快捷入口(首页弹窗):绑定当前卡片所属账号
onOpenPanelManagement = if (card.service == ServiceType.SUB2API) {
{ accountId -> onOpenSub2PanelManagement(accountId ?: card.accountId) }
} else {
null
},
// 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开
onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null) {
{ onOpenRecharge(card.service) }
} else {
null
},
)
})
}
}
val itemById = defaultItems.associateBy { it.id }
val ordered = order.mapNotNull { itemById[it] }
val ordered = order.mapNotNull { id ->
// 回退兼容:叠放实验期间保存过 "group:{storageKey}" 排序 id 的用户,
// 旧 id 映射为当前该服务第一张账号卡 key,避免升级回退后卡片掉尾
if (id.startsWith("group:")) {
val serviceKey = id.removePrefix("group:")
defaultItems.firstOrNull { it.id.startsWith("$serviceKey:") && it.id != id }?.id ?: id
} else {
id
}
}.mapNotNull { itemById[it] }
return ordered + defaultItems.filterNot { item -> ordered.any { it.id == item.id } }
}
@Composable
/** 同服务内账号序号(1 起,用于“账号 N”兜底显示)。 */
private fun accountOrdinal(cards: List<DashboardCardUi>, card: DashboardCardUi): Int {
var idx = 1
for (c in cards) {
if (c.key == card.key) break
if (c.service == card.service) idx++
}
return idx
}
/** 历史排序 id → 当前 id。返回 null 表示该 id 无法对应任何卡片(忽略)。 */
private fun migrateOrderId(id: String): String? {
// 最早格式:"service:{storageKey}"
val servicePrefix = Regex("""^service:([^/:]+)$""").find(id)?.groupValues?.get(1)
if (servicePrefix != null) {
val svc = ServiceType.entries.firstOrNull { it.storageKey == servicePrefix } ?: return null
return if (ServiceType.isMultiAccount(svc)) {
"${svc.storageKey}:${CredentialRepository.LEGACY_DEFAULT_ACCOUNT_ID}"
} else {
"${svc.storageKey}:"
}
}
// 旧默认账号 key:多账号服务 "{key}:" → "{key}:default"(其余账号 key 原样保留)
val sep = id.indexOf(':')
if (sep > 0) {
val key = id.substring(0, sep)
val svc = ServiceType.entries.firstOrNull { it.storageKey == key } ?: return id
if (ServiceType.isMultiAccount(svc) && id.endsWith(":")) {
return "$key:${CredentialRepository.LEGACY_DEFAULT_ACCOUNT_ID}"
}
}
return id
}
@Composable
private fun DraggableDashboardCards(
items: List<DashboardHomeItem>,
@ -635,9 +902,12 @@ private fun DraggableDashboardCards(
@Composable
private fun DashboardCard(
card: DashboardCardUi,
accountLabel: String? = null,
onClick: () -> Unit,
onOpenUsageDetail: (() -> Unit)? = null,
onOpenHeatmap: (() -> Unit)? = null,
onOpenPanelManagement: ((String?) -> Unit)? = null,
onOpenRecharge: (() -> Unit)? = null,
) {
Card(
modifier = Modifier
@ -650,20 +920,68 @@ private fun DashboardCard(
Column(modifier = Modifier.padding(16.dp)) {
// ─── 顶部:图标 + 名称 + 状态 chip ───
Row(verticalAlignment = Alignment.CenterVertically) {
ServiceIcon(service = card.service, size = 44)
if (card.service == ServiceType.SUB2API && card.siteIcon != null) {
// Sub2API 面板登录后用站点真实 Logo 替换内置图标
SiteIcon(url = card.siteIcon, size = 44.dp)
} else {
ServiceIcon(service = card.service, size = 44)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = card.service.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
// 多账号:默认账号在卡片标题旁标「默认」
if (card.accountId != null && card.isDefaultAccount) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.account_default_badge),
style = MaterialTheme.typography.labelSmall,
color = StrawberryPink,
maxLines = 1,
softWrap = false,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(StrawberryPink.copy(alpha = 0.12f))
.padding(horizontal = 6.dp, vertical = 1.dp)
)
}
}
Text(
text = card.service.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(secondaryLineRes(card)),
// Sub2API 显示登录方式;Trae/WorkBuddy 显示「账号 N · 订阅状态」;
// 其余服务用固定描述
text = when (card.service) {
ServiceType.SUB2API -> sub2AuthModeText(card)
ServiceType.TRAE, ServiceType.WORKBUDDY -> {
val sub = subscriptionLine(card) ?: stringResource(secondaryLineRes(card))
if (card.accountId != null && accountLabel != null) {
"$accountLabel · $sub"
} else {
sub
}
}
else -> stringResource(secondaryLineRes(card))
},
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
// 今日已签到角标(Trae/WorkBuddy):让用户在首页即知签到状态,不用点进详情
if (card.checkedInToday) {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.checkin_badge_today),
style = MaterialTheme.typography.labelSmall,
color = StrawberryPink,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(StrawberryPink.copy(alpha = 0.12f))
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
StatusChip(style = card.statusBadgeStyle())
}
@ -714,16 +1032,78 @@ private fun DashboardCard(
}
}
// ─── 底部:更新时间 / 错误信息 ───
// ─── 底部:左侧更新时间/错误信息,右侧「面板管理 / 充值订阅」入口(同一行、右对齐)───
Spacer(modifier = Modifier.height(12.dp))
Text(
text = footerText(card).asString(),
style = MaterialTheme.typography.bodySmall,
color = if (card.lastFetchError != null)
MaterialTheme.colorScheme.error
else
inkMuted()
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = footerText(card).asString(),
style = MaterialTheme.typography.bodySmall,
color = if (card.lastFetchError != null)
MaterialTheme.colorScheme.error
else
inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
// 占据全部剩余宽度,把右侧按钮顶到卡片最右边。
// 注意不能再用一个 Spacer(weight) 平分剩余空间——那样按钮只会到中间。
modifier = Modifier.weight(1f)
)
if (card.service == ServiceType.SUB2API &&
card.credentialState != CredentialStatus.State.NOT_CONFIGURED &&
onOpenPanelManagement != null
) {
// 清零内边距让文字贴到卡片右内缘。
// 注意:Material3 的最小尺寸挂在 TextButton 内层节点上,外层 modifier 传
// defaultMinSize(0) 压不下去(实测为 no-op),这里不再写无用约束。
TextButton(
onClick = { onOpenPanelManagement?.invoke(card.accountId) },
contentPadding = PaddingValues(0.dp)
) {
Text(
stringResource(R.string.sub2_panel_manage_title),
color = StrawberryPink,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
tint = StrawberryPink,
// 图标盒即箭头可视范围;不用 offset 微调(offset 只改放置不改测量,
// 会让右移的那几 dp 落在按钮命中区之外造成误触)。
modifier = Modifier.size(14.dp)
)
}
}
// 充值/订阅入口(DeepSeek/Trae/WorkBuddy 等有官方 externalUrl 的服务):
// 与「面板管理」同款式同位置,点开 App 内 WebView 到官方充值/订阅页
if (onOpenRecharge != null &&
card.credentialState != CredentialStatus.State.NOT_CONFIGURED
) {
TextButton(
onClick = onOpenRecharge,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.padding(start = 8.dp)
) {
Text(
stringResource(R.string.action_subscribe_recharge),
color = StrawberryPink,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
tint = StrawberryPink,
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}
@ -776,6 +1156,42 @@ private fun HeatmapEntryCard(onOpenHeatmap: () -> Unit) {
}
}
/**
* 空态引导卡:首页仅显示已配置的服务,当一个都没配置时给出引导。
*/
@Composable
private fun EmptyServicesHintCard(onOpenSettings: () -> 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(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.dashboard_empty_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.dashboard_empty_subtitle),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = onOpenSettings) {
Text(stringResource(R.string.dashboard_empty_go_settings))
}
}
}
}
/**
* 仪表盘底部 footer:填空白 + 显示版本号。
* 容器透明无边框,让卡片列表与背景融合自然。

View File

@ -7,15 +7,18 @@ 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
@ -38,10 +41,13 @@ import kotlinx.coroutines.sync.Mutex
class DashboardViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
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()
/** 防止并发 refresh() 调用交错覆盖 UI。 */
@ -49,94 +55,152 @@ class DashboardViewModel @Inject constructor(
init {
viewModelScope.launch {
// 1) 先从缓存快速填充 UI(断网时也能看旧数据)
// 1) 先从缓存快速填充 UI(不阻塞首帧,断网时也能看旧数据)
loadFromCache()
// 2) 再发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值
// 2) 每日自动签到:必须先于 refresh() 串行完成。两者并发会对同一服务取
// 相同凭据快照,后提交者因快照过期被丢弃(CredentialChanged),而签到
// 在服务端已成功、角标也已落库 → 弹窗报「凭据」错误但积分实际到账,
// 展示自相矛盾。串行后签到先落库,refresh 再拉到含新积分的余额。
// 用 try/catch 而非 runCatching:后者会吞掉 CancellationException,
// 导致 ViewModel 销毁取消后协程仍继续往下跑。
// 2.5) 打开 App 时把零点后台闹钟重排到下次零点(开机/升级/后台被杀后的兜底)
autoCheckinManager.refreshSchedule()
try {
autoCheckinManager.runDailyIfDue()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
// 签到失败不阻塞余额刷新
}
// 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 的最新卡片:
* - 凭据变化时采用已清理过的当前账户缓存;
* - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显;
* - 以当前已配置服务(configuredTypes)为准重建,新增服务即时出现、删除服务即时消失;
* - 仍存在的服务复用旧卡片做增量合并:
* - 凭据变化时采用已清理过的当前账户缓存;
* - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显;
* - 不修改 refreshing,旧缓存快照也不能回滚新数据。
*/
fun reloadLocalState() {
viewModelScope.launch {
val localStates = credentialRepository.readLocalStates()
val accountStates = credentialRepository.readAccountStates()
_uiState.update { state ->
state.copy(
cards = state.cards.map { card ->
val local = localStates.getValue(card.service)
val credentialChanged = local.fingerprint != card.credentialFingerprint
val cacheAdvanced = !credentialChanged && isNewer(
candidate = local.cachedBalance,
current = card.cachedBalance
)
card.copy(
credentialState = local.status.state,
credentialFingerprint = local.fingerprint,
cachedBalance = if (credentialChanged) {
local.cachedBalance
} else {
newerOf(card.cachedBalance, local.cachedBalance)
},
// 凭据变化或出现更新的成功缓存时,旧错误已不再代表当前数据。
lastFetchError = if (credentialChanged || cacheAdvanced) {
null
} else {
card.lastFetchError
}
)
}
)
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 localStates = credentialRepository.readLocalStates()
val cards = ServiceType.entries.map { type ->
buildCard(localStates.getValue(type), lastFetchError = null)
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 {
// Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过
if (!refreshMutex.tryLock()) return@launch
try {
_uiState.update { it.copy(refreshing = true) }
val results: Map<ServiceType, Result<ServiceBalance>?> = coroutineScope {
ServiceType.entries.map { type ->
val results: Map<AccountKey, Result<ServiceBalance>?> = coroutineScope {
val entries = orderAccountEntries(credentialRepository.readAccountStates())
entries.map { entry ->
async {
val status = credentialRepository.statusFor(type)
if (status.state == CredentialStatus.State.NOT_CONFIGURED) {
type to null // 未配置的服务不拉
} else {
type to refreshBalanceUseCase(type)
}
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()
}.awaitAll().associateBy({ it.first }, { it.second })
}
val localStates = credentialRepository.readLocalStates()
val cards = ServiceType.entries.map { type ->
val result = results[type]
val error = result?.exceptionOrNull()
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(localStates.getValue(type), lastFetchError = errorUi)
buildCard(entry, lastFetchError = errorUi)
}
_uiState.update { it.copy(refreshing = false, cards = cards) }
// 刷新成功后更新桌面小组件
if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) {
if (results[AccountKey(ServiceType.OPENCODE_GO, null)]?.isSuccess == true) {
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
}
} finally {
@ -145,15 +209,85 @@ class DashboardViewModel @Inject constructor(
}
}
/**
* 基于最新账号状态重建卡片列表,尽量保留旧顺序:
* - 同一服务内默认账号在前,其余按账号创建顺序;
* - 新增账号追加到同服务现有卡片之后;服务整体顺序沿用旧顺序,新服务按枚举追加。
*/
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.LocalState,
local: CredentialRepository.AccountLocalState,
lastFetchError: UiText?
): DashboardCardUi = DashboardCardUi(
service = local.status.service,
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
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(
@ -170,6 +304,140 @@ class DashboardViewModel @Inject constructor(
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(
@ -180,17 +448,40 @@ 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: UiText?
val lastFetchError: UiText?,
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
val siteIcon: String? = null,
/** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */
val checkedInToday: Boolean = false
) {
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
/** 卡片唯一键:`{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(

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

@ -1,6 +1,7 @@
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
@ -8,22 +9,28 @@ 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
@ -91,6 +98,12 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
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(
@ -125,6 +138,73 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
}
}
/**
* 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) {
@ -547,6 +627,61 @@ internal fun DashboardCardUi.statusBadgeStyle(): StatusStyle = when {
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) {
@ -555,6 +690,9 @@ internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service)
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
}
/**
@ -603,4 +741,154 @@ internal fun usageUpdatedAtText(updatedAt: Long): String {
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

@ -76,7 +76,7 @@ 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
@ -89,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)
@ -551,7 +551,8 @@ internal fun StackedBarChart(
Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall)
return
}
var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) }
// key = buckets:切换时间范围/筛选后重置 tooltip,避免残留旧数据
var tooltipBucket by remember(buckets) { mutableStateOf(buckets.lastOrNull()) }
val density = LocalDensity.current
val d = density.density
val barCount = buckets.size
@ -681,7 +682,8 @@ internal fun LineChart(
Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall)
return
}
var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) }
// key = buckets:切换时间范围/筛选后重置 tooltip,避免残留旧数据
var tooltipBucket by remember(buckets) { mutableStateOf(buckets.lastOrNull()) }
val density = LocalDensity.current
val d = density.density
val barCount = buckets.size

View File

@ -153,30 +153,35 @@ class UsageChartViewModel @Inject constructor(
// 降级后或用户手动选择 → 关闭降级,正常显示
allowFallback = false
// 图例数据源:只保留当前显示范围内实际出现过的模型
val rangeModels = result.flatMap { it.byModel.keys }.distinct().sorted()
_state.update {
it.copy(
buckets = result,
allModels = allModels,
rangeModels = rangeModels,
loading = false
)
}
}
}
fun setCustomDay(date: LocalDate) {
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY) }
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY, customDay = date) }
_customDay = date
load()
}
fun setCustomMonth(monthDate: LocalDate) {
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY) }
_customMonth = monthDate.withDayOfMonth(1)
val month = monthDate.withDayOfMonth(1)
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY, customMonth = month) }
_customMonth = month
load()
}
fun setCustomRange(fromDate: LocalDate, toDate: LocalDate) {
if (toDate.isBefore(fromDate)) return
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY) }
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY, customRange = fromDate to toDate) }
_customRange = fromDate to toDate
load()
}
@ -262,6 +267,12 @@ 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 useUtc8: Boolean = false,

View File

@ -59,6 +59,7 @@ 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
@ -82,10 +83,7 @@ fun UsageDataScreen(
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) }
@ -127,7 +125,7 @@ fun UsageDataScreen(
timeMenuExpanded = false; showCustomMonthPicker = true
})
DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_range)) }, onClick = {
timeMenuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L))
timeMenuExpanded = false; showRangePicker = true
})
}
}
@ -155,11 +153,8 @@ 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)) })
// 自定义时间范围:确认选择后显示 UTC 说明
if (state.timeFilter is TimeFilter.Custom) {
Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
@ -228,20 +223,43 @@ fun UsageDataScreen(
}
// 日期选择器
if (showCustomDayPicker) DateTimePickerDialog(stringResource(R.string.date_select_day), { 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(stringResource(R.string.date_select_month), { 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(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { 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 ->
@ -313,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

@ -53,6 +53,7 @@ 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
@ -96,9 +97,7 @@ fun UsageDetailScreen(
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 customRangeStartDate by remember { mutableStateOf<LocalDate?>(null) }
var showCustomRangePicker by remember { mutableStateOf(false) }
var showCostDetail by remember { mutableStateOf(false) }
var showReqDetail by remember { mutableStateOf(false) }
var showTokenDetail by remember { mutableStateOf(false) }
@ -110,7 +109,23 @@ fun UsageDetailScreen(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.title_usage_detail)) },
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) {
@ -153,7 +168,7 @@ fun UsageDetailScreen(
when (g) {
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangePicker = true
else -> viewModel.setGranularity(g)
}
})
@ -209,7 +224,7 @@ fun UsageDetailScreen(
if (wideEnough) {
// 面板够宽:前两张图表并排
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val models = state.rangeModels
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
val reqTotal = state.buckets.sumOf { it.totalRequests }
Row(
@ -256,7 +271,7 @@ fun UsageDetailScreen(
} else {
// 窄面板:三张图表纵向堆叠
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
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,
@ -304,34 +319,38 @@ fun UsageDetailScreen(
}
}
// 自定义日期选择器
if (showCustomDayPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_day), { date ->
viewModel.setCustomDay(date)
showCustomDayPicker = false
}, { showCustomDayPicker = false })
if (showCustomMonthPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_month), { date ->
viewModel.setCustomMonth(date)
showCustomMonthPicker = false
}, { showCustomMonthPicker = false })
if (showCustomRangeStart) DateOnlyPickerDialog(stringResource(R.string.date_pick_start), { date ->
customRangeStartDate = date
showCustomRangeStart = false
showCustomRangeEnd = true
}, { showCustomRangeStart = false })
if (showCustomRangeEnd) {
val startDate = customRangeStartDate
DateOnlyPickerDialog(
title = stringResource(R.string.date_pick_end),
onConfirm = { endDate ->
if (startDate != null && !endDate.isBefore(startDate)) {
viewModel.setCustomRange(startDate, endDate)
showCustomRangeEnd = 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 = { showCustomRangeEnd = false },
minDate = startDate
onDismiss = { showCustomRangePicker = false }
)
}
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
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)}") }
}
@ -448,8 +467,9 @@ internal fun CustomTimeRangeRow(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DateOnlyPickerDialog(
internal fun DateOnlyPickerDialog(
title: String,
initialDate: LocalDate? = null,
onConfirm: (LocalDate) -> Unit,
onDismiss: () -> Unit,
minDate: LocalDate? = null
@ -463,7 +483,10 @@ private fun DateOnlyPickerDialog(
}
}
}
val dateState = rememberDatePickerState(selectableDates = selectableDates)
val dateState = rememberDatePickerState(
initialSelectedDateMillis = initialDate?.atStartOfDay(utc)?.toInstant()?.toEpochMilli(),
selectableDates = selectableDates
)
DatePickerDialog(
onDismissRequest = onDismiss,
@ -495,7 +518,7 @@ private fun DateOnlyPickerDialog(
color = InkMuted
)
}
DatePicker(state = dateState)
DatePicker(state = dateState, title = {})
}
}
}

View File

@ -52,6 +52,9 @@ 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
@ -74,10 +77,7 @@ fun UsageOverviewScreen(
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,
@ -115,16 +115,13 @@ fun UsageOverviewScreen(
}
DropdownMenuItem(
text = { Text(stringResource(R.string.time_custom)) },
onClick = { menuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L)) }
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(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
@ -179,8 +176,21 @@ fun UsageOverviewScreen(
}
item { Spacer(Modifier.height(24.dp)) }
}
if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { 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 }
)
}
}
}
@ -281,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

@ -289,6 +289,8 @@ private data class LoadResult(
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 ->

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

@ -66,24 +66,24 @@ import kotlinx.coroutines.flow.first
// ── 热力图颜色 ──────────────────────────────────────────────
/** 亮色模式 6 级粉色梯度 */
/** 亮色模式 6 级玫瑰粉梯度(精致粉调,最高级与主色一致) */
private val LightHeatmapColors = listOf(
Color(0xFFEFE0E5), // Level 0: 极浅暖灰
Color(0xFFFFD6E0), // Level 1: 浅樱粉
Color(0xFFFFB3C6), // Level 2: 草莓粉柔
Color(0xFFFF85A2), // Level 3: 草莓粉
Color(0xFFFF6B8E), // Level 4: 草莓粉深
Color(0xFFE84973), // Level 5: 玫红
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 级粉色梯度 */
/** 暗色模式 6 级玫瑰粉梯度 */
private val DarkHeatmapColors = listOf(
Color(0xFF3A2A30), // Level 0: 暖深灰
Color(0xFF4A2E3A), // Level 1
Color(0xFF6B3D4A), // Level 2
Color(0xFF8B4D5A), // Level 3
Color(0xFFB86880), // Level 4
Color(0xFFD67F9A), // Level 5
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) */

View File

@ -17,6 +17,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.horizontalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.AlertDialog
@ -29,6 +30,8 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -54,6 +57,7 @@ import androidx.compose.ui.draw.clip
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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
@ -62,6 +66,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.repository.ModelUsageRow
import com.rainy.token.data.repository.OpenCodeGoRepository
import com.rainy.token.data.repository.WorkBuddyRepository
import com.rainy.token.data.repository.WindowModelUsage
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
@ -80,6 +85,11 @@ import com.rainy.token.ui.components.isFiveHourLabel
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@ -100,12 +110,15 @@ import java.util.Locale
@Composable
fun ServiceDetailScreen(
service: ServiceType,
accountId: String? = null,
onBack: () -> Unit,
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
onConfigureCredential: (ServiceType) -> Unit,
onStartWebViewLogin: (ServiceType) -> Unit,
onStartWebViewLogin: (ServiceType, String?) -> Unit,
onOpenRecharge: (ServiceType) -> Unit = { },
viewModel: ServiceDetailViewModel = hiltViewModel()
) {
LaunchedEffect(service) { viewModel.bind(service) }
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
// 从凭据编辑页返回时重新读取凭据状态 + 缓存
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.reloadCredentialState()
@ -115,6 +128,28 @@ fun ServiceDetailScreen(
val models by viewModel.models.collectAsStateWithLifecycle()
val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle()
val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle()
val checkinState by viewModel.checkinState.collectAsStateWithLifecycle()
val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
// 解析当前账号(显式 accountId 优先;未指定则默认账号)
val currentAccount: com.rainy.token.domain.model.ServiceAccountInfo? =
accounts.firstOrNull { it.id == accountId }
?: if (accountId.isNullOrBlank()) {
accounts.firstOrNull { it.isDefault } ?: accounts.firstOrNull()
} else {
null
}
val accountLabel: String? = currentAccount?.let { account ->
account.label?.takeIf { it.isNotBlank() }
?: stringResource(R.string.account_fallback_name, accounts.indexOf(account) + 1)
}
// 面板管理(Key/分组/兑换)已移到首页 Sub2API 卡片的「面板管理」入口,
// 详情页只保留用量明细所需的窗口统计状态。
val panelWindowStats by viewModel.panelWindowStats.collectAsStateWithLifecycle()
val panelKeyStats by viewModel.panelKeyStats.collectAsStateWithLifecycle()
val hourlyTrend by viewModel.hourlyTrend.collectAsStateWithLifecycle()
val config = ServiceConfigProvider.get(service)
val isManualMode = config.method == FetchMethod.MANUAL
@ -135,7 +170,11 @@ fun ServiceDetailScreen(
ServiceIcon(service = service, size = 32)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = service.displayName,
text = if (accountLabel != null) {
stringResource(R.string.service_detail_title_with_account, service.displayName, accountLabel)
} else {
service.displayName
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
@ -163,6 +202,18 @@ fun ServiceDetailScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 多账号服务:账号切换器(仅展示单个账号时不显示,保持旧版视觉)
if (ServiceType.isMultiAccount(service) && accounts.size > 1) {
item {
AccountSwitcherRow(
accounts = accounts,
selectedId = currentAccount?.id,
fallbackName = { index -> stringResource(R.string.account_fallback_name, index + 1) },
onSelected = { id -> onSwitchAccount(service, id) }
)
}
}
// 主余额卡
item { MainBalanceCard(state = uiState.state, service = service) }
@ -183,6 +234,59 @@ fun ServiceDetailScreen(
ServiceType.OLLAMA -> {
item { OllamaUsageCard(uiState.state) }
}
ServiceType.TRAE -> {
// 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮
item {
if (uiState.hasCredential) {
CheckinCard(
state = checkinState,
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
streakDays = checkinInfo?.streakDays ?: 0L,
isTrae = true,
onCheckin = { viewModel.checkin() }
)
} else {
CheckinNeedCredentialCard()
}
}
// 权益包积分明细(拆开显示每包总额/已用/剩余);无 entitlement.* 数据时该卡不渲染
item { TraePacksCard(uiState.state) }
// 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键)
item { TraeAccountInfoCard(uiState.state) }
}
ServiceType.WORKBUDDY -> {
item {
if (uiState.hasCredential) {
CheckinCard(
state = checkinState,
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
streakDays = checkinInfo?.streakDays ?: 0L,
isTrae = false,
onCheckin = { viewModel.checkin() }
)
} else {
CheckinNeedCredentialCard()
}
}
// 资源明细(资源 ID 美化为「资源 #007」+ 余额;套餐/冻结/付费信息归账户信息)
item { WorkBuddyResourcesCard(uiState.state) }
}
ServiceType.SUB2API -> {
// 新明细卡:模型筛选 + 时间窗口 + 柱状图;无结构化明细时内部回退 GenericExtrasCard
item {
Sub2ApiUsageCard(
state = uiState.state,
windowStatsState = panelWindowStats,
onLoadWindowModelStats = { from, to -> viewModel.loadPanelWindowModelStats(from, to) },
hourlyTrend = hourlyTrend,
onLoadHourlyTrend = { date -> viewModel.loadHourlyTrend(date) },
keyStatsState = panelKeyStats,
onLoadKeyStats = { from, to -> viewModel.loadPanelKeyStats(from, to) }
)
}
// 面板管理(Key 列表/新建/分组/兑换)不再在详情页展示:
// 入口统一放在首页 Sub2API 卡片的「面板管理」按钮,避免同页重复且详情页过长。
}
}
// 错误信息(如有)
@ -211,7 +315,8 @@ fun ServiceDetailScreen(
modelsLoading = modelsLoading,
onRefresh = { viewModel.refresh() },
onConfigureCredential = { onConfigureCredential(service) },
onStartWebViewLogin = { onStartWebViewLogin(service) },
onStartWebViewLogin = { onStartWebViewLogin(service, accountId) },
onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } },
onTriggerUsage = { viewModel.triggerUsage() },
onSelectModel = { viewModel.selectModel(it) },
onRefreshModels = { viewModel.loadModels(force = true) }
@ -244,7 +349,47 @@ fun ServiceDetailScreen(
}
/**
* CommandCode Go 专属:月度用量卡 + 窗口进度条。
* 多账号服务的账号切换器:横向可滚动的 FilterChip 行,默认账号带「默认」角标。
*/
@Composable
private fun AccountSwitcherRow(
accounts: List<com.rainy.token.domain.model.ServiceAccountInfo>,
selectedId: String?,
fallbackName: @Composable (Int) -> String,
onSelected: (String) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
accounts.forEachIndexed { index, account ->
val label = account.label?.takeIf { it.isNotBlank() } ?: fallbackName(index)
val selected = account.id == selectedId
FilterChip(
selected = selected,
onClick = { if (!selected) onSelected(account.id) },
label = {
Text(
text = if (account.isDefault) {
stringResource(R.string.account_chip_default, label)
} else {
label
},
maxLines = 1
)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = StrawberryPink.copy(alpha = 0.14f)
)
)
}
}
}
/**
* CommandCode 专属:月度用量卡 + 窗口进度条。
*
* 数据从 balance.extras 中的 monthlyRemaining / monthlyTotal / fiveHour / weekly 取,
* 展示月度已用/总量 + 5h + 每周窗口进度条。
@ -915,9 +1060,9 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
contentAlignment = Alignment.Center
) { CircularProgressIndicator(color = StrawberryPink) }
}
is State.Fresh -> BalanceBigNumber(state.data)
is State.Fresh -> BalanceBigNumber(state.data, service)
is State.Stale -> {
BalanceBigNumber(state.data)
BalanceBigNumber(state.data, service)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.msg_stale_cache, formatTime(state.lastFetchedAt)),
@ -926,7 +1071,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
)
}
is State.Error -> {
BalanceBigNumber(state.cached)
BalanceBigNumber(state.cached, service)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.msg_showing_last_balance),
@ -947,7 +1092,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
}
@Composable
private fun BalanceBigNumber(balance: ServiceBalance?) {
private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
if (balance == null) {
Text(
text = "—",
@ -987,6 +1132,34 @@ private fun BalanceBigNumber(balance: ServiceBalance?) {
color = inkMuted()
)
}
// WorkBuddy:账户信息(冻结 / 付费 / 订阅)并入主余额卡,与「当前余额」同区展示
if (service == ServiceType.WORKBUDDY) {
val extras = balance.extras
val accountRows = buildList {
extras["frozen"]?.takeIf { it != "0" }?.let {
add(stringResource(R.string.extra_label_frozen) to "$it ${balance.unit}")
}
extras["is_paid_user"]?.let {
add(
stringResource(R.string.extra_label_is_paid_user) to
stringResource(if (it == "true") R.string.extra_value_yes else R.string.extra_value_no)
)
}
if (extras["subscription"] == "true") {
add(stringResource(R.string.extra_label_subscription) to stringResource(R.string.extra_value_yes))
}
}
if (accountRows.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.detail_account_info_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
accountRows.forEach { (label, value) -> InfoRow(label = label, value = value) }
}
}
}
/**
@ -1056,6 +1229,570 @@ private fun BreakdownRow(label: String, value: Double, unit: String) {
}
}
/**
* 每日签到卡(WorkBuddy / Trae 共用)。
* WorkBuddy:codebuddy.cn Buddy 加油站(每日积分 + 连签奖励);
* Trae:api.trae.cn checkin_credits(每日积分)。
* 结果消息直接采用官方响应语义(签到成功/今日已签到/设备拦截/失败原因)。
*/
@Composable
private fun CheckinCard(
state: CheckinState,
todayCheckedIn: Boolean,
streakDays: Long,
isTrae: Boolean = false,
onCheckin: () -> Unit
) {
val actionDone = todayCheckedIn || state is CheckinState.Done
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.checkin_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.checkin_subtitle),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
Spacer(modifier = Modifier.height(12.dp))
when (state) {
is CheckinState.Done -> Text(
text = state.message.asString(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
is CheckinState.Error -> Text(
text = state.message.asString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
// 提示态(设备级拦截等):中性色,且不置灰按钮
is CheckinState.Notice -> Text(
text = state.message.asString(),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
// 尚未点过按钮但预取到「今日已签」:给出连签提示
else -> if (todayCheckedIn) {
Text(
text = if (streakDays > 0) {
stringResource(R.string.checkin_already_streak, streakDays)
} else {
stringResource(R.string.checkin_already)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
// Trae 签到失败/提示时附设备 ID 获取路径(签到须用官方客户端绑定设备,
// 9074 风控根因;用户换设备后可按此路径自查新 did)。INTL 不支持与设备无关,不提示。
val hintRelevant = state is CheckinState.Notice ||
(state is CheckinState.Error && state.showDeviceHint)
if (isTrae && hintRelevant) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.checkin_trae_device_hint),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onCheckin,
enabled = state !is CheckinState.Loading && !actionDone,
modifier = Modifier.fillMaxWidth().height(44.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = stringResource(
when {
state is CheckinState.Loading -> R.string.checkin_loading
actionDone -> R.string.checkin_action_done
else -> R.string.checkin_action
}
)
)
}
}
}
}
/** 未配置凭据时的签到占位卡:引导去配置,而不是直接暴露必然失败的签到按钮。 */
@Composable
private fun CheckinNeedCredentialCard() {
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.checkin_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.checkin_need_credential),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
}
/**
* Trae 权益包积分明细卡:把 balance.extras 中固定前缀键 entitlement.$i.* 拆开逐包展示,
* 并按 available_endpoint 分成「通用积分」(TraeCode/TraeWork 均可用)与
* 「Work 专属积分」(仅 TraeWork)两个分区,各带小计,与官方用量页口径一致。
* 只在存在 entitlement.* 数据时渲染;其余键仍由 [GenericExtrasCard] 展示。
*/
@Composable
internal fun TraePacksCard(state: State) {
val balance = when (state) {
is State.Fresh -> state.data
is State.Stale -> state.data
is State.Error -> state.cached
else -> null
}
val extras = balance?.extras ?: return
val packs = buildList<PackLine> {
var i = 0
while (true) {
val name = extras["entitlement.$i.name"] ?: break
val unlimited = extras["entitlement.$i.unlimited"] == "true"
val limit = extras["entitlement.$i.limit"]?.toLongOrNull()
val used = extras["entitlement.$i.used"]?.toDoubleOrNull()
val remaining = extras["entitlement.$i.remaining"]?.toDoubleOrNull()
val isWork = extras["entitlement.$i.endpoint"] == "1"
add(PackLine(name, limit, used, remaining, unlimited, isWork))
i++
}
}
if (packs.isEmpty()) return
val generalTotal = extras["general_credits"]?.toDoubleOrNull()
val workTotal = extras["work_credits"]?.toDoubleOrNull()
val generalPacks = packs.filter { !it.isWork }
val workPacks = packs.filter { it.isWork }
val unlimitedLabel = stringResource(R.string.trae_pack_unlimited)
val remainingFmt = stringResource(R.string.trae_pack_remaining_fmt)
val generalTitle = stringResource(R.string.trae_general_credits_title)
val workTitle = stringResource(R.string.trae_work_credits_title)
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),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
Text(
text = stringResource(R.string.trae_packs_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
// 通用积分区(available_endpoint=0:TraeCode/TraeWork 均可用)
if (generalTotal != null || generalPacks.isNotEmpty()) {
TraeSectionRow(generalTitle, generalTotal)
generalPacks.forEach { p ->
Text(
text = traePackLine(p, unlimitedLabel, remainingFmt),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
// Work 专属积分区(available_endpoint=1:仅 TraeWork 可用)
if (workTotal != null || workPacks.isNotEmpty()) {
TraeSectionRow(workTitle, workTotal)
workPacks.forEach { p ->
Text(
text = traePackLine(p, unlimitedLabel, remainingFmt),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
}
}
}
/** 分区标题行:左侧分区名,右侧小计(通用/Work 专属各一组)。 */
@Composable
private fun TraeSectionRow(title: String, total: Double?) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium
)
if (total != null) {
Text(
text = formatAmount(total),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium
)
}
}
}
/** 单个权益包的展示行文案(剩余/总额/已用,或无限额度)。 */
private fun traePackLine(p: PackLine, unlimitedLabel: String, remainingFmt: String): String =
if (p.unlimited) {
"${p.name}: $unlimitedLabel"
} else {
val limitStr = p.limit?.toString() ?: "?"
val usedStr = p.used?.let { formatAmount(it) } ?: "?"
val remStr = p.remaining?.let { formatAmount(it) } ?: "?"
"${p.name}: " + String.format(Locale.US, remainingFmt, remStr, limitStr, usedStr)
}
/** Trae 单个权益包展示行。 */
private data class PackLine(
val name: String,
val limit: Long?,
val used: Double?,
val remaining: Double?,
val unlimited: Boolean,
val isWork: Boolean
)
/**
* Trae 账户信息卡:只展示对用户有意义的账户级指标(总积分/已消耗/消耗比例/权益包数),
* 原始布尔键(is_credits_billing / is_dollar_usage_billing)合并为一行「计费方式」,
* is_new_user 仅在为新用户时展示。数据缺失的行自动隐藏;无任何数据时整卡不渲染。
*/
@Composable
internal fun TraeAccountInfoCard(state: State) {
val balance = when (state) {
is State.Fresh -> state.data
is State.Stale -> state.data
is State.Error -> state.cached
else -> null
}
val extras = balance?.extras ?: return
val unit = balance.unit
val rows = buildList {
extras["total_credits"]?.let {
add(stringResource(R.string.extra_label_total_credits) to "$it $unit")
}
extras["consumed_credits"]?.let {
add(stringResource(R.string.extra_label_consumed_credits) to "$it $unit")
}
extras["consumption_ratio"]?.toDoubleOrNull()?.let {
add(stringResource(R.string.extra_label_consumption_ratio) to "%.1f%%".format(it * 100))
}
extras["pack_count"]?.toIntOrNull()?.takeIf { it > 0 }?.let {
add(stringResource(R.string.extra_label_pack_count) to it.toString())
}
// 计费方式:两个英文布尔键合并为一行自然语言
val billingLabel = 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
}
billingLabel?.let {
add(stringResource(R.string.extra_label_billing_mode) to it)
}
if (extras["is_new_user"] == "true") {
add(stringResource(R.string.extra_label_is_new_user) to stringResource(R.string.extra_value_yes))
}
}
if (rows.isEmpty()) return
AccountInfoCard(title = stringResource(R.string.detail_account_info_title), rows = rows)
}
/**
* WorkBuddy 资源卡:按官网个人中心语义分区展示结构化包明细(extras["wb_detail_raw"])。
*
* - 「套餐积分」区:订阅套餐包,展示 已用/总量 + 剩余 + 下次权益周期更新时间;
* - 「平台奖励积分」区:奖励类包,展示 可用数量 · 剩余合计,各行带到期时间;
* - 「账户信息」区:套餐数/冻结/付费用户/订阅状态。
*
* 旧缓存无 wb_detail_raw 时回退为「资源 #007」简化行。无任何数据时整卡不渲染。
*/
@Composable
internal fun WorkBuddyResourcesCard(state: State) {
val balance = when (state) {
is State.Fresh -> state.data
is State.Stale -> state.data
is State.Error -> state.cached
else -> null
}
val extras = balance?.extras ?: return
val unit = balance.unit
val packs = remember(extras) { parseWorkBuddyPacks(extras[WorkBuddyRepository.KEY_DETAIL_RAW]) }
// 套餐包编码:优先用仓库算好的 KEY_PLAN_CODE;旧数据回退到 summary 的 subscription_code
val planCode = extras[WorkBuddyRepository.KEY_PLAN_CODE]
?: extras["subscription_code"].orEmpty()
// 分区:套餐积分 vs 平台奖励积分(依据 PackageCode 分组,与官网个人中心口径一致)
// 账户信息(frozen/付费/订阅)已并入主余额卡 MainBalanceCard
if (packs.isEmpty()) return
val planPacks = if (planCode.isNotBlank()) packs.filter { it.code == planCode } else emptyList()
val rewardPacks = packs - planPacks.toSet()
val planTotal = planPacks.sumOf { it.remain }
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),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
// ── 套餐积分:已用/总量 + 剩余 + 周期结束时间 ──
if (planPacks.isNotEmpty()) {
Text(
text = stringResource(R.string.wb_plan_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
InfoRow(
label = stringResource(R.string.wb_plan_used_total),
value = "${formatAmount(planPacks.sumOf { it.used })} / ${formatAmount(planPacks.sumOf { it.total })} $unit"
)
InfoRow(
label = stringResource(R.string.extra_label_remaining),
value = "${formatAmount(planTotal)} $unit"
)
planPacks.firstNotNullOfOrNull { it.cycleEnd }?.let {
InfoRow(label = stringResource(R.string.wb_plan_next_reset), value = it)
}
Spacer(modifier = Modifier.height(6.dp))
}
// ── 平台奖励积分:逐行列出到期时间 + 剩余 ──
if (rewardPacks.isNotEmpty()) {
Text(
text = stringResource(R.string.wb_reward_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
rewardPacks
.sortedWith(compareBy({ it.expireAt == null }, { it.expireAt ?: "" }))
.forEachIndexed { index, p ->
val label = p.expireAt?.let {
stringResource(R.string.wb_reward_expire, it)
} ?: stringResource(R.string.wb_reward_item, index + 1)
InfoRow(label = label, value = "${formatAmount(p.remain)} $unit")
}
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
/** WorkBuddy 单个资源包(结构化明细行)。 */
private data class WbPack(
val code: String,
val name: String,
val remain: Double,
val used: Double,
val total: Double,
/** 周期结束时间(CycleEndTime,形如 "2026-09-30 23:59:59") */
val cycleEnd: String?,
/** 到期时间(ExpiredTime;null=未到期) */
val expireAt: String?,
/** 0=生效中,3=已过期(服务端枚举,其余值按未知处理) */
val status: Int
) {
val cycleReset: String? get() = cycleEnd
}
/** 解析 extras[KEY_DETAIL_RAW](WorkBuddyRepository 序列化的账户明细 JSON 数组)。 */
private fun parseWorkBuddyPacks(raw: String?): List<WbPack> {
if (raw.isNullOrBlank()) return emptyList()
return runCatching {
Json.parseToJsonElement(raw).jsonArray.mapNotNull { el ->
val o = el as? JsonObject ?: return@mapNotNull null
WbPack(
code = o["code"]?.jsonPrimitive?.contentOrNull.orEmpty(),
name = o["name"]?.jsonPrimitive?.contentOrNull.orEmpty(),
remain = o["remain"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null,
used = o["used"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
total = o["total"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
cycleEnd = o["cycleEnd"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() },
expireAt = o["expiredAt"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() },
status = o["status"]?.jsonPrimitive?.doubleOrNull?.toInt() ?: -1
)
}
}.getOrDefault(emptyList())
}
/** 通用「账户信息」卡:标题 + 左灰标签/右强调值 行列表。 */
@Composable
private fun AccountInfoCard(title: String, rows: List<Pair<String, String>>) {
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),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
rows.forEach { (label, value) -> InfoRow(label = label, value = value) }
}
}
}
/** 明细行:左标签(灰)+ 右值(强调,右对齐),避免「key: value」裸奔样式。 */
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.weight(1f)
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.End
)
}
}
/**
* 通用明细卡:把 balance.extras 中的友好键逐行展示(Trae 权益包 / WorkBuddy 资源 /
* Sub2API 账户等)。技术键(models / reset* / raw 等)一律过滤。
*
* 已知技术键(consumed_credits / pack_count / is_paid_user 等)映射为本地化标签;
* 未知键(权益包名等,由服务端按用户 locale 返回)原样展示。
*/
@Composable
internal fun GenericExtrasCard(state: State) {
val balance = when (state) {
is State.Fresh -> state.data
is State.Stale -> state.data
is State.Error -> state.cached
else -> null
}
val extras = balance?.extras ?: return
val friendly = extras.filterKeys { key ->
val k = key.lowercase()
// entitlement.* 键由 TraePacksCard 专门拆开渲染,不得进入通用卡
!k.startsWith("entitlement.") &&
// 通用/Work 专属小计由 TraePacksCard 分区标题展示,避免重复
k != "general_credits" && k != "work_credits" &&
!k.contains("models") &&
!k.contains("reset") &&
!k.contains("raw") &&
!k.endsWith(".unlimited") &&
!k.endsWith(".used") &&
!k.endsWith(".limit")
}
if (friendly.isEmpty()) return
val yesLabel = stringResource(R.string.extra_value_yes)
val noLabel = stringResource(R.string.extra_value_no)
val labelMap = mapOf(
"total_credits" to stringResource(R.string.extra_label_total_credits),
"consumed_credits" to stringResource(R.string.extra_label_consumed_credits),
"consumption_ratio" to stringResource(R.string.extra_label_consumption_ratio),
"pack_count" to stringResource(R.string.extra_label_pack_count),
"is_credits_billing" to stringResource(R.string.extra_label_is_credits_billing),
"is_dollar_usage_billing" to stringResource(R.string.extra_label_is_dollar_usage_billing),
"is_new_user" to stringResource(R.string.extra_label_is_new_user),
"packages" to stringResource(R.string.extra_label_packages),
"frozen" to stringResource(R.string.extra_label_frozen),
"is_paid_user" to stringResource(R.string.extra_label_is_paid_user),
"subscription" to stringResource(R.string.extra_label_subscription)
)
val knownOrder = listOf(
"total_credits", "consumed_credits", "consumption_ratio", "pack_count",
"is_credits_billing", "is_dollar_usage_billing", "is_new_user",
"packages", "frozen", "is_paid_user", "subscription"
)
val sorted = friendly.entries.sortedWith { a, b ->
val ai = knownOrder.indexOf(a.key)
val bi = knownOrder.indexOf(b.key)
when {
ai >= 0 && bi < 0 -> -1
ai < 0 && bi >= 0 -> 1
ai >= 0 && bi >= 0 -> ai.compareTo(bi)
else -> a.key.compareTo(b.key)
}
}
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),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
Text(
text = stringResource(R.string.detail_extras_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
sorted.forEach { (key, value) ->
val label = labelMap[key] ?: key
val formatted = formatExtraValue(key, value, yesLabel, noLabel)
Text(
text = "$label: $formatted",
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
}
}
/** 格式化 extras 值:布尔 → 是/否,比例 → 百分比,其余原样。 */
private fun formatExtraValue(
key: String,
value: String,
yes: String,
no: String
): String = when (key) {
"is_credits_billing", "is_dollar_usage_billing", "is_new_user",
"is_paid_user", "subscription" ->
if (value.equals("true", ignoreCase = true)) yes else no
"consumption_ratio" ->
value.toDoubleOrNull()?.let { "%.1f%%".format(it * 100) } ?: value
else -> value
}
@Composable
private fun ErrorCard(message: String) {
Card(
@ -1106,6 +1843,7 @@ private fun ActionButtons(
onRefresh: () -> Unit,
onConfigureCredential: () -> Unit,
onStartWebViewLogin: () -> Unit,
onOpenRecharge: (() -> Unit)? = null,
onTriggerUsage: () -> Unit,
onSelectModel: (String) -> Unit,
onRefreshModels: () -> Unit
@ -1250,6 +1988,16 @@ private fun ActionButtons(
Text(stringResource(R.string.action_configure_credential))
}
}
// 外部操作入口(如 DeepSeek 充值页),App 内 WebView 打开
if (onOpenRecharge != null) {
OutlinedButton(
onClick = onOpenRecharge,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_recharge))
}
}
}
}
@ -1260,6 +2008,9 @@ private fun mainCardLabel(service: ServiceType): String = when (service) {
ServiceType.COMMANDCODE_GO -> stringResource(R.string.main_card_monthly_balance)
ServiceType.CODEX -> stringResource(R.string.window_usage)
ServiceType.OLLAMA -> stringResource(R.string.main_card_session_usage)
ServiceType.TRAE,
ServiceType.WORKBUDDY,
ServiceType.SUB2API -> stringResource(R.string.main_card_current_balance)
}
private fun stateToChip(state: State): StatusStyle = when (state) {
@ -1412,4 +2163,4 @@ private fun ResponseDialog(
}
}
)
}
}

View File

@ -7,17 +7,21 @@ import com.rainy.token.data.cache.CachedBalance
import com.rainy.token.data.debug.DebugLog
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.data.repository.TriggerError
import com.rainy.token.data.repository.TriggerErrorReason
import com.rainy.token.domain.model.Credential
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.TriggerSummary
import com.rainy.token.R
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import com.rainy.token.ui.components.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.LocalDate
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -30,10 +34,21 @@ import kotlinx.coroutines.launch
class ServiceDetailViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache,
private val refreshBalanceUseCase: RefreshBalanceUseCase
private val refreshBalanceUseCase: RefreshBalanceUseCase,
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
) : ViewModel() {
private val _serviceType = MutableStateFlow<ServiceType?>(null)
val serviceType: StateFlow<ServiceType?> = _serviceType.asStateFlow()
/** 当前选中的账号 ID(多账号服务;null=默认账号,单账号服务恒为 null)。 */
private val _accountId = MutableStateFlow<String?>(null)
/** 多账号服务的账号列表(用于详情页账号切换)。 */
private val _accounts = MutableStateFlow<List<com.rainy.token.domain.model.ServiceAccountInfo>>(emptyList())
val accounts: StateFlow<List<com.rainy.token.domain.model.ServiceAccountInfo>> = _accounts.asStateFlow()
private val _uiState = MutableStateFlow(ServiceDetailUiState())
val uiState: StateFlow<ServiceDetailUiState> = _uiState.asStateFlow()
@ -49,6 +64,298 @@ class ServiceDetailViewModel @Inject constructor(
private val _modelsLoading = MutableStateFlow(false)
val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
/** 每日签到状态(WorkBuddy / Trae 详情页用)。 */
private val _checkinState = MutableStateFlow<CheckinState>(CheckinState.Idle)
val checkinState: StateFlow<CheckinState> = _checkinState.asStateFlow()
/**
* 进入详情页时预取的「今日签到信息」(WorkBuddy)。
* null=未知/未加载;用于展示「今日已签到(连签 N 天)」并禁用按钮,避免重复点击。
*/
private val _checkinInfo = MutableStateFlow<CheckinInfo?>(null)
val checkinInfo: StateFlow<CheckinInfo?> = _checkinInfo.asStateFlow()
/** 今日签到信息摘要(仅字段已确认的才填)。 */
data class CheckinInfo(val todayCheckedIn: Boolean, val streakDays: Long)
/** 进入详情页时预取「今日签到信息」:WORKBUDDY 走状态接口;TRAE 本地记录 + status 接口。 */
private fun preloadCheckinInfo(service: ServiceType, accountId: String?) {
_checkinInfo.value = null
val generation = serviceGeneration
when (service) {
ServiceType.WORKBUDDY -> viewModelScope.launch {
// 走 RefreshBalanceUseCase 会话:状态查询也会触发 OAuth 轮换,需按快照校验提交
val status = refreshBalanceUseCase.fetchCheckinStatus(accountId).getOrNull() ?: return@launch
if (generation != serviceGeneration || _serviceType.value != service) return@launch
_checkinInfo.value = CheckinInfo(
todayCheckedIn = status.todayCheckedIn,
streakDays = status.streakDays
)
}
ServiceType.TRAE -> {
// 先看统一签到记录(零网络秒回),未命中再查服务端 status(跨设备已签也能识别)
if (checkinStatusStore.isCheckedToday(service, accountId)) {
_checkinInfo.value = CheckinInfo(todayCheckedIn = true, streakDays = 0)
return
}
viewModelScope.launch {
val checked = refreshBalanceUseCase.fetchTraeCheckinStatus(accountId).getOrNull()
?: return@launch
if (generation != serviceGeneration || _serviceType.value != service) return@launch
if (checked) {
checkinStatusStore.markChecked(service, accountId)
_checkinInfo.value = CheckinInfo(todayCheckedIn = true, streakDays = 0)
}
}
}
else -> Unit
}
}
/** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果按语义本地化(不直接显示 Repository 中文串)。 */
fun checkin() {
val service = _serviceType.value ?: return
val accountId = _accountId.value
if (service != ServiceType.WORKBUDDY && service != ServiceType.TRAE) return
if (_checkinState.value is CheckinState.Loading) return
_checkinState.value = CheckinState.Loading
val generation = serviceGeneration
viewModelScope.launch {
val result = when (service) {
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(accountId).fold(
onSuccess = { s ->
when (s.outcome) {
WorkBuddyRepository.CheckinOutcome.ALREADY ->
// 已签时带上连签天数(接口已回传,别浪费)
CheckinState.Done(
if (s.streakDays > 0) {
UiText.Resource(R.string.checkin_already_streak, listOf(s.streakDays))
} else {
UiText.Resource(R.string.checkin_already)
}
)
WorkBuddyRepository.CheckinOutcome.SUCCESS -> {
val credits = s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit
CheckinState.Done(
if (credits > 0) {
UiText.Resource(R.string.checkin_wb_success_credits, listOf(credits))
} else {
UiText.Resource(R.string.checkin_success)
}
)
}
WorkBuddyRepository.CheckinOutcome.FAILED ->
// 失败原因优先透传服务端原文(多为官方中文提示),缺省用通用失败文案
CheckinState.Error(
if (s.message.isNotBlank()) UiText.Dynamic(s.message)
else UiText.Resource(R.string.checkin_failed)
)
// QUERY 不该出现在签到动作结果里,兜底为通用成功
WorkBuddyRepository.CheckinOutcome.QUERY ->
CheckinState.Done(UiText.Resource(R.string.checkin_success))
}
},
// RepositoryError.message 是硬编码中文,必须走 errorMessage() 本地化映射
onFailure = { e -> CheckinState.Error(errorMessage(e)) }
)
ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae(accountId).fold(
onSuccess = { r ->
when (r.outcome) {
TraeRepository.CheckinOutcome.SUCCESS ->
CheckinState.Done(UiText.Resource(R.string.checkin_success))
TraeRepository.CheckinOutcome.ALREADY ->
CheckinState.Done(UiText.Resource(R.string.checkin_already))
// 设备级拦截不是「账号已签」:用 Notice 展示,按钮保持可点(用户可换网/稍后重试)
TraeRepository.CheckinOutcome.DEVICE_BLOCKED ->
CheckinState.Notice(UiText.Resource(R.string.checkin_trae_device_blocked))
// 服务端临时限流(参与用户太多等):提示而非错误,稍后可重试
TraeRepository.CheckinOutcome.RETRYABLE ->
CheckinState.Notice(
if (r.message.isNotBlank()) UiText.Dynamic(r.message)
else UiText.Resource(R.string.checkin_trae_retry_later)
)
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED ->
CheckinState.Error(
UiText.Resource(R.string.checkin_trae_intl),
showDeviceHint = false
)
TraeRepository.CheckinOutcome.FAILED -> CheckinState.Error(
// 服务端原始原因优先;缺省用带 code 的失败文案
if (r.message.isNotBlank()) {
UiText.Dynamic(r.message)
} else if (r.code >= 0) {
UiText.Resource(R.string.checkin_trae_failed_code, listOf(r.code))
} else {
UiText.Resource(R.string.checkin_failed)
}
)
}
},
onFailure = { e -> CheckinState.Error(errorMessage(e)) }
)
else -> CheckinState.Error(UiText.Resource(R.string.checkin_unsupported))
}
if (generation == serviceGeneration) {
_checkinState.value = result
// 签到成功后刷新「今日已签到/连签」摘要,让按钮态即时更新
if (result is CheckinState.Done) preloadCheckinInfo(service, accountId)
}
}
}
// ---------- Sub2API 面板管理(Key 列表/新建/分组/兑换/低余额) ----------
/** Key 列表状态。 */
private val _panelKeysState = MutableStateFlow<PanelKeysState>(PanelKeysState.Idle)
val panelKeysState: StateFlow<PanelKeysState> = _panelKeysState.asStateFlow()
/** 管理操作的一次性结果消息(新建/切组/兑换/复制)。 */
private val _panelActionMessage = MutableStateFlow<String?>(null)
val panelActionMessage: StateFlow<String?> = _panelActionMessage.asStateFlow()
fun consumePanelActionMessage() { _panelActionMessage.value = null }
/** 可用分组(新建 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()
/** 按所选时间窗口拉取的分模型统计(「全部」窗口直接用明细里的全量 model_stats)。 */
private val _panelWindowStats = MutableStateFlow<PanelWindowStatsState>(PanelWindowStatsState.Idle)
val panelWindowStats: StateFlow<PanelWindowStatsState> = _panelWindowStats.asStateFlow()
/** 窗口统计请求代次:快速切换窗口时只接受最后一次请求的结果,避免旧窗口数据覆盖新窗口。 */
private var panelWindowStatsGeneration = 0
fun loadPanelWindowModelStats(from: LocalDate, to: LocalDate) {
val generation = ++panelWindowStatsGeneration
_panelWindowStats.value = PanelWindowStatsState.Loading
viewModelScope.launch {
val result = sub2ApiRepository.fetchWindowModelStats(from, to, _accountId.value)
// 过时结果(用户已切换到别的窗口)直接丢弃,不写状态
if (generation != panelWindowStatsGeneration) return@launch
result.fold(
onSuccess = { r -> _panelWindowStats.value = PanelWindowStatsState.Success(r.stats, r.allTime) },
onFailure = { e ->
_panelWindowStats.value = PanelWindowStatsState.Error(e.message ?: "加载模型统计失败")
}
)
}
}
fun loadPanelGroups() {
viewModelScope.launch {
if (_panelGroups.value.isNotEmpty()) return@launch
sub2ApiRepository.fetchPanelGroups(_accountId.value).onSuccess { _panelGroups.value = it }
}
}
/** 「今天」按小时趋势(面板 snapshot-v2?granularity=hour)。 */
private val _hourlyTrend = MutableStateFlow<List<Sub2DailyUsage>>(emptyList())
val hourlyTrend: StateFlow<List<Sub2DailyUsage>> = _hourlyTrend.asStateFlow()
private var hourlyTrendGeneration = 0
fun loadHourlyTrend(date: LocalDate) {
val generation = ++hourlyTrendGeneration
viewModelScope.launch {
val result = sub2ApiRepository.fetchHourlyTrend(date, _accountId.value)
if (generation != hourlyTrendGeneration) return@launch
_hourlyTrend.value = result.getOrDefault(emptyList())
}
}
/** 「按 Key」用量状态(面板使用记录口径,按当前时间周期)。 */
private val _panelKeyStats = MutableStateFlow<PanelKeyStatsState>(PanelKeyStatsState.Idle)
val panelKeyStats: StateFlow<PanelKeyStatsState> = _panelKeyStats.asStateFlow()
private var panelKeyStatsGeneration = 0
/** 按所选周期拉取「按 Key」用量。 */
fun loadPanelKeyStats(from: LocalDate, to: LocalDate) {
val generation = ++panelKeyStatsGeneration
_panelKeyStats.value = PanelKeyStatsState.Loading
viewModelScope.launch {
val result = sub2ApiRepository.fetchKeyUsageStats(from, to, _accountId.value)
if (generation != panelKeyStatsGeneration) return@launch
result.fold(
onSuccess = { list ->
_panelKeyStats.value = PanelKeyStatsState.Success(
list.map {
PanelKeyStatsState.KeyStat(
keyId = it.keyId, name = it.name, groupName = it.groupName,
requests = it.requests, totalTokens = it.totalTokens,
cost = it.cost, unused = it.unused
)
}
)
},
onFailure = { e -> _panelKeyStats.value = PanelKeyStatsState.Error(e.message ?: "加载按 Key 用量失败") }
)
}
}
/** 刷新面板 Key 列表(仅 SUB2API 且有面板 Token)。 */
fun loadPanelKeys() {
viewModelScope.launch {
if (_panelKeysState.value is PanelKeysState.Loading) return@launch
_panelKeysState.value = PanelKeysState.Loading
sub2ApiRepository.fetchPanelKeys(_accountId.value).fold(
onSuccess = { list -> _panelKeysState.value = PanelKeysState.Success(list) },
onFailure = { e -> _panelKeysState.value = PanelKeysState.Error(e.message ?: "加载 Key 列表失败") }
)
}
}
/** 最近一次新建成功的明文 Key(弹窗立即复制用;查看后清除)。 */
private val _recentlyCreatedKey = MutableStateFlow<String?>(null)
val recentlyCreatedKey: StateFlow<String?> = _recentlyCreatedKey.asStateFlow()
fun consumeRecentlyCreatedKey() { _recentlyCreatedKey.value = null }
fun createPanelKey(name: String, groupId: Long?) {
viewModelScope.launch {
sub2ApiRepository.createPanelKey(name, groupId, _accountId.value).fold(
onSuccess = { key ->
_recentlyCreatedKey.value = key
_panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)"
loadPanelKeys()
},
onFailure = { e -> _panelActionMessage.value = "创建失败:" + (e.message ?: "") }
)
}
}
fun switchPanelGroup(keyId: Long, groupId: Long) {
viewModelScope.launch {
sub2ApiRepository.updatePanelKey(keyId, groupId, accountId = _accountId.value).fold(
onSuccess = {
_panelActionMessage.value = "分组已切换"
loadPanelKeys()
},
onFailure = { e -> _panelActionMessage.value = "切换失败:" + (e.message ?: "") }
)
}
}
fun redeemPanelCode(code: String) {
viewModelScope.launch {
sub2ApiRepository.redeemPanelCode(code, _accountId.value).fold(
onSuccess = { msg ->
_panelActionMessage.value = msg
refreshBalanceUseCase(ServiceType.SUB2API, _accountId.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)
}
/** 当前凭据的不可逆 SHA-256 指纹。 */
private var currentCredentialFingerprint: String? = null
@ -92,34 +399,55 @@ class ServiceDetailViewModel @Inject constructor(
else -> CODEX_PREFS
}
fun bind(service: ServiceType) {
if (_serviceType.value == service) return
fun bind(service: ServiceType, accountId: String? = null) {
val effectiveAccountId = accountId?.takeIf { it.isNotBlank() }
if (_serviceType.value == service && _accountId.value == effectiveAccountId) return
serviceGeneration++
refreshGeneration++
_serviceType.value = service
_accountId.value = effectiveAccountId
_accounts.value = emptyList()
currentCredentialFingerprint = null
_models.value = emptyList()
_selectedModel.value = null
_modelsLoading.value = false
_triggerState.value = TriggerState.Idle
// 切换服务/账号时重置签到状态,避免上一服务/账号的 Done/Error 残留(按钮态/文案串台)
_checkinState.value = CheckinState.Idle
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
loadSelectedModel(service)?.let { _selectedModel.value = it }
loadModelsCache(service).takeIf { it.isNotEmpty() }?.let { _models.value = it }
}
loadAccounts(service)
preloadCheckinInfo(service, effectiveAccountId)
loadFromCache()
}
/** 加载多账号服务的账号列表(账号管理页返回后可再次调用刷新)。 */
fun loadAccounts(service: ServiceType) {
val serviceGen = serviceGeneration
viewModelScope.launch {
val accounts = credentialRepository.accountsFor(service)
if (serviceGen == serviceGeneration && _serviceType.value == service) {
_accounts.value = accounts
}
}
}
/**
* 返回页面时同步凭据和缓存。凭据未变时保留错误/Loading,但按 fetchedAt 采用
* Widget 或其他入口写入的更新缓存;凭据新增或替换时废弃旧状态并重新刷新。
*/
fun reloadCredentialState() {
val type = _serviceType.value ?: return
val accountId = _accountId.value
val serviceGen = serviceGeneration
// 账号列表可能因账号管理页新增/删除/设默认而变化,一并刷新
loadAccounts(type)
viewModelScope.launch {
val local = credentialRepository.readLocalState(type)
val local = credentialRepository.readLocalState(type, accountId)
val newFingerprint = local.fingerprint
val change = classifyCredentialChange(currentCredentialFingerprint, newFingerprint)
val newHasCredential = local.status.state != CredentialStatus.State.NOT_CONFIGURED
@ -169,10 +497,11 @@ class ServiceDetailViewModel @Inject constructor(
fun refresh() {
val type = _serviceType.value ?: return
val accountId = _accountId.value
val serviceGen = serviceGeneration
val gen = ++refreshGeneration
viewModelScope.launch {
val requestFingerprint = credentialRepository.readLocalState(type).fingerprint
val requestFingerprint = credentialRepository.readLocalState(type, accountId).fingerprint
if (isStaleRequest(type, serviceGen, gen)) return@launch
_uiState.update { it.copy(state = State.Loading) }
@ -193,10 +522,10 @@ class ServiceDetailViewModel @Inject constructor(
return@launch
}
val result = refreshBalanceUseCase(type)
val result = refreshBalanceUseCase(type, accountId)
if (isStaleRequest(type, serviceGen, gen)) return@launch
val local = credentialRepository.readLocalState(type)
val local = credentialRepository.readLocalState(type, accountId)
if (isStaleRequest(type, serviceGen, gen)) return@launch
val fingerprintChanged = local.fingerprint != requestFingerprint
@ -386,6 +715,7 @@ class ServiceDetailViewModel @Inject constructor(
fun saveManualBalance(amount: Double) {
val type = _serviceType.value ?: return
val accountId = _accountId.value
val serviceGen = serviceGeneration
viewModelScope.launch {
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
@ -395,8 +725,8 @@ class ServiceDetailViewModel @Inject constructor(
unit = config.displayUnit,
isAvailable = true
)
balanceCache.put(type, balance)
val cached = balanceCache.get(type)
balanceCache.put(type, balance, accountId)
val cached = balanceCache.get(type, accountId)
if (serviceGen == serviceGeneration && _serviceType.value == type) {
_uiState.update {
it.copy(
@ -410,8 +740,9 @@ class ServiceDetailViewModel @Inject constructor(
fun markVerified() {
val type = _serviceType.value ?: return
val accountId = _accountId.value
viewModelScope.launch {
val credential = credentialRepository.get(type) ?: return@launch
val credential = credentialRepository.get(type, accountId) ?: return@launch
val updated = when (credential) {
is Credential.ApiKeyCredential ->
credential.copy(lastVerifiedAt = System.currentTimeMillis())
@ -419,17 +750,24 @@ class ServiceDetailViewModel @Inject constructor(
credential.copy(lastVerifiedAt = System.currentTimeMillis())
is Credential.CodexCredential ->
credential.copy(lastVerifiedAt = System.currentTimeMillis())
is Credential.TraeCredential ->
credential.copy(lastVerifiedAt = System.currentTimeMillis())
is Credential.WorkBuddyCredential ->
credential.copy(lastVerifiedAt = System.currentTimeMillis())
is Credential.Sub2ApiCredential ->
credential.copy(lastVerifiedAt = System.currentTimeMillis())
}
credentialRepository.save(updated)
credentialRepository.save(updated, accountId)
loadFromCache()
}
}
private fun loadFromCache() {
val type = _serviceType.value ?: return
val accountId = _accountId.value
val serviceGen = serviceGeneration
viewModelScope.launch {
val local = credentialRepository.readLocalState(type)
val local = credentialRepository.readLocalState(type, accountId)
val status = local.status
val cached = local.cachedBalance
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
@ -443,7 +781,10 @@ class ServiceDetailViewModel @Inject constructor(
UiText.Resource(R.string.error_credential_not_configured),
RepositoryError.InvalidCredential()
)
cached != null && status.state == CredentialStatus.State.OK ->
// OK 与 WARNING 都视为「凭据可用」:WARNING 仅是提示建议重新验证(如面板登录较久),
// 凭据仍有效,不应误报为「未配置或已过期」——否则能正常刷新余额却显示错误。
cached != null && (status.state == CredentialStatus.State.OK ||
status.state == CredentialStatus.State.WARNING) ->
// 缓存新鲜(≤5 分钟)时视为最新数据直接展示,避免"刚刷新完进详情却显示缓存"的困惑
if (isCacheFresh(cached.fetchedAt)) {
State.Fresh(cached.balance)
@ -545,6 +886,8 @@ class ServiceDetailViewModel @Inject constructor(
_serviceType.value != type
private fun errorMessage(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.CredentialChanged ->
@ -598,6 +941,58 @@ class ServiceDetailViewModel @Inject constructor(
?: emptyList()
}
/** 每日签到 UI 状态。 */
/** 每日签到 UI 状态。消息用 [UiText] 承载,由 UI 层按当前 Locale 解析(支持多语言)。 */
sealed class CheckinState {
data object Idle : CheckinState()
data object Loading : CheckinState()
data class Done(val message: UiText) : CheckinState()
/**
* @param showDeviceHint INTL 区接口不支持与设备无关,false 时不追加「设备 ID 获取路径」提示。
*/
data class Error(val message: UiText, val showDeviceHint: Boolean = true) : CheckinState()
/**
* 提示态(非错误、也非完成):例如 Trae 设备级拦截「该设备今日签到次数已达上限」。
* 与 [Done] 的区别是**不置灰按钮**——账号本身尚未签到,换网络/稍后可重试。
*/
data class Notice(val message: UiText) : CheckinState()
}
/** Sub2API 面板 Key 列表状态。 */
sealed class PanelKeysState {
data object Idle : PanelKeysState()
data object Loading : PanelKeysState()
data class Success(val keys: List<com.rainy.token.data.repository.Sub2ApiRepository.PanelApiKey>) : PanelKeysState()
data class Error(val message: String) : PanelKeysState()
}
/** Sub2API 按所选时间窗口拉取的分模型统计状态。 */
sealed class PanelWindowStatsState {
data object Idle : PanelWindowStatsState()
data object Loading : PanelWindowStatsState()
/** @param allTime 数据为历史累计口径(下游接口无法按窗口过滤),UI 需显式标注 */
data class Success(
val stats: List<com.rainy.token.domain.model.Sub2ModelStat>,
val allTime: Boolean = false
) : PanelWindowStatsState()
data class Error(val message: String) : PanelWindowStatsState()
}
/** Sub2API「按 Key」用量状态(按当前时间周期查询面板使用记录)。 */
sealed class PanelKeyStatsState {
data object Idle : PanelKeyStatsState()
data object Loading : PanelKeyStatsState()
data class Success(val stats: List<KeyStat>) : PanelKeyStatsState()
data class Error(val message: String) : PanelKeyStatsState()
data class KeyStat(
val keyId: Long, val name: String, val groupName: String?,
val requests: Long, val totalTokens: Long, val cost: Double, val unused: Boolean
)
}
sealed class State {
data object Loading : State()
data class Fresh(val data: ServiceBalance) : State()

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

@ -5,18 +5,22 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@ -27,6 +31,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@ -37,8 +44,18 @@ 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.setValue
import androidx.compose.foundation.layout.statusBarsPadding
import com.rainy.token.ui.components.SiteIcon
import androidx.compose.ui.Alignment
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import kotlinx.coroutines.delay
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -63,13 +80,18 @@ import com.rainy.token.domain.service.ServiceType
@Composable
fun CredentialEditScreen(
service: ServiceType,
accountId: String? = null,
onBack: () -> Unit,
onStartWebViewLogin: (ServiceType) -> Unit,
onStartWebViewLogin: (ServiceType, String?) -> Unit,
onWebViewLoginSuccess: (ServiceType) -> Unit,
onStartCodexOAuth: () -> Unit = {},
viewModel: CredentialEditViewModel = hiltViewModel()
) {
LaunchedEffect(service) { viewModel.bind(service) }
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
// 从 WebView 登录页返回时,登录流程可能已改写当前账号凭据,重载表单
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.reload()
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
@ -85,10 +107,23 @@ fun CredentialEditScreen(
}
}
// 多账号服务:标题带账号名(新建账号尚无 ID 时也提示是「新账号」)
val titleText = when {
ServiceType.isMultiAccount(service) && uiState.accountId != null ->
stringResource(
R.string.service_detail_title_with_account,
service.displayName,
uiState.accountLabel ?: stringResource(R.string.account_fallback_name, uiState.accountIndex)
)
ServiceType.isMultiAccount(service) && !uiState.hasExisting ->
stringResource(R.string.account_new_credential_title, service.displayName)
else -> service.displayName
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(service.displayName) },
title = { Text(titleText) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
@ -115,7 +150,64 @@ fun CredentialEditScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (uiState.isApiKeyService) {
if (service == ServiceType.TRAE) {
TraeCredentialForm(
jwt = uiState.traeJwt,
region = uiState.traeRegion,
checkinDeviceId = uiState.traeCheckinDeviceId,
hasExisting = uiState.hasExisting,
onJwtChange = viewModel::updateTraeJwt,
onRegionChange = viewModel::updateTraeRegion,
onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId) },
onSave = viewModel::saveTraeCredential
)
} else if (service == ServiceType.WORKBUDDY) {
WorkBuddyCredentialForm(
access = uiState.workBuddyAccess,
refresh = uiState.workBuddyRefresh,
hasExisting = uiState.hasExisting,
onAccessChange = viewModel::updateWorkBuddyAccess,
onRefreshChange = viewModel::updateWorkBuddyRefresh,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) },
onSave = viewModel::saveWorkBuddyCredential
)
} else if (service == ServiceType.SUB2API) {
var showPanelLogin by remember { mutableStateOf(false) }
Sub2ApiCredentialForm(
baseUrl = uiState.sub2BaseUrl,
email = uiState.sub2Email,
password = uiState.sub2Password,
apiKey = uiState.sub2ApiKey,
hasExisting = uiState.hasExisting,
hasPanelToken = uiState.sub2PanelToken != null,
siteName = uiState.sub2SiteName,
siteIcon = uiState.sub2SiteIcon,
siteLoading = uiState.sub2SiteLoading,
loggingIn = uiState.sub2LoggingIn,
loginMode = uiState.sub2LoginMode,
totp = uiState.sub2Totp,
onBaseUrlChange = viewModel::updateSub2BaseUrl,
onEmailChange = viewModel::updateSub2Email,
onPasswordChange = viewModel::updateSub2Password,
onApiKeyChange = viewModel::updateSub2ApiKey,
onLoginModeChange = viewModel::updateSub2LoginMode,
onTotpChange = viewModel::updateSub2Totp,
onPanelLogin = { showPanelLogin = true },
onLoginPanel = viewModel::loginPanelOneClick,
onSave = viewModel::saveSub2ApiCredential
)
if (showPanelLogin && uiState.sub2BaseUrl.isNotBlank()) {
PanelLoginDialog(
baseUrl = uiState.sub2BaseUrl.trim(),
onToken = { token ->
viewModel.onPanelTokenAcquired(token)
showPanelLogin = false
},
onDismiss = { showPanelLogin = false }
)
}
} else if (uiState.isApiKeyService) {
if (service == ServiceType.COMMANDCODE_GO) {
CommandCodeGoForm(
apiKey = uiState.apiKey,
@ -175,6 +267,7 @@ fun CredentialEditScreen(
onImportFromClipboard = { viewModel.importFromClipboard(context) },
onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) },
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) },
onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId) },
onShowHelp = { showGoHelp = true },
onApiKeyChange = viewModel::updateTriggerApiKey,
onSaveApiKey = { viewModel.saveTriggerApiKey() }
@ -418,6 +511,7 @@ private fun OpenCodeGoForm(
onImportFromClipboard: () -> Unit,
onCopyLoginUrl: () -> Unit,
onOpenLoginUrl: () -> Unit,
onStartInAppLogin: () -> Unit,
onShowHelp: () -> Unit,
onApiKeyChange: (String) -> Unit,
onSaveApiKey: () -> Unit
@ -428,6 +522,10 @@ private fun OpenCodeGoForm(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
// 主推入口:应用内登录自动抓 auth cookie + workspaceId,免手动开 DevTools 手抄
OutlinedButton(onClick = onStartInAppLogin, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.action_login_in_app))
}
OutlinedButton(onClick = onImportFromClipboard, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.action_import_from_clipboard))
}
@ -676,6 +774,412 @@ private fun ManualCookieForm(
}
}
@Composable
private fun TraeCredentialForm(
jwt: String,
region: String,
checkinDeviceId: String,
hasExisting: Boolean,
onJwtChange: (String) -> Unit,
onRegionChange: (String) -> Unit,
onCheckinDeviceIdChange: (String) -> Unit,
onStartInAppLogin: () -> Unit,
onSave: () -> Unit
) {
Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(R.string.credential_hint_trae),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedButton(
onClick = onStartInAppLogin,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_login_in_app))
}
OutlinedTextField(
value = jwt,
onValueChange = onJwtChange,
label = { Text(stringResource(R.string.field_trae_jwt)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.field_trae_region),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RegionButton(
label = stringResource(R.string.option_region_cn),
selected = region.uppercase() != "INTL",
onSelect = { onRegionChange("CN") }
)
RegionButton(
label = stringResource(R.string.option_region_intl),
selected = region.uppercase() == "INTL",
onSelect = { onRegionChange("INTL") }
)
}
OutlinedTextField(
value = checkinDeviceId,
onValueChange = onCheckinDeviceIdChange,
label = { Text(stringResource(R.string.field_trae_checkin_device_id)) },
supportingText = { Text(stringResource(R.string.field_trae_checkin_device_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
var showDeviceIdGuide by remember { mutableStateOf(false) }
TextButton(onClick = { showDeviceIdGuide = true }) {
Text(stringResource(R.string.field_trae_device_id_howto))
}
if (showDeviceIdGuide) {
TraeDeviceIdGuideDialog(onDismiss = { showDeviceIdGuide = false })
}
Button(onClick = onSave, enabled = jwt.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
}
}
/** 设备 ID 获取分步指引弹窗:面向不熟悉电脑操作的用户,含一键复制日志目录路径。 */
@Composable
private fun TraeDeviceIdGuideDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
// 环境变量形式:Win+R 与资源管理器地址栏都能展开 %APPDATA%,相对路径在两处都打不开
val logPath = "%APPDATA%\\Trae CN\\logs"
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.field_trae_device_id_howto_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
stringResource(R.string.field_trae_device_id_howto_step1),
style = MaterialTheme.typography.bodyMedium
)
// 路径 + 一键复制(小白直接粘到资源管理器地址栏)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
logPath,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f, fill = true)
)
TextButton(onClick = {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("trae_log_path", logPath))
// 复制反馈:小白需要确认复制成功(R.string 引用须在 composable 内解析)
android.widget.Toast.makeText(
context,
context.getString(R.string.msg_path_copied),
android.widget.Toast.LENGTH_SHORT
).show()
}) {
Text(stringResource(R.string.action_copy))
}
}
Text(
stringResource(R.string.field_trae_device_id_howto_step2),
style = MaterialTheme.typography.bodyMedium
)
Text(
stringResource(R.string.field_trae_device_id_howto_step3),
style = MaterialTheme.typography.bodyMedium
)
Text(
stringResource(R.string.field_trae_device_id_howto_step4),
style = MaterialTheme.typography.bodyMedium
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_close)) }
}
)
}
@Composable
private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit) {
OutlinedButton(onClick = onSelect) {
Text(
text = label,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
private fun WorkBuddyCredentialForm(
access: String,
refresh: String,
hasExisting: Boolean,
onAccessChange: (String) -> Unit,
onRefreshChange: (String) -> Unit,
onStartInAppLogin: () -> Unit,
onSave: () -> Unit
) {
Text(text = stringResource(R.string.credential_title_workbuddy), style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(R.string.credential_hint_workbuddy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedButton(
onClick = onStartInAppLogin,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_login_in_app))
}
OutlinedTextField(
value = access,
onValueChange = onAccessChange,
label = { Text(stringResource(R.string.field_workbuddy_access)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = refresh,
onValueChange = onRefreshChange,
label = { Text(stringResource(R.string.field_workbuddy_refresh)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Button(onClick = onSave, enabled = access.isNotBlank() && refresh.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
}
}
@Composable
private fun Sub2ApiCredentialForm(
baseUrl: String,
email: String,
password: String,
apiKey: String,
hasExisting: Boolean,
hasPanelToken: Boolean = false,
siteName: String? = null,
siteIcon: String? = null,
siteLoading: Boolean = false,
loggingIn: Boolean = false,
loginMode: String = "account",
totp: String = "",
onBaseUrlChange: (String) -> Unit,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onApiKeyChange: (String) -> Unit,
onLoginModeChange: (String) -> Unit = {},
onTotpChange: (String) -> Unit = {},
onPanelLogin: () -> Unit = {},
onLoginPanel: () -> Unit = {},
onSave: () -> Unit
) {
Text(text = stringResource(R.string.credential_title_sub2api), style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = baseUrl,
onValueChange = onBaseUrlChange,
label = { Text(stringResource(R.string.field_sub2_base_url)) },
placeholder = { Text(stringResource(R.string.placeholder_sub2_base_url)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// 站点识别预览:Logo + 名称(确认地址填对;探测中/失败静默隐藏)
if (siteLoading || siteName != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
) {
if (siteIcon != null) {
SiteIcon(url = siteIcon, size = 16.dp)
Spacer(modifier = Modifier.size(6.dp))
}
Text(
text = siteName ?: stringResource(R.string.sub2_site_probing),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
// 登录方式:账号密码(优先)| API Key
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
SegmentedButton(
selected = loginMode == "account",
onClick = { onLoginModeChange("account") },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2)
) {
Text(stringResource(R.string.sub2_mode_account))
}
SegmentedButton(
selected = loginMode == "apikey",
onClick = { onLoginModeChange("apikey") },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2)
) {
Text(stringResource(R.string.sub2_mode_apikey))
}
}
if (loginMode == "account") {
OutlinedTextField(
value = email,
onValueChange = onEmailChange,
label = { Text(stringResource(R.string.field_sub2_email)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.field_sub2_password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = totp,
onValueChange = onTotpChange,
label = { Text(stringResource(R.string.sub2_field_totp)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedButton(
onClick = onLoginPanel,
enabled = baseUrl.isNotBlank() && email.isNotBlank() && password.isNotBlank() && !loggingIn,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (loggingIn) stringResource(R.string.sub2_panel_logining)
else if (hasPanelToken) stringResource(R.string.sub2_panel_login_done_short)
else stringResource(R.string.sub2_panel_oneclick)
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
TextButton(onClick = onPanelLogin, enabled = baseUrl.isNotBlank()) {
Text(stringResource(R.string.sub2_panel_login))
}
}
} else {
OutlinedTextField(
value = apiKey,
onValueChange = onApiKeyChange,
label = { Text(stringResource(R.string.field_sub2_api_key)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.sub2_mode_api_key_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
Button(
onClick = onSave,
enabled = baseUrl.isNotBlank() &&
(apiKey.isNotBlank() || (email.isNotBlank() && password.isNotBlank()) || hasPanelToken),
modifier = Modifier.fillMaxWidth()
) {
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
}
}
/**
* Sub2 面板 WebView 登录:打开实例登录页,用户正常登录后自动读取 localStorage.auth_token
* (evaluateJavascript 轮询),回填凭据。Token 全程不出设备;也可不登录直接关闭继续手动填写。
*/
@Composable
private fun PanelLoginDialog(
baseUrl: String,
onToken: (String) -> Unit,
onDismiss: () -> Unit
) {
var webViewRef by remember { mutableStateOf<android.webkit.WebView?>(null) }
var detected by remember { mutableStateOf(false) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)
) {
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
AndroidView(
factory = { ctx ->
android.webkit.WebView(ctx).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = android.webkit.WebViewClient()
webViewRef = this
loadUrl(if (baseUrl.contains("://")) baseUrl else "https://" + baseUrl)
}
},
modifier = Modifier.fillMaxSize()
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = stringResource(R.string.sub2_panel_login),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = null)
}
}
}
}
// 只接受来自面板本域名的 auth_token:
// 登录容器会跳转到第三方页面(OAuth 回调 / CDN),任何被加载的页面都能改写自己的
// localStorage,不做来源校验就等于允许它们伪造 token 覆盖用户凭据。
val expectedHost = remember(baseUrl) {
val withScheme = if (baseUrl.contains("://")) baseUrl else "https://" + baseUrl
okhttp3.HttpUrl.Companion.run { withScheme.toHttpUrlOrNull()?.host }
}
// 轮询 localStorage.auth_token(登录成功后自动捕获并回调关闭)
LaunchedEffect(detected, expectedHost) {
while (!detected) {
kotlinx.coroutines.delay(1500)
val web = webViewRef ?: continue
// 回传 "hosttoken",避免 JSON 在 Kotlin 字符串里的多层转义
web.evaluateJavascript(
"(function(){try{" +
"var t=window.localStorage.getItem('auth_token')||'';" +
"var h=window.location.hostname||'';" +
"return h+'\u0001'+t;" +
"}catch(e){return ''}})()"
) { value ->
if (detected) return@evaluateJavascript
// evaluateJavascript 回传的是 JS 字符串字面量,带引号与转义
val unquoted = value?.let { v ->
runCatching { org.json.JSONTokener(v).nextValue() as? String }.getOrNull()
} ?: return@evaluateJavascript
val sep = unquoted.indexOf('')
if (sep < 0) return@evaluateJavascript
val host = unquoted.substring(0, sep)
val token = unquoted.substring(sep + 1)
if (token.length > 20 && expectedHost != null &&
host.equals(expectedHost, ignoreCase = true)
) {
detected = true
onToken(token)
}
}
}
}
}
private fun copyToClipboard(context: Context, text: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("login_url", text))

View File

@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import com.rainy.token.R
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.domain.model.CookieEntry
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.FetchMethod
@ -20,6 +21,8 @@ import javax.inject.Provider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
@ -36,33 +39,59 @@ import kotlinx.serialization.json.jsonPrimitive
@HiltViewModel
class CredentialEditViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>,
private val sub2ApiRepository: Sub2ApiRepository,
private val savedStateHandle: androidx.lifecycle.SavedStateHandle
) : ViewModel() {
/** Sub2 站点信息探测(baseUrl 输入 debounce 后触发)。 */
private var sub2SiteJob: Job? = null
private var sub2SiteGeneration: Int = 0
private val _uiState = MutableStateFlow(CredentialEditUiState())
val uiState: StateFlow<CredentialEditUiState> = _uiState.asStateFlow()
private var serviceType: ServiceType? = null
fun bind(service: ServiceType) {
if (serviceType == service) return
/** 当前编辑的账号 ID(多账号服务);null=新建账号/单账号服务。 */
private var accountId: String? = null
fun bind(service: ServiceType, accountId: String? = null) {
val effectiveAccountId = accountId?.takeIf { it.isNotBlank() }
if (serviceType == service && this.accountId == effectiveAccountId) return
serviceType = service
this.accountId = effectiveAccountId
val config = ServiceConfigProvider.get(service)
val isApiKey = config.method == FetchMethod.REST_API
_uiState.update {
it.copy(
service = service,
isApiKeyService = isApiKey,
loginUrl = config.loginUrl
loginUrl = config.loginUrl,
accountId = effectiveAccountId,
accountLabel = null,
accountIndex = 0
)
}
load()
refreshAccountInfo()
}
private fun load() {
val type = serviceType ?: return
// WebView 登录成功回调会把实际写入的账号 ID 通过 savedStateHandle 回传:
// 新建账号模式(accountId=null)登录后要切换到“编辑该新账号”模式,避免再次保存时误建第二个账号
savedStateHandle.get<String>(KEY_LOGIN_ACCOUNT_ID)?.let { pendingId ->
savedStateHandle[KEY_LOGIN_ACCOUNT_ID] = null
if (accountId.isNullOrBlank() && !pendingId.isBlank()) {
accountId = pendingId
_uiState.update { it.copy(accountId = pendingId) }
}
}
viewModelScope.launch {
val existing = credentialRepository.get(type)
// 多账号服务 + null accountId = “新建账号”模式:表单必须留空(不能预填默认账号凭据)
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
val existing = if (isNewAccount) null else credentialRepository.get(type, accountId)
_uiState.update {
it.copy(
apiKey = when (existing) {
@ -92,9 +121,31 @@ class CredentialEditViewModel @Inject constructor(
} else "",
ollamaCookie = (existing as? Credential.SessionCredential)?.ollamaCookie.orEmpty(),
triggerApiKey = (existing as? Credential.SessionCredential)?.apiKey.orEmpty(),
traeJwt = (existing as? Credential.TraeCredential)?.jwt.orEmpty(),
traeRegion = (existing as? Credential.TraeCredential)?.region ?: "CN",
traeCheckinDeviceId = (existing as? Credential.TraeCredential)?.checkinDeviceId.orEmpty(),
workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(),
workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(),
sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(),
sub2SiteIcon = (existing as? Credential.Sub2ApiCredential)?.siteIcon,
sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(),
sub2Password = (existing as? Credential.Sub2ApiCredential)?.password.orEmpty(),
sub2ApiKey = (existing as? Credential.Sub2ApiCredential)?.apiKey.orEmpty(),
sub2LoginMode = when {
// 有面板 Token:账号密码模式优先(面板登录是主力)
!((existing as? Credential.Sub2ApiCredential)?.authToken).isNullOrBlank() -> "account"
// 仅存过 API Key:保持 API Key 模式
!((existing as? Credential.Sub2ApiCredential)?.apiKey).isNullOrBlank() -> "apikey"
else -> "account"
},
hasExisting = existing != null
)
}
// 已有 baseUrl:补跑一次站点识别(load 不经过输入框,探测不会自动触发)
val base = (existing as? Credential.Sub2ApiCredential)?.baseUrl
if (!base.isNullOrBlank()) {
updateSub2BaseUrl(base)
}
}
}
@ -161,6 +212,8 @@ class CredentialEditViewModel @Inject constructor(
}
val err = result.exceptionOrNull()
val reason: UiText = when (err) {
is RepositoryError.SessionExpired ->
UiText.Resource(R.string.error_credential_session_expired)
is RepositoryError.InvalidCredential ->
UiText.Resource(R.string.error_credential_rejected)
is RepositoryError.CredentialChanged ->
@ -240,6 +293,216 @@ class CredentialEditViewModel @Inject constructor(
}
}
// ---------- Trae ----------
fun updateTraeJwt(value: String) {
_uiState.update { it.copy(traeJwt = value) }
}
fun updateTraeRegion(value: String) {
_uiState.update { it.copy(traeRegion = value) }
}
fun updateTraeCheckinDeviceId(value: String) {
_uiState.update { it.copy(traeCheckinDeviceId = value) }
}
fun saveTraeCredential() {
val type = serviceType ?: return
val current = _uiState.value
val jwt = current.traeJwt.trim()
if (jwt.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return
}
viewModelScope.launch {
// 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据(lastVerifiedAt 等)
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
val existing = if (isNewAccount) {
null
} else {
credentialRepository.get(type, accountId) as? Credential.TraeCredential
}
val effectiveId = credentialRepository.save(
(existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy(
jwt = jwt,
region = if (current.traeRegion.uppercase() == "INTL") "INTL" else "CN",
// 换账号(JWT 变化)时清空本地签到短路标记,避免新账号被误判「今日已签到」
lastCheckinDate = if (existing != null && existing.jwt == jwt) existing.lastCheckinDate else null,
// 签到设备 ID:留空 = 用内置默认;填写 = 优先(换设备后自查 did 更新)
checkinDeviceId = current.traeCheckinDeviceId.trim().ifBlank { null },
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
),
accountId
)
accountId = effectiveId.ifBlank { accountId }
_uiState.update {
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
}
refreshAccountInfo()
}
}
// ---------- WorkBuddy ----------
fun updateWorkBuddyAccess(value: String) {
_uiState.update { it.copy(workBuddyAccess = value) }
}
fun updateWorkBuddyRefresh(value: String) {
_uiState.update { it.copy(workBuddyRefresh = value) }
}
fun saveWorkBuddyCredential() {
val type = serviceType ?: return
val current = _uiState.value
val access = current.workBuddyAccess.trim()
val refresh = current.workBuddyRefresh.trim()
if (access.isBlank() || refresh.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_workbuddy_tokens_required)) }
return
}
viewModelScope.launch {
// 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
val existing = if (isNewAccount) {
null
} else {
credentialRepository.get(type, accountId) as? Credential.WorkBuddyCredential
}
val effectiveId = credentialRepository.save(
(existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh))
.copy(
accessToken = access,
refreshToken = refresh,
expiresAt = existing?.expiresAt ?: 0L,
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
),
accountId
)
accountId = effectiveId.ifBlank { accountId }
_uiState.update {
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
}
refreshAccountInfo()
}
}
// ---------- Sub2API ----------
fun updateSub2BaseUrl(value: String) {
_uiState.update { it.copy(sub2BaseUrl = value, sub2SiteName = null, sub2SiteIcon = null) }
sub2SiteJob?.cancel()
val generation = ++sub2SiteGeneration
sub2SiteJob = viewModelScope.launch {
delay(600)
val base = value.trim()
if (base.isBlank() || generation != sub2SiteGeneration) return@launch
_uiState.update { it.copy(sub2SiteLoading = true) }
val info = sub2ApiRepository.fetchSiteInfo(base)
if (generation != sub2SiteGeneration) return@launch
_uiState.update {
it.copy(
sub2SiteLoading = false,
sub2SiteName = info?.name?.takeIf { n -> n.isNotBlank() },
sub2SiteIcon = info?.iconUrl
)
}
}
}
/** 面板账号一键登录:邮箱+密码 → POST /api/v1/auth/login → 面板 Token(存为 authToken 走面板模式)。 */
fun loginPanelOneClick() {
val current = _uiState.value
val base = current.sub2BaseUrl.trim()
val email = current.sub2Email.trim()
val password = current.sub2Password.trim()
if (base.isBlank() || email.isBlank() || password.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) }
return
}
if (current.sub2LoggingIn) return
_uiState.update { it.copy(sub2LoggingIn = true) }
viewModelScope.launch {
sub2ApiRepository.loginPanel(base, email, password, current.sub2Totp.trim()).fold(
onSuccess = { token -> onPanelTokenAcquired(token) },
onFailure = { e ->
_uiState.update {
it.copy(message = UiText.Dynamic(e.message ?: "登录失败,请检查邮箱/密码"))
}
}
)
_uiState.update { it.copy(sub2LoggingIn = false) }
}
}
fun updateSub2Email(value: String) {
_uiState.update { it.copy(sub2Email = value) }
}
fun updateSub2Password(value: String) {
_uiState.update { it.copy(sub2Password = value) }
}
fun updateSub2ApiKey(value: String) {
_uiState.update { it.copy(sub2ApiKey = value) }
}
fun updateSub2LoginMode(mode: String) {
_uiState.update { it.copy(sub2LoginMode = mode) }
}
fun updateSub2Totp(value: String) {
_uiState.update { it.copy(sub2Totp = value) }
}
/** WebView 网页登录成功:写入本次待保存的面板 Token(保存时并入 authToken)。 */
fun onPanelTokenAcquired(token: String) {
_uiState.update { it.copy(sub2PanelToken = token, message = UiText.Resource(R.string.sub2_panel_login_done)) }
}
fun saveSub2ApiCredential() {
val type = serviceType ?: return
val current = _uiState.value
val base = current.sub2BaseUrl.trim()
val apiKey = current.sub2ApiKey.trim()
val email = current.sub2Email.trim()
val password = current.sub2Password.trim()
viewModelScope.launch {
// 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
val existing = if (isNewAccount) {
null
} else {
credentialRepository.get(type, accountId) as? Credential.Sub2ApiCredential
}
// 面板 Token(本次 WebView 获取或既有)也算有效认证
val panelToken = current.sub2PanelToken ?: existing?.authToken
// 实例地址必填;认证需 API Key /(邮箱+密码)/ 面板 Token 之一
if (base.isBlank() || (apiKey.isBlank() && (email.isBlank() || password.isBlank()) && panelToken == null)) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) }
return@launch
}
val effectiveId = credentialRepository.save(
(existing ?: Credential.Sub2ApiCredential(service = type, baseUrl = base, email = email, password = password))
.copy(
baseUrl = base,
email = email,
password = password,
apiKey = apiKey,
authToken = panelToken,
siteIcon = current.sub2SiteIcon ?: existing?.siteIcon,
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
),
accountId
)
accountId = effectiveId.ifBlank { accountId }
_uiState.update {
it.copy(hasExisting = true, sub2PanelToken = null, message = UiText.Resource(R.string.msg_credentials_saved))
}
}
}
/** 把 API Key 缩成 'sk-a***xyz' 这种形式,前 4 后 4,中间用 *** 代替。 */
private fun maskedKeyPreview(key: String): UiText {
if (key.length <= 8) {
@ -561,10 +824,19 @@ class CredentialEditViewModel @Inject constructor(
fun deleteCredential() {
val type = serviceType ?: return
val targetAccountId = accountId
viewModelScope.launch {
credentialRepository.remove(type)
if (ServiceType.isMultiAccount(type) && !targetAccountId.isNullOrBlank()) {
credentialRepository.removeAccount(type, targetAccountId)
} else {
credentialRepository.remove(type)
}
accountId = null
_uiState.update {
it.copy(
accountId = null,
accountLabel = null,
accountIndex = 0,
message = UiText.Resource(R.string.msg_credential_deleted),
hasExisting = false,
apiKey = "",
@ -572,8 +844,37 @@ class CredentialEditViewModel @Inject constructor(
authCookie = "",
workspaceId = "",
cookieCount = 0,
codexAuthJson = "",
ollamaCookie = "",
triggerApiKey = ""
triggerApiKey = "",
traeJwt = "",
traeRegion = "CN",
traeCheckinDeviceId = "",
workBuddyAccess = "",
workBuddyRefresh = "",
sub2BaseUrl = "",
sub2Email = "",
sub2Password = "",
sub2ApiKey = ""
)
}
}
}
/** 多账号服务:按当前账号 ID 刷新展示用的账号名与序号。 */
private fun refreshAccountInfo() {
val type = serviceType ?: return
val aid = accountId ?: return
viewModelScope.launch {
val accounts = credentialRepository.accountsFor(type)
val index = accounts.indexOfFirst { it.id == aid }
if (index < 0) return@launch
val account = accounts[index]
_uiState.update {
it.copy(
accountId = aid,
accountLabel = account.label?.takeIf { l -> l.isNotBlank() },
accountIndex = index + 1
)
}
}
@ -583,6 +884,17 @@ class CredentialEditViewModel @Inject constructor(
_uiState.update { it.copy(message = null) }
}
/** 从 WebView 登录页返回后重载表单与账号信息(当前凭据可能已被登录流程改写)。 */
fun reload() {
load()
refreshAccountInfo()
}
companion object {
/** WebView 登录页通过 previousBackStackEntry.savedStateHandle 回传实际写入的账号 ID。 */
const val KEY_LOGIN_ACCOUNT_ID = "login_account_id"
}
fun importFromClipboard(context: Context) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
val text = cm.primaryClip?.getItemAt(0)?.text?.toString()
@ -639,6 +951,12 @@ class CredentialEditViewModel @Inject constructor(
data class CredentialEditUiState(
val service: ServiceType? = null,
/** 当前编辑的账号 ID(多账号服务);null=新建账号/单账号服务。 */
val accountId: String? = null,
/** 账号自定义名称(可能为 null,UI 按“账号 N”兜底)。 */
val accountLabel: String? = null,
/** 同服务内账号序号(1 起,兜底名称用)。 */
val accountIndex: Int = 0,
val isApiKeyService: Boolean = false,
val loginUrl: String = "",
val apiKey: String = "",
@ -651,5 +969,30 @@ data class CredentialEditUiState(
val ollamaCookie: String = "",
/** OCGO / Ollama 的一键激活用量 API Key */
val triggerApiKey: String = "",
/** Trae:Cloud-IDE-JWT + 区域 */
val traeJwt: String = "",
val traeRegion: String = "CN",
/** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */
val traeCheckinDeviceId: String = "",
/** WorkBuddy:OAuth tokens */
val workBuddyAccess: String = "",
val workBuddyRefresh: String = "",
/** Sub2API:自托管实例连接 */
val sub2BaseUrl: String = "",
val sub2Email: String = "",
val sub2Password: String = "",
val sub2ApiKey: String = "",
/** Sub2API:WebView/一键登录新获取的面板 Token(保存时并入 authToken) */
val sub2PanelToken: String? = null,
/** Sub2API:baseUrl 输入后自动探测的站点名称/图标(确认地址填对) */
val sub2SiteName: String? = null,
val sub2SiteIcon: String? = null,
val sub2SiteLoading: Boolean = false,
/** Sub2API:一键登录进行中 */
val sub2LoggingIn: Boolean = false,
/** Sub2API:登录方式(默认账号密码优先;仅存 apiKey 时切 API Key) */
val sub2LoginMode: String = "account",
/** Sub2API:二次验证码(选填) */
val sub2Totp: String = "",
val message: UiText? = null
)

View File

@ -54,6 +54,11 @@ 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
@ -73,6 +78,7 @@ import com.rainy.token.util.LocaleManager
fun SettingsScreen(
onBack: () -> Unit,
onEditCredential: (ServiceType) -> Unit,
onManageAccounts: (ServiceType) -> Unit = {},
onOpenTips: () -> Unit = {},
onOpenDebugLog: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel()
@ -137,10 +143,17 @@ fun SettingsScreen(
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 {
@ -152,6 +165,9 @@ fun SettingsScreen(
item {
DebugLogCard(onClick = { onOpenDebugLog() })
}
item {
UpdateCard()
}
item {
Spacer(modifier = Modifier.padding(top = 8.dp))
AboutCard()
@ -261,7 +277,11 @@ private fun LanguageDialog(onDismiss: () -> Unit) {
}
@Composable
private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit) {
private fun CredentialStatusCard(
status: CredentialStatus,
accountCount: Int = 0,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
@ -283,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)
@ -294,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(

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

@ -122,7 +122,7 @@ fun CodexOAuthScreen(
Text(
uiState.error!!.asString(),
style = MaterialTheme.typography.bodyLarge,
color = androidx.compose.ui.graphics.Color(0xFFE91E63)
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
Text(stringResource(R.string.action_retry), color = StrawberryPink)
@ -286,7 +286,7 @@ private fun HeadlessOAuthContent(
Text(
text = error.asString(),
style = MaterialTheme.typography.bodySmall,
color = androidx.compose.ui.graphics.Color(0xFFE91E63),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp)
)
TextButton(onClick = onRetry) {

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,6 +29,8 @@ 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
@ -33,6 +38,7 @@ 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
@ -47,17 +53,34 @@ import com.rainy.token.ui.components.asString
@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)
}
}
@ -66,6 +89,7 @@ fun WebViewLoginScreen(
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.title_webview_login, service.displayName)) },
@ -100,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()
@ -112,13 +181,12 @@ fun WebViewLoginScreen(
modifier = Modifier.fillMaxSize()
)
} else {
Column(modifier = Modifier
.fillMaxSize()
.padding(16.dp)) {
Text(
text = stringResource(R.string.error_login_url_missing),
style = MaterialTheme.typography.bodyLarge
)
// loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
@ -146,4 +214,43 @@ fun WebViewLoginScreen(
}
)
}
}
/**
* 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,8 +1,28 @@
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
@ -15,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 {
@ -55,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)
}
@ -63,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 = UiText.Resource(R.string.error_cookie_not_found),
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: UiText? = 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()
)
}
}
}

View File

@ -8,11 +8,15 @@ import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.util.TypedValue
import android.view.View
import android.widget.RemoteViews
import com.rainy.token.MainActivity
import com.rainy.token.R
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.cache.balanceCacheDataStore
import com.rainy.token.data.local.SecureStorage
import com.rainy.token.data.local.secureStorageDataStore
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.normalizeWindowLabel
import com.rainy.token.util.LocaleManager
@ -57,12 +61,15 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
var selectedHasCachedData = false
// 每次渲染都用最新语言偏好重新包装 context:Application 层的 wrap 在进程启动时固定,
// 应用内切换语言后不会自动更新,必须在这里按当前偏好重建,否则 widget 文本停留在旧语言
val localized = LocaleManager.wrapContext(context)
// 已配置服务只读一次(Keystore 解密有成本);未配置的服务不进轮换、DS 行整体隐藏
val configured = configuredServices(context)
val dsConfigured = isCredentialPresent(context, ServiceType.DEEPSEEK)
val selectedService = currentDisplayService(context, configured)
for (widgetId in appWidgetIds) {
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
@ -104,49 +111,78 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
try {
val dataStore = context.applicationContext.balanceCacheDataStore
val cache = BalanceCache(dataStore)
val selectedService = currentDisplayService(context)
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
views.setTextViewText(
R.id.widget_service_title,
localized.getString(R.string.widget_service_quota, selectedService.displayName)
views.setViewVisibility(
R.id.widget_ds_row,
if (dsConfigured) View.VISIBLE else View.GONE
)
views.setImageViewResource(R.id.widget_logo, widgetLogo(selectedService))
// Ollama logo 是正方形,XML 默认 22x12 是给宽扁 logo 的
if (selectedService == ServiceType.OLLAMA) {
views.setViewLayoutWidth(R.id.widget_logo, 14f, TypedValue.COMPLEX_UNIT_DIP)
views.setViewLayoutHeight(R.id.widget_logo, 14f, TypedValue.COMPLEX_UNIT_DIP)
} else {
views.setViewLayoutWidth(R.id.widget_logo, 22f, TypedValue.COMPLEX_UNIT_DIP)
views.setViewLayoutHeight(R.id.widget_logo, 12f, TypedValue.COMPLEX_UNIT_DIP)
}
val cached = cache.get(selectedService)
if (cached != null) {
selectedHasCachedData = true
populateServiceRows(views, localized, selectedService, cached.balance)
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
val timeText = sdf.format(Date(cached.fetchedAt))
if (selectedService == null || configured.isEmpty()) {
// 轮换区没有任何已配置服务:
// - DeepSeek 已配置 → 标题回退「DeepSeek 余额」,只展示 DS 余额行
// - 全都没配置 → 展示引导空态,不展示任何服务数据
views.setTextViewText(R.id.widget_switch, "")
val emptyLogo = if (dsConfigured) ServiceType.DEEPSEEK else ServiceType.OPENCODE_GO
views.setImageViewResource(R.id.widget_logo, widgetLogo(emptyLogo))
// DeepSeek logo 是方形,XML 默认 22x12 会压扁,用正方形盒
if (emptyLogo == ServiceType.DEEPSEEK) {
views.setViewLayoutWidth(R.id.widget_logo, 14f, TypedValue.COMPLEX_UNIT_DIP)
views.setViewLayoutHeight(R.id.widget_logo, 14f, TypedValue.COMPLEX_UNIT_DIP)
} else {
views.setViewLayoutWidth(R.id.widget_logo, 22f, TypedValue.COMPLEX_UNIT_DIP)
views.setViewLayoutHeight(R.id.widget_logo, 12f, TypedValue.COMPLEX_UNIT_DIP)
}
views.setTextViewText(
R.id.widget_updated,
localized.getString(R.string.widget_updated_at, timeText)
R.id.widget_service_title,
localized.getString(
if (dsConfigured) R.string.widget_deepseek_balance
else R.string.widget_not_configured
)
)
} else {
setEmptyState(views, localized)
} else {
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
views.setTextViewText(
R.id.widget_service_title,
localized.getString(R.string.widget_service_quota, selectedService.displayName)
)
}
views.setImageViewResource(R.id.widget_logo, widgetLogo(selectedService))
// Ollama logo 是正方形,XML 默认 22x12 是给宽扁 logo 的
if (selectedService == ServiceType.OLLAMA) {
views.setViewLayoutWidth(R.id.widget_logo, 14f, TypedValue.COMPLEX_UNIT_DIP)
views.setViewLayoutHeight(R.id.widget_logo, 14f, TypedValue.COMPLEX_UNIT_DIP)
} else {
views.setViewLayoutWidth(R.id.widget_logo, 22f, TypedValue.COMPLEX_UNIT_DIP)
views.setViewLayoutHeight(R.id.widget_logo, 12f, TypedValue.COMPLEX_UNIT_DIP)
}
val cached = cache.get(selectedService)
if (cached != null) {
populateServiceRows(views, localized, selectedService, cached.balance)
// DeepSeek 余额
val dsCached = cache.get(ServiceType.DEEPSEEK)
if (dsCached != null && dsCached.balance.amount > 0) {
val dsBal = dsCached.balance
val dsText = dsBal.unit + String.format("%.2f", dsBal.amount)
views.setTextViewText(R.id.widget_ds_amount, dsText)
} else {
views.setTextViewText(R.id.widget_ds_amount, "—")
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
val timeText = sdf.format(Date(cached.fetchedAt))
views.setTextViewText(
R.id.widget_updated,
localized.getString(R.string.widget_updated_at, timeText)
)
} else {
setEmptyState(views, localized)
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
views.setTextViewText(
R.id.widget_service_title,
localized.getString(R.string.widget_service_quota, selectedService.displayName)
)
}
}
// DeepSeek 余额独立于轮换区填充,且必须放在所有 setEmptyState 之后:
// setEmptyState 会把 widget_ds_amount 写成 "—",后填的真值才能覆盖它
if (dsConfigured) {
val dsCached = cache.get(ServiceType.DEEPSEEK)
if (dsCached != null && dsCached.balance.amount > 0) {
val dsBal = dsCached.balance
val dsText = dsBal.unit + String.format("%.2f", dsBal.amount)
views.setTextViewText(R.id.widget_ds_amount, dsText)
} else {
views.setTextViewText(R.id.widget_ds_amount, "—")
}
}
} catch (_: Exception) {
setEmptyState(views, localized)
@ -156,8 +192,11 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
appWidgetManager.updateAppWidget(widgetId, views)
}
// 自动刷新:缓存为空 或 超过冷却时间 → 触发后台刷新
if (!selectedHasCachedData || shouldAutoRefresh(context)) {
// 自动刷新:有已配置服务且超过冷却时间 → 触发后台刷新。
// 缓存命中与否不参与决策——receiver 现在无条件重渲染,若以「缓存为空」为由
// 绕过冷却,刷新失败时会形成 onUpdate → 广播 → 失败 → 重渲染 → 再广播的
// 无冷却重试环;冷却约束是唯一的节流阀。
if ((configured.isNotEmpty() || dsConfigured) && shouldAutoRefresh(context)) {
markAutoRefreshTime(context)
context.sendBroadcast(WidgetRefreshReceiver.createIntent(context))
}
@ -240,6 +279,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
}
}
ServiceType.DEEPSEEK -> setEmptyState(views, context)
ServiceType.TRAE,
ServiceType.WORKBUDDY,
ServiceType.SUB2API -> setEmptyState(views, context)
ServiceType.OLLAMA -> {
setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_every_week), "")
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
@ -297,9 +339,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
/** 根据用量百分比动态改进度条颜色 */
private fun setProgressColor(views: RemoteViews, barViewId: Int, pct: Int) {
val color = when {
pct >= 80 -> 0xFFE91E63.toInt() // 玫红
pct >= 80 -> 0xFFD64A6E.toInt() // 玫红
pct >= 50 -> 0xFFFFA726.toInt() // 暖橙
else -> 0xFFFF85A2.toInt() // 草莓粉
else -> 0xFFC9436C.toInt() // 深玫瑰(主色)
}
views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color))
}
@ -342,14 +384,57 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
.apply()
}
fun currentDisplayService(context: Context): ServiceType {
val key = autoRefreshPrefs(context).getString(KEY_DISPLAY_SERVICE, ServiceType.OPENCODE_GO.storageKey)
return DISPLAY_SERVICES.firstOrNull { it.storageKey == key } ?: ServiceType.OPENCODE_GO
/**
* 已配置凭据的服务(本地存在 credential 即视为已配置)。
* 小组件只在这些服务间轮换展示;未配置的不占位。
* 注:只覆盖 [DISPLAY_SERVICES](轮换区),DeepSeek 单独用 [isCredentialPresent] 判断。
*/
fun configuredServices(context: Context): List<ServiceType> {
val repository = widgetCredentialRepository(context)
return runCatching {
runBlocking {
DISPLAY_SERVICES.filter { repository.get(it) != null }
}
}.getOrDefault(emptyList())
}
/** 指定服务本地是否已保存凭据(DeepSeek 余额行显隐用)。 */
fun isCredentialPresent(context: Context, service: ServiceType): Boolean {
val repository = widgetCredentialRepository(context)
return runCatching {
runBlocking { repository.get(service) != null }
}.getOrDefault(false)
}
private fun widgetCredentialRepository(context: Context): CredentialRepository {
val appContext = context.applicationContext
return CredentialRepository(
secureStorage = SecureStorage(appContext.secureStorageDataStore),
balanceCache = BalanceCache(appContext.balanceCacheDataStore)
)
}
/**
* 当前展示的服务:仅在已配置集合内取用户选择;选择失效(服务被删除)
* 时回退第一个已配置服务;一个都没配置返回 null(Widget 展示引导空态)。
*/
fun currentDisplayService(context: Context): ServiceType? =
currentDisplayService(context, configuredServices(context))
/** 同上,但复用已读好的 [configured],避免一次更新里多次 Keystore 解密。 */
fun currentDisplayService(context: Context, configured: List<ServiceType>): ServiceType? {
if (configured.isEmpty()) return null
val key = autoRefreshPrefs(context).getString(KEY_DISPLAY_SERVICE, null)
return configured.firstOrNull { it.storageKey == key } ?: configured.first()
}
/** 只在已配置服务间轮换。 */
private fun switchDisplayService(context: Context) {
val current = currentDisplayService(context)
val next = DISPLAY_SERVICES[(DISPLAY_SERVICES.indexOf(current).coerceAtLeast(0) + 1) % DISPLAY_SERVICES.size]
val configured = configuredServices(context)
if (configured.size <= 1) return
val current = currentDisplayService(context, configured)
val currentIndex = current?.let { configured.indexOf(it) } ?: -1
val next = configured[(currentIndex.coerceAtLeast(0) + 1) % configured.size]
autoRefreshPrefs(context).edit().putString(KEY_DISPLAY_SERVICE, next.storageKey).apply()
}
@ -359,6 +444,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
ServiceType.CODEX -> "Codex"
ServiceType.DEEPSEEK -> "DS"
ServiceType.OLLAMA -> "Ollama"
ServiceType.TRAE -> "Trae"
ServiceType.WORKBUDDY -> "WB"
ServiceType.SUB2API -> "S2API"
}
private fun widgetLogo(service: ServiceType): Int = when (service) {
@ -366,6 +454,10 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
ServiceType.CODEX -> R.drawable.ic_codex_logo_widget // PNG for RemoteViews compatibility
ServiceType.DEEPSEEK -> R.drawable.ic_deepseek_logo
ServiceType.OLLAMA -> R.drawable.ic_ollama_logo_widget
// 新服务暂不进桌面组件轮播(DISPLAY_SERVICES 未包含),这里用占位 logo 保证穷尽
ServiceType.TRAE,
ServiceType.WORKBUDDY,
ServiceType.SUB2API -> R.drawable.ic_deepseek_logo
}
/**

View File

@ -23,26 +23,30 @@ class WidgetRefreshReceiver : BroadcastReceiver() {
if (isRefreshing) return
isRefreshing = true
val pendingResult = goAsync()
val entryPoint = EntryPointAccessors.fromApplication(
appContext, WidgetRefreshEntryPoint::class.java
)
val useCase = entryPoint.refreshBalanceUseCase()
OpenCodeGoWidgetProvider.showRefreshing(appContext)
CoroutineScope(Dispatchers.IO).launch {
try {
// EntryPoint 获取也放进 try:任何异常都必须落到 finally,
// 否则 isRefreshing 卡死 true、pendingResult 泄漏
val entryPoint = EntryPointAccessors.fromApplication(
appContext, WidgetRefreshEntryPoint::class.java
)
val useCase = entryPoint.refreshBalanceUseCase()
val selectedService = OpenCodeGoWidgetProvider.currentDisplayService(appContext)
val refreshed = withTimeoutOrNull(25_000L) {
val selectedResult = useCase(selectedService)
val dsResult = useCase(ServiceType.DEEPSEEK)
selectedResult.isSuccess || dsResult.isSuccess
} == true
if (refreshed) {
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
val dsConfigured = OpenCodeGoWidgetProvider.isCredentialPresent(appContext, ServiceType.DEEPSEEK)
if (selectedService != null || dsConfigured) {
withTimeoutOrNull(25_000L) {
// 只刷新「当前展示的服务 + 已配置的 DeepSeek」;未配置的不发请求
selectedService?.let { useCase(it) }
if (dsConfigured) useCase(ServiceType.DEEPSEEK)
}
}
} catch (_: Exception) {
// 静默,Widget 保留旧数据
} finally {
// 无论成败都重渲染:清掉「刷新中」占位,零配置时也能立即恢复空态文案
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
isRefreshing = false
pendingResult.finish()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -3,8 +3,8 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="#2D1825" />
<solid android:color="#1A1417" />
<stroke
android:width="1dp"
android:color="#4D2D3D" />
android:color="#4C3B44" />
</shape>

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 雨晴Token 背景 · 樱粉渐变 -->
<!-- 雨晴Token 背景 · 精致粉调渐变 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
@ -12,10 +12,10 @@
android:startX="0" android:startY="0"
android:endX="108" android:endY="108"
android:type="linear">
<item android:offset="0" android:color="#FFF0F5" />
<item android:offset="0.5" android:color="#FFD1DC" />
<item android:offset="1" android:color="#FFB3C1" />
<item android:offset="0" android:color="#FFF9FB" />
<item android:offset="0.5" android:color="#FDEFF4" />
<item android:offset="1" android:color="#FBDCE8" />
</gradient>
</aapt:attr>
</path>
</vector>
</vector>

View File

@ -7,14 +7,14 @@
android:viewportHeight="108">
<!--
配色统一雨晴品牌系:
· 水滴:草莓粉 #FF85A2(主品牌色)
· sparkle:樱粉 #FFD1DC(浅层次)
配色统一雨晴品牌系(精致粉调):
· 水滴:深玫瑰 #C9436C(主品牌色)
· sparkle:浅玫瑰粉 #FFC9D8(浅层次)
· 高光:白色
· 背景:樱粉渐变 #FFF0F5→#FFD1DC→#FFB3C1
· 背景:粉白渐变 #FFF9FB→#FDEFF4→#FBDCE8
-->
<!-- 大水滴 · 草莓粉 -->
<!-- 大水滴 · 深玫瑰 -->
<path
android:pathData="
M54,30
@ -24,7 +24,7 @@
Q64,76 68,68
Q72,62 64,50
Q54,30 54,30Z"
android:fillColor="#FF85A2" />
android:fillColor="#C9436C" />
<!-- 水滴高光 · 白色椭圆 -->
<path
@ -32,24 +32,24 @@
android:fillColor="#FFFFFF"
android:fillAlpha="0.75" />
<!-- sparkle 小太阳 · 樱粉 · 右上角 (72,32) -->
<!-- sparkle 小太阳 · 浅玫瑰粉 · 右上角 (72,32) -->
<path android:pathData="M72,22L72,42"
android:strokeColor="#FFD1DC" android:strokeWidth="3"
android:strokeColor="#FFC9D8" android:strokeWidth="3"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent" />
<path android:pathData="M62,32L82,32"
android:strokeColor="#FFD1DC" android:strokeWidth="3"
android:strokeColor="#FFC9D8" android:strokeWidth="3"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent" />
<path android:pathData="M66,26L78,38"
android:strokeColor="#FFD1DC" android:strokeWidth="2"
android:strokeColor="#FFC9D8" android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent" />
<path android:pathData="M78,26L66,38"
android:strokeColor="#FFD1DC" android:strokeWidth="2"
android:strokeColor="#FFC9D8" android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent" />
<path android:pathData="M72,32m-2.5,0a2.5,2.5 0,1,1 5,0a2.5,2.5 0,1,1 -5,0"
android:fillColor="#FFD1DC" />
android:fillColor="#FFC9D8" />
</vector>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
RainyToken 几何字标 · 与 OpenCode Go logo 完全同构
同样的两个 24x30 S 形方块拼接,只是配色换成雨晴粉色系:
- Go 深色 #211E1E → 粉色 #C2185B
- Go 浅色 #CFCECD → 浅粉 #FFD1DC
同样的两个 24x30 S 形方块拼接,只是配色换成雨晴精致粉系:
- Go 深色 #211E1E → 深玫瑰 #C9436C
- Go 浅色 #CFCECD → 浅玫瑰粉 #FFC9D8
viewBox 和 path 与 ic_opencode_go_logo 完全一致
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
@ -11,20 +11,20 @@
android:height="30dp"
android:viewportWidth="54"
android:viewportHeight="30">
<!-- 左块(深粉) -->
<!-- 左块(深玫瑰) -->
<path
android:fillColor="#C2185B"
android:fillColor="#C9436C"
android:pathData="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z"/>
<!-- 左块 S 内弯(浅粉) -->
<path
android:fillColor="#FFD1DC"
android:fillColor="#FFC9D8"
android:pathData="M12 18H18V24H6V12H12V18Z"/>
<!-- 右块 S 内弯(浅粉) -->
<path
android:fillColor="#FFD1DC"
android:fillColor="#FFC9D8"
android:pathData="M48 12V24H36V12H48Z"/>
<!-- 右块(深粉) -->
<!-- 右块(深玫瑰) -->
<path
android:fillColor="#C2185B"
android:fillColor="#C9436C"
android:pathData="M54 30H30V0H54V30ZM36 24H48V6H36V24Z"/>
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<!-- 圆角渐变底(源自官网 SVG paint0_linear:#0EC8A9 → #01C886) -->
<path android:pathData="M20,0C31.046,0 40,8.954 40,20C40,31.046 31.046,40 20,40C8.954,40 0,31.046 0,20C0,8.954 8.954,0 20,0Z">
<aapt:attr name="android:fillColor">
<gradient
android:startColor="#0EC8A9"
android:endColor="#01C886"
android:startX="20"
android:startY="0"
android:endX="20"
android:endY="40" />
</aapt:attr>
</path>
<!-- 主体图形(官网 SVG 白色路径,去除高斯模糊滤镜后保留轮廓) -->
<path
android:pathData="M28.5931,3.12762C28.9853,2.77585 29.0091,2.76226 29.2968,2.74499C29.7628,2.71096 30.1894,2.93488 30.916,3.59634C32.6132,5.13875 34.9769,8.30904 36.4462,11.0168L37.0138,12.0682L37.8156,12.4668C38.5896,12.8579 39.8593,13.6593 40.3898,14.0895C40.6297,14.2878 40.6638,14.2925 40.9133,14.1954C42.0388,13.7572 43.6506,14.3382 45.0727,15.7024C46.3529,16.9294 47.5794,19.026 48.0491,20.7757C48.1177,21.0574 48.2087,21.6628 48.2419,22.1134C48.349,23.6964 47.8414,24.9608 46.8637,25.5331C46.664,25.6484 46.6505,25.6795 46.6561,26.1774C46.7011,28.5481 46.0621,30.9144 44.7785,33.2221C43.3293,35.8133 40.7489,38.4945 37.2566,41.0199C35.3813,42.3846 30.9445,44.9701 28.9382,45.8778C24.1324,48.0414 20.2794,48.8709 16.9329,48.4609C14.9368,48.219 12.6769,47.44 11.34,46.5355C10.9885,46.2925 10.9325,46.2774 10.6637,46.3543C9.2327,46.7651 7.35867,45.9207 5.76659,44.1535C5.13165,43.4471 4.1065,41.7127 3.77404,40.7843C3.0054,38.6118 3.15852,36.6506 4.18236,35.4799C4.44671,35.1785 4.45511,35.1658 4.39735,34.6589C4.30195,33.8289 4.25837,32.6008 4.30192,31.808L4.33666,31.0674L3.22502,29.101C1.5033,26.0375 0.409325,23.4645 -0.012563,21.4992C-0.235219,20.4218 -0.221193,19.9436 0.0522186,19.5899C0.21871,19.3763 0.764928,19.1545 1.42322,19.0329C3.08041,18.742 6.69466,19.0056 10.7155,19.7155L11.1329,19.788L12.0511,18.976C13.5747,17.6264 14.587,16.8696 16.4531,15.706C18.3981,14.4891 20.5929,13.4877 23.0648,12.695L23.8577,12.4414L24.2939,11.2964C25.8547,7.17615 27.4533,4.13844 28.5931,3.12762ZM15.5182,24.243C13.7542,25.2615 12.8718,25.7706 12.2236,26.3413C9.59893,28.6526 8.61811,32.3134 9.73545,35.6274C10.0114,36.4457 10.5201,37.3283 11.5386,39.0923C12.5571,40.8564 13.0671,41.7383 13.6378,42.3864C15.9491,45.0112 19.6103,45.9929 22.9243,44.8755C23.7426,44.5995 24.6249,44.0899 26.3888,43.0714L36.5375,37.2121C38.3016,36.1936 39.184,35.6845 39.8321,35.1137C42.4568,32.8024 43.4373,29.1409 42.3198,25.8269C42.0438,25.0086 41.5342,24.1264 40.5158,22.3624C39.4973,20.5983 38.9882,19.7159 38.4175,19.0678C36.8263,17.2506 35.0162,16.0207 33.2109,15.4111C32.0906,15.0345 31.0169,14.8711 29.9888,14.9141C29.9121,14.9172 29.8379,14.9248 29.7661,14.9368C29.6944,14.9489 29.6241,14.963 29.5553,14.9785L29.5553,14.9785C28.5066,15.2051 27.5082,15.6179 26.5601,16.2168C25.612,16.8157 24.7347,17.5885 23.9283,18.5352C23.1219,19.4819 22.4032,20.5843 21.7722,21.8423C21.1412,23.1003 20.6135,24.5007 20.189,26.0436C19.7645,27.5865 19.4573,29.2539 19.2674,31.0458C19.0775,32.8377 19.0159,34.7359 19.0826,36.7404C19.1493,38.7449 19.3424,40.8284 19.6619,42.991C19.6698,42.9989 19.6777,43.0068 19.6856,43.0147C19.6935,43.0226 19.7014,43.0305 19.7093,43.0384L19.7093,43.0384C20.758,42.8118 21.7564,42.399 22.7045,41.8001C23.6526,41.2012 24.5299,40.4284 25.3363,39.4817C26.1427,38.535 26.8614,37.4326 27.4924,36.1746C28.1234,34.9166 28.6511,33.5162 29.0756,31.9733C29.5001,30.4304 29.8073,28.763 29.9972,26.9711C30.1871,25.1792 30.2487,23.281 30.182,21.2765C30.1153,19.272 29.9222,17.1885 29.6027,15.0259L29.5948,15.018C29.5869,15.0101 29.579,15.0022 29.5711,14.9943C28.5224,15.2209 27.524,15.6337 26.5759,16.2326C25.6278,16.8315 24.7505,17.6043 23.9441,18.551C23.1377,19.4977 22.419,20.6001 21.788,21.8581C21.157,23.1161 20.6293,24.5165 20.2048,26.0594C19.7803,27.6023 19.4731,29.2697 19.2832,31.0616C19.0933,32.8535 19.0317,34.7517 19.0984,36.7562C19.1651,38.7607 19.3582,40.8442 19.6777,43.0068L19.6777,43.0068Z"
android:fillColor="#FFFFFF" />
<!-- 白色小装饰条(官网 SVG 两个 rect) -->
<path
android:pathData="M18.5,32.9L16.5,31.3C15.9,32.4 16.2,33.8 17.3,34.4L19.3,36C19.9,34.9 19.6,33.5 18.5,32.9Z"
android:fillColor="#FFFFFF" />
<path
android:pathData="M29.3,26.7L27.3,25.1C26.7,26.2 27,27.6 28.1,28.2L30.1,29.8C30.7,28.7 30.4,27.3 29.3,26.7Z"
android:fillColor="#FFFFFF" />
</vector>

View File

@ -2,8 +2,8 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="16dp" />
<solid android:color="#FFF0F5" />
<solid android:color="#FFF9FB" />
<stroke
android:width="1dp"
android:color="#FFD1DC" />
android:color="#F2DCE4" />
</shape>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MIUI Widget 深色模式布局 — 文字颜色适配暗色背景 #2D1825 -->
<!-- MIUI Widget 深色模式布局 — 文字颜色适配暗色背景 #1A1417 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/background"
android:layout_width="match_parent"
@ -35,7 +35,7 @@
android:layout_width="10dp"
android:layout_height="24dp"
android:text=">"
android:textColor="#8A7A82"
android:textColor="#CBB8C0"
android:textSize="14sp"
android:gravity="center" />
@ -44,7 +44,7 @@
android:layout_width="48dp"
android:layout_height="24dp"
android:text="OCGO"
android:textColor="#FF85A2"
android:textColor="#FFA9BF"
android:textSize="10sp"
android:textStyle="bold"
android:layout_marginStart="6dp"
@ -75,7 +75,7 @@
android:layout_width="40dp"
android:layout_height="24dp"
android:text="↻"
android:textColor="#FF85A2"
android:textColor="#FFA9BF"
android:textSize="20sp"
android:layout_marginStart="6dp"
android:gravity="center" />
@ -99,7 +99,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_title_opencode"
android:textColor="#C4A0AB"
android:textColor="#CBB8C0"
android:textSize="11sp" />
</LinearLayout>
@ -113,10 +113,10 @@
<TextView android:id="@+id/row1_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#C4A0AB" android:textSize="11sp" />
android:text="5h" android:textColor="#CBB8C0" android:textSize="11sp" />
<TextView android:id="@+id/row1_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row1_bar"
style="@android:style/Widget.ProgressBar.Horizontal"
@ -126,7 +126,7 @@
android:progressTint="@color/widget_bar_5h" />
<TextView android:id="@+id/row1_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" />
android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
</LinearLayout>
<!-- 本周 -->
@ -139,10 +139,10 @@
<TextView android:id="@+id/row2_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_weekly" android:textColor="#C4A0AB" android:textSize="11sp" />
android:text="@string/window_weekly" android:textColor="#CBB8C0" android:textSize="11sp" />
<TextView android:id="@+id/row2_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row2_bar"
style="@android:style/Widget.ProgressBar.Horizontal"
@ -152,7 +152,7 @@
android:progressTint="@color/widget_bar_weekly" />
<TextView android:id="@+id/row2_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" />
android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
</LinearLayout>
<!-- 本月 -->
@ -165,10 +165,10 @@
<TextView android:id="@+id/row3_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_monthly" android:textColor="#C4A0AB" android:textSize="11sp" />
android:text="@string/window_monthly" android:textColor="#CBB8C0" android:textSize="11sp" />
<TextView android:id="@+id/row3_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row3_bar"
style="@android:style/Widget.ProgressBar.Horizontal"
@ -178,11 +178,12 @@
android:progressTint="@color/widget_bar_monthly" />
<TextView android:id="@+id/row3_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" />
android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
</LinearLayout>
<!-- DeepSeek 余额 -->
<!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) -->
<LinearLayout
android:id="@+id/widget_ds_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
@ -199,14 +200,14 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance"
android:textColor="#C4A0AB"
android:textColor="#CBB8C0"
android:textSize="11sp" />
<TextView
android:id="@+id/widget_ds_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--"
android:textColor="#FFD1DC"
android:textColor="#FFC9D8"
android:textSize="11sp"
android:textStyle="bold"
android:layout_marginStart="4dp" />
@ -232,7 +233,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_brand"
android:textColor="#8A7A82"
android:textColor="#CBB8C0"
android:textSize="9sp" />
</LinearLayout>

View File

@ -36,7 +36,7 @@
android:layout_width="10dp"
android:layout_height="24dp"
android:text=">"
android:textColor="#C4A0AB"
android:textColor="#7A6870"
android:textSize="14sp"
android:gravity="center" />
@ -45,7 +45,7 @@
android:layout_width="48dp"
android:layout_height="24dp"
android:text="OCGO"
android:textColor="#FF85A2"
android:textColor="#C9436C"
android:textSize="10sp"
android:textStyle="bold"
android:layout_marginStart="6dp"
@ -69,7 +69,7 @@
android:maxLines="1"
android:ellipsize="end"
android:text="--"
android:textColor="#C4A0AB"
android:textColor="#7A6870"
android:textSize="10sp" />
<TextView
@ -77,7 +77,7 @@
android:layout_width="40dp"
android:layout_height="24dp"
android:text="↻"
android:textColor="#FF85A2"
android:textColor="#C9436C"
android:textSize="20sp"
android:layout_marginStart="6dp"
android:gravity="center" />
@ -105,7 +105,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_title_opencode"
android:textColor="#8A7A82"
android:textColor="#7A6870"
android:textSize="11sp" />
</LinearLayout>
@ -120,11 +120,11 @@
<TextView android:id="@+id/row1_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="5h" android:textColor="#7A6870" android:textSize="11sp" />
<TextView android:id="@+id/row1_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#3D2C35" android:textSize="12sp"
android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row1_bar"
@ -136,7 +136,7 @@
<TextView android:id="@+id/row1_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" />
android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
</LinearLayout>
<!-- 本周 -->
@ -150,11 +150,11 @@
<TextView android:id="@+id/row2_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView android:id="@+id/row2_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#3D2C35" android:textSize="12sp"
android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row2_bar"
@ -166,7 +166,7 @@
<TextView android:id="@+id/row2_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" />
android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
</LinearLayout>
<!-- 本月 -->
@ -180,11 +180,11 @@
<TextView android:id="@+id/row3_label"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView android:id="@+id/row3_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#3D2C35" android:textSize="12sp"
android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar android:id="@+id/row3_bar"
@ -196,11 +196,12 @@
<TextView android:id="@+id/row3_reset"
android:layout_width="48dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" />
android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
</LinearLayout>
<!-- DeepSeek 余额 -->
<!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) -->
<LinearLayout
android:id="@+id/widget_ds_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
@ -220,7 +221,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance"
android:textColor="#8A7A82"
android:textColor="#7A6870"
android:textSize="11sp" />
<TextView
@ -228,7 +229,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="--"
android:textColor="#3D2C35"
android:textColor="#3A2B31"
android:textSize="11sp"
android:textStyle="bold"
android:layout_marginStart="4dp" />
@ -257,7 +258,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_brand"
android:textColor="#D4C4CC"
android:textColor="#7A6870"
android:textSize="9sp" />
</LinearLayout>

View File

@ -42,7 +42,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_auto_refresh"
android:textColor="#C4A0AB"
android:textColor="#7A6870"
android:textSize="10sp" />
</LinearLayout>
@ -64,7 +64,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_title_opencode"
android:textColor="#8A7A82"
android:textColor="#7A6870"
android:textSize="11sp" />
</LinearLayout>
@ -79,11 +79,11 @@
<TextView
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="5h" android:textColor="#7A6870" android:textSize="11sp" />
<TextView
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="42%" android:textColor="#3D2C35" android:textSize="12sp"
android:text="42%" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar
@ -91,7 +91,7 @@
android:layout_width="0dp" android:layout_height="6dp"
android:layout_weight="1" android:layout_marginStart="8dp"
android:layout_marginEnd="8dp" android:max="100" android:progress="42"
android:progressTint="#FF85A2" />
android:progressTint="#C9436C" />
</LinearLayout>
<!-- 本周 -->
@ -105,11 +105,11 @@
<TextView
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="68%" android:textColor="#3D2C35" android:textSize="12sp"
android:text="68%" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar
@ -131,11 +131,11 @@
<TextView
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
<TextView
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="35%" android:textColor="#3D2C35" android:textSize="12sp"
android:text="35%" android:textColor="#3A2B31" android:textSize="12sp"
android:textStyle="bold" android:gravity="end" />
<ProgressBar
@ -143,7 +143,7 @@
android:layout_width="0dp" android:layout_height="6dp"
android:layout_weight="1" android:layout_marginStart="8dp"
android:layout_marginEnd="8dp" android:max="100" android:progress="35"
android:progressTint="#FF85A2" />
android:progressTint="#C9436C" />
</LinearLayout>
<!-- DeepSeek 余额 -->
@ -165,14 +165,14 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_deepseek_balance"
android:textColor="#8A7A82"
android:textColor="#7A6870"
android:textSize="11sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="¥18.00"
android:textColor="#3D2C35"
android:textColor="#3A2B31"
android:textSize="11sp"
android:textStyle="bold"
android:layout_marginStart="4dp" />

View File

@ -18,6 +18,7 @@
<string name="action_delete">删除</string>
<string name="action_clear">清除</string>
<string name="action_save">保存</string>
<string name="action_login_in_app">软件内登录</string>
<string name="action_jump">跳转</string>
<string name="action_prev_page">◀ 上一页</string>
<string name="action_next_page">下一页 ▶</string>
@ -44,6 +45,27 @@
<string name="nav_empty_title">👈 选择服务查看详情</string>
<string name="nav_empty_subtitle">点击左侧卡片即可</string>
<string name="title_settings">设置</string>
<string name="api_mgmt_title">API 管理</string>
<string name="api_mgmt_test">测试</string>
<string name="api_mgmt_add">添加 API Key</string>
<string name="api_mgmt_add_picker_title">添加服务</string>
<string name="api_mgmt_all_added">所有服务都已添加</string>
<string name="api_mgmt_empty_title">还没有添加任何 API</string>
<string name="api_mgmt_empty_body">点击右上角「+」添加一个 API Key</string>
<string name="api_mgmt_delete_title">删除凭据?</string>
<string name="api_mgmt_delete_body">将删除 %1$s 已保存的凭据。</string>
<string name="api_mgmt_unconfigured">未配置</string>
<string name="api_mgmt_accounts_subtitle">%1$d 个账号 · 点按管理</string>
<string name="api_mgmt_last_verified">最后验证:%1$s</string>
<string name="api_mgmt_cached_balance">缓存余额:%1$s %2$s</string>
<string name="api_mgmt_test_success">✅ %1$s 连接成功:%2$s %3$s</string>
<string name="api_mgmt_test_fail">❌ %1$s 连接失败:%2$s</string>
<string name="api_mgmt_err_invalid">凭据无效</string>
<string name="api_mgmt_err_changed">凭据已变更</string>
<string name="api_mgmt_err_ratelimited">请求过于频繁</string>
<string name="api_mgmt_err_network">网络异常</string>
<string name="api_mgmt_err_server">服务端异常(HTTP %1$d)</string>
<string name="api_mgmt_err_parse">解析失败</string>
<string name="title_credentials">凭据管理</string>
<string name="title_about">关于 RainyToken</string>
<string name="title_tips">使用小技巧</string>
@ -77,6 +99,9 @@
<!-- ══════════ 仪表盘 ══════════ -->
<string name="dashboard_subtitle">AI 余额一览</string>
<string name="dashboard_empty_title">还没有配置任何服务</string>
<string name="dashboard_empty_subtitle">首页仅显示已配置凭据的服务</string>
<string name="dashboard_empty_go_settings">去设置添加</string>
<string name="footer_never_fetched">从未获取</string>
<string name="footer_just_updated">刚刚更新</string>
<string name="footer_minutes_ago">%1$d 分钟前更新</string>
@ -108,6 +133,120 @@
<string name="service_desc_commandcode_go">JSON API · $</string>
<string name="service_desc_codex">ChatGPT · Codex 额度</string>
<string name="service_desc_ollama">Cookie 抓取 · Cloud 配额</string>
<string name="service_desc_trae">Cloud-IDE-JWT · IDE 额度</string>
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同体系积分</string>
<string name="service_desc_sub2api">自托管面板 · 用户级 sk- Key 余额</string>
<string name="detail_extras_title">明细</string>
<string name="detail_account_info_title">账户信息</string>
<string name="detail_resources_title">资源明细</string>
<string name="wb_resource_label">资源 #%1$s</string>
<string name="wb_plan_title">套餐积分</string>
<string name="wb_plan_used_total">已用/总量</string>
<string name="wb_plan_in_use">使用中</string>
<string name="wb_plan_next_reset">下次权益周期更新</string>
<string name="wb_reward_title">平台奖励积分</string>
<string name="wb_reward_expire">到期:%1$s</string>
<string name="wb_reward_item">奖励包 %1$d</string>
<string name="extra_label_remaining">剩余</string>
<string name="extra_label_billing_mode">计费方式</string>
<string name="extra_value_billing_credits">积分</string>
<string name="extra_value_billing_dollar">美元</string>
<string name="extra_label_billing_mode_desc">%1$s计费</string>
<string name="extra_value_subscription_active">订阅中</string>
<string name="extra_value_paid_user">付费用户</string>
<string name="extra_value_free_user">免费用户</string>
<string name="extra_label_consumed_credits">已消耗</string>
<string name="extra_label_consumption_ratio">消耗比例</string>
<string name="extra_label_total_credits">总积分</string>
<string name="extra_label_pack_count">权益包</string>
<string name="extra_label_packages">套餐数</string>
<string name="extra_label_frozen">冻结</string>
<string name="extra_label_is_credits_billing">积分计费</string>
<string name="extra_label_is_dollar_usage_billing">美元计费</string>
<string name="extra_label_is_new_user">新用户</string>
<string name="extra_label_is_paid_user">付费用户</string>
<string name="extra_label_subscription">订阅</string>
<string name="extra_value_yes">是</string>
<string name="extra_value_no">否</string>
<string name="credential_title_trae">Trae 凭据</string>
<string name="credential_hint_trae">粘贴 Trae 登录态中的 Cloud-IDE-JWT(ey… 开头)。请求头按 Authorization: Cloud-IDE-JWT 发送。</string>
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
<string name="field_trae_region">区域</string>
<string name="field_trae_checkin_device_id">签到设备 ID(可选)</string>
<string name="field_trae_checkin_device_hint">签到须使用官方客户端绑定的设备 ID。若持续失败,请在电脑客户端日志(AppData\\Roaming\\Trae CN\\logs\\日期目录\\main.log)中搜索 did=,将最新的值粘贴到此处。留空则使用内置默认。</string>
<string name="field_trae_device_id_howto">设备 ID 怎么找?(分步指引)</string>
<string name="msg_path_copied">路径已复制,粘贴到 Win+R 或文件资源管理器即可</string>
<string name="field_trae_device_id_howto_title">查找设备 ID(4 步)</string>
<string name="field_trae_device_id_howto_step1">1. 在电脑上按 Win+R,粘贴下面的路径后回车(或复制后粘贴到文件资源管理器地址栏):</string>
<string name="field_trae_device_id_howto_step2">2. 打开文件夹名里日期最新的那个文件夹。</string>
<string name="field_trae_device_id_howto_step3">3. 用记事本打开 main.log。</string>
<string name="field_trae_device_id_howto_step4">4. 按 Ctrl+F 搜索 did=,后面的数字(如 did=895482260673817)就是设备 ID,复制并粘贴到上面的输入框。</string>
<string name="option_region_cn">CN</string>
<string name="option_region_intl">INTL</string>
<string name="credential_title_workbuddy">WorkBuddy 凭据</string>
<string name="credential_hint_workbuddy">粘贴腾讯 WorkBuddy(CodeBuddy 同体系)会话的 access &amp; refresh token。refresh token 每次使用会轮换。</string>
<string name="field_workbuddy_access">Access Token</string>
<string name="field_workbuddy_refresh">Refresh Token</string>
<string name="error_workbuddy_tokens_required">Access 与 Refresh Token 必填</string>
<string name="credential_title_sub2api">Sub2API 实例</string>
<string name="credential_hint_sub2api">该实例的任何用户:直接填分给你的 sk-… API Key 即可查余额(无需管理员)。没有 Key 也可用面板账号邮箱+密码登录。</string>
<string name="field_sub2_base_url">实例基址</string>
<string name="placeholder_sub2_base_url">https://api.example.com</string>
<string name="sub2_auth_header">认证方式</string>
<string name="field_sub2_api_key">API Key(sk-…)</string>
<string name="sub2_or_account">或面板账号</string>
<string name="field_sub2_email">邮箱</string>
<string name="field_sub2_password">密码</string>
<string name="error_sub2_required">实例基址与(API Key 或 邮箱+密码)必填</string>
<!-- ══════════ Sub2API 用量明细 ══════════ -->
<string name="sub2_usage_title">用量明细</string>
<string name="sub2_usage_subtitle">来自 Sub2API 网关的每日与分模型用量</string>
<string name="sub2_window_today">今天</string>
<string name="sub2_window_7d">近7天</string>
<string name="sub2_window_30d">近30天</string>
<string name="sub2_window_90d">近90天</string>
<string name="sub2_window_all">全部</string>
<string name="sub2_stat_requests">请求</string>
<string name="sub2_stat_tokens">Tokens</string>
<string name="sub2_stat_cost">花费</string>
<string name="sub2_daily_header">每日用量</string>
<string name="sub2_models_header">按模型</string>
<string name="sub2_model_all">全部模型</string>
<string name="sub2_model_meta">%1$s 次请求 · %2$s tokens</string>
<string name="sub2_no_daily">该时间范围内暂无每日用量数据</string>
<string name="sub2_no_cost">该时间范围内无花费</string>
<string name="sub2_model_sel_summary">模型:%1$s · 当前周期累计</string>
<string name="sub2_no_model_data">该周期暂无模型数据</string>
<!-- ══════════ 每日签到(WorkBuddy / Trae) ══════════ -->
<string name="checkin_title">每日签到</string>
<string name="checkin_subtitle">每天签到领积分,连签有奖励</string>
<string name="checkin_action">签到</string>
<string name="checkin_loading">签到中…</string>
<string name="checkin_success">签到成功</string>
<string name="checkin_already">今日已签到</string>
<string name="checkin_already_streak">今日已签到(连签 %1$d 天)</string>
<string name="checkin_failed">签到失败,请检查凭据</string>
<string name="checkin_unsupported">该服务不支持签到</string>
<string name="checkin_action_done">今日已签到</string>
<string name="checkin_need_credential">请先添加凭据后再签到。</string>
<string name="checkin_trae_intl">INTL 区签到接口暂未支持,请用 CN 区账号</string>
<string name="checkin_trae_device_blocked">该设备今日签到次数已达上限</string>
<string name="checkin_trae_retry_later">参与用户太多,请稍后重试</string>
<string name="checkin_trae_device_hint">签到须使用官方客户端绑定的设备 ID。若持续失败,请在电脑上打开 Trae 客户端日志目录(AppData\\Roaming\\Trae CN\\logs\\日期目录\\main.log),搜索 did= 获取当前绑定的设备 ID,然后粘贴到「设置 → Trae → 签到设备 ID」。</string>
<string name="checkin_trae_failed_code">签到失败(code=%1$d)</string>
<string name="checkin_wb_success_credits">签到成功 +%1$d 积分</string>
<string name="trae_packs_title">积分权益包</string>
<string name="trae_pack_unlimited">无限额度</string>
<string name="trae_general_credits_title">通用积分</string>
<string name="trae_work_credits_title">Work 专属积分</string>
<string name="trae_pack_remaining_fmt">剩余 %1$s/%2$s 分 · 已用 %3$s</string>
<!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ -->
<string name="sub2_panel_login_hint">在打开的页面中正常登录,App 自动读取登录态;也可继续手动填 API Key</string>
<string name="sub2_panel_login_done">已获取面板 Token,点击保存生效</string>
<string name="sub2_panel_login_done_short">✓ 已获取面板 Token</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">天</string>
@ -209,7 +348,7 @@
<string name="help_ocgo_7">6. 找到域名 opencode.ai 下名为 auth 的 Cookie</string>
<string name="help_ocgo_8">7. 复制它的 Value 字段 → auth cookie</string>
<string name="help_ocgo_note">注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串</string>
<string name="help_ccgo_title">如何获取 CommandCode Go 凭据?</string>
<string name="help_ccgo_title">如何获取 CommandCode 凭据?</string>
<string name="help_ccgo_1">1. 打开 https://commandcode.ai 并登录 GitHub</string>
<string name="help_ccgo_2">▸ 获取 API Key(必填):</string>
<string name="help_ccgo_3"> 右上角头像 → Settings → API Keys → 创建一个 Key</string>
@ -244,6 +383,34 @@
<string name="error_credential_not_configured">凭据未配置</string>
<string name="error_credential_not_configured_expired">凭据未配置或已过期</string>
<string name="error_credential_invalid_reconfigure">凭据无效,请在设置中重新配置</string>
<string name="error_credential_session_expired">登录已过期,请重新登录</string>
<string name="auto_checkin_switch_label">自动签到</string>
<string name="checkin_badge_today">今日已签 ✓</string>
<string name="auto_checkin_dialog_title">每日自动签到</string>
<string name="auto_checkin_line_gained">%1$s 已签到,积分增加 %2$d</string>
<string name="auto_checkin_line_already">%1$s 今日已签到</string>
<string name="auto_checkin_line_failed">%1$s 签到失败:%2$s</string>
<string name="auto_checkin_line_failed_generic">%1$s 签到失败,请手动签到</string>
<string name="account_manager_title">%1$s · 账号管理</string>
<string name="account_add">添加账号</string>
<string name="account_actions">账号操作</string>
<string name="account_default_badge">默认</string>
<string name="account_fallback_name">账号 %1$d</string>
<string name="account_set_default">设为默认</string>
<string name="account_rename">重命名</string>
<string name="account_rename_dialog_title">重命名账号</string>
<string name="account_name_label">账号名称</string>
<string name="account_delete_title">删除账号</string>
<string name="account_delete_confirm">确定删除该账号?该账号的凭据与余额缓存将一并清除。</string>
<string name="account_empty_hint">还没有账号,点下方按钮添加一个。</string>
<string name="account_count_line">%1$s · %2$d 个账号</string>
<string name="account_new_credential_title">%1$s · 新账号</string>
<string name="service_detail_title_with_account">%1$s · %2$s</string>
<string name="account_chip_default">%1$s(默认)</string>
<string name="msg_account_default_set">已设为默认账号</string>
<string name="msg_account_renamed">已重命名</string>
<string name="msg_account_deleted">账号已删除</string>
<string name="action_delete_confirmed">删除</string>
<string name="error_credential_invalid_relogin">凭据无效,请重新登录</string>
<string name="error_credential_changed_reload">凭据已变更,正在重新加载</string>
<string name="error_credential_changed_retry">凭据已变更,请重新操作</string>
@ -256,6 +423,7 @@
<string name="error_select_model_first">请先选择模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
<string name="error_login_url_missing">未配置登录 URL</string>
<string name="error_login_timeout">登录超时,请重试</string>
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
<string name="error_oauth_auth_failed">授权失败: %1$s</string>
<string name="error_oauth_state_mismatch">State 不匹配,请重试</string>
@ -294,6 +462,7 @@
<string name="widget_quota_with_plan">%1$s · %2$s</string>
<string name="widget_updated_at">更新 %1$s</string>
<string name="widget_no_data">暂无数据</string>
<string name="widget_not_configured">还没有配置任何服务</string>
<string name="widget_refreshing">刷新中…</string>
<!-- ══════════ 小技巧(AppTips) ══════════ -->
@ -341,6 +510,7 @@
<string name="count_times">%1$d次</string>
<string name="date_select_day">选择日期</string>
<string name="date_select_month">选择月份(任意一天)</string>
<string name="date_pick_range">日期范围</string>
<string name="date_pick_start">开始日期</string>
<string name="date_pick_end">结束日期</string>
<string name="chart_detail_cost">消费明细</string>
@ -407,7 +577,7 @@
<string name="action_how_get_two_values">如何获取这两个值?</string>
<string name="action_open_login_entry">在浏览器中打开登录入口</string>
<string name="hint_opencode_trigger_key">填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。</string>
<string name="credential_title_ccgo">CommandCode Go 凭据</string>
<string name="credential_title_ccgo">CommandCode 凭据</string>
<string name="credential_hint_ccgo">需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。</string>
<string name="placeholder_api_key_from_settings">从 Settings → API Keys 生成</string>
<string name="field_session_cookie_optional">Session Cookie(可选)</string>
@ -454,6 +624,8 @@
<string name="action_refresh_balance">刷新余额</string>
<string name="action_relogin">重新登录</string>
<string name="action_configure_credential">配置凭据</string>
<string name="action_recharge">充值</string>
<string name="action_subscribe_recharge">充值 / 订阅</string>
<string name="main_card_current_balance">当前余额</string>
<string name="main_card_5h_usage">5h 实时用量</string>
<string name="main_card_monthly_balance">月度余额</string>
@ -554,4 +726,62 @@
<string name="month_12">十二月</string>
<string name="field_api_key">API Key</string>
<string name="field_workspace_id">Workspace ID</string>
<string name="sub2_site_probing">正在识别站点…</string>
<string name="sub2_mode_api_key_desc">API Key:仅统计该 Key 的用量</string>
<string name="sub2_mode_panel_desc">面板登录:全账号消费统计,并解锁 Key 管理/分组/兑换</string>
<string name="sub2_panel_oneclick">账号一键登录(推荐)</string>
<string name="sub2_panel_logining">正在登录…</string>
<string name="sub2_panel_login">网页登录(备用,如开了二次验证)</string>
<string name="sub2_mode_account">账号密码</string>
<string name="sub2_mode_apikey">API Key</string>
<string name="sub2_field_totp">二次验证码(选填)</string>
<string name="sub2_home_no_usage">暂无用量数据,下拉刷新</string>
<string name="sub2_panel_manage_title">面板管理</string>
<string name="sub2_panel_load_keys">加载 API Key 列表</string>
<string name="sub2_panel_no_keys">暂无 API Key</string>
<string name="sub2_panel_create">新建 Key</string>
<string name="sub2_panel_redeem">兑换卡密</string>
<string name="sub2_panel_create_title">新建 API Key</string>
<string name="sub2_panel_key_name">Key 名称</string>
<string name="sub2_panel_group_optional">分组(可选,不选为默认)</string>
<string name="sub2_panel_no_groups">暂无可用分组</string>
<string name="sub2_panel_create_success_title">✓ 新建 Key 成功</string>
<string name="sub2_panel_create_success_hint">以下 Key 仅显示这一次,请立即复制保存:</string>
<string name="sub2_panel_copy_and_close">复制并关闭</string>
<string name="sub2_panel_redeem_code_label">兑换码</string>
<string name="sub2_panel_switch_group">切换分组</string>
<string name="sub2_panel_group_column">分组</string>
<string name="sub2_panel_default_group">默认分组</string>
<string name="sub2_panel_never_used">未使用</string>
<string name="sub2_panel_last_used">最近 %1$s</string>
<string name="sub2_panel_key_id">%1$s(ID %2$d)</string>
<string name="sub2_panel_copied">API Key 已复制</string>
<string name="sub2_panel_low_balance">⚠ 余额不足:剩余 $%1$s,请及时充值或兑换卡密</string>
<string name="sub2_models_alltime_suffix">(累计口径,未按所选周期过滤)</string>
<string name="sub2_panel_keys_header">API Key 列表</string>
<string name="sub2_auth_panel">邮箱登录</string>
<string name="sub2_auth_apikey">API 登录</string>
<string name="sub2_home_requests">消耗 %1$d 次</string>
<string name="sub2_balance_label">余额</string>
<string name="sub2_tab_by_model">按模型</string>
<string name="sub2_tab_by_key">按 Key</string>
<string name="sub2_no_key_data">该周期内没有 API Key 用量</string>
<string name="sub2_key_unused">本周期未使用</string>
<string name="sub2_home_spend">消耗 $%1$.2f</string>
<string name="sub2_panel_delete_confirm_title">删除 API Key</string>
<string name="sub2_panel_delete_confirm_body">确定删除「%1$s」吗?删除后无法恢复,正在使用该 Key 的客户端会立即失效。</string>
<string name="sub2_trend_account_scope">此图为账号整体趋势(无单模型维度)</string>
<string name="title_update">检查更新</string>
<string name="update_current_version">当前版本 %1$s(%2$d)</string>
<string name="update_check">检查更新</string>
<string name="update_checking">正在检查…</string>
<string name="update_latest">已是最新版本</string>
<string name="update_available">发现新版本 %1$s</string>
<string name="update_download">下载并安装</string>
<string name="update_downloading">正在下载 %1$d%%</string>
<string name="update_download_finished">下载完成</string>
<string name="update_install">安装</string>
<string name="update_install_permission_tip">需要开启「安装未知应用」权限</string>
<string name="update_enable_permission">去开启</string>
<string name="update_failed">更新失败:%1$s</string>
</resources>

View File

@ -18,6 +18,7 @@
<string name="action_delete">刪除</string>
<string name="action_clear">清除</string>
<string name="action_save">儲存</string>
<string name="action_login_in_app">軟體內登入</string>
<string name="action_jump">跳轉</string>
<string name="action_prev_page">◀ 上一頁</string>
<string name="action_next_page">下一頁 ▶</string>
@ -44,6 +45,27 @@
<string name="nav_empty_title">👈 選擇服務檢視詳情</string>
<string name="nav_empty_subtitle">點選左側卡片即可</string>
<string name="title_settings">設定</string>
<string name="api_mgmt_title">API 管理</string>
<string name="api_mgmt_test">測試</string>
<string name="api_mgmt_add">新增 API Key</string>
<string name="api_mgmt_add_picker_title">新增服務</string>
<string name="api_mgmt_all_added">所有服務都已新增</string>
<string name="api_mgmt_empty_title">還沒有新增任何 API</string>
<string name="api_mgmt_empty_body">點擊右上角「+」新增一個 API Key</string>
<string name="api_mgmt_delete_title">刪除憑證?</string>
<string name="api_mgmt_delete_body">將刪除 %1$s 已儲存的憑證。</string>
<string name="api_mgmt_unconfigured">未設定</string>
<string name="api_mgmt_accounts_subtitle">%1$d 個帳號 · 點按管理</string>
<string name="api_mgmt_last_verified">最後驗證:%1$s</string>
<string name="api_mgmt_cached_balance">快取餘額:%1$s %2$s</string>
<string name="api_mgmt_test_success">✅ %1$s 連線成功:%2$s %3$s</string>
<string name="api_mgmt_test_fail">❌ %1$s 連線失敗:%2$s</string>
<string name="api_mgmt_err_invalid">憑證無效</string>
<string name="api_mgmt_err_changed">憑證已變更</string>
<string name="api_mgmt_err_ratelimited">請求過於頻繁</string>
<string name="api_mgmt_err_network">網路異常</string>
<string name="api_mgmt_err_server">伺服器異常(HTTP %1$d)</string>
<string name="api_mgmt_err_parse">解析失敗</string>
<string name="title_credentials">憑據管理</string>
<string name="title_about">關於 RainyToken</string>
<string name="title_tips">使用小技巧</string>
@ -77,6 +99,9 @@
<!-- ══════════ 仪表盘 ══════════ -->
<string name="dashboard_subtitle">AI 餘額一覽</string>
<string name="dashboard_empty_title">尚未設定任何服務</string>
<string name="dashboard_empty_subtitle">首頁僅顯示已設定憑證的服務</string>
<string name="dashboard_empty_go_settings">前往設定新增</string>
<string name="footer_never_fetched">從未獲取</string>
<string name="footer_just_updated">剛剛更新</string>
<string name="footer_minutes_ago">%1$d 分鐘前更新</string>
@ -108,6 +133,120 @@
<string name="service_desc_commandcode_go">JSON API · $</string>
<string name="service_desc_codex">ChatGPT · Codex 額度</string>
<string name="service_desc_ollama">Cookie 抓取 · Cloud 配額</string>
<string name="service_desc_trae">Cloud-IDE-JWT · IDE 額度</string>
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同體系積分</string>
<string name="service_desc_sub2api">自托管面板 · 用戶級 sk- Key 餘額</string>
<string name="detail_extras_title">明細</string>
<string name="detail_account_info_title">帳戶資訊</string>
<string name="detail_resources_title">資源明細</string>
<string name="wb_resource_label">資源 #%1$s</string>
<string name="wb_plan_title">套餐積分</string>
<string name="wb_plan_used_total">已用/總量</string>
<string name="wb_plan_in_use">使用中</string>
<string name="wb_plan_next_reset">下次權益週期更新</string>
<string name="wb_reward_title">平台獎勵積分</string>
<string name="wb_reward_expire">到期:%1$s</string>
<string name="wb_reward_item">獎勵包 %1$d</string>
<string name="extra_label_remaining">剩餘</string>
<string name="extra_label_billing_mode">計費方式</string>
<string name="extra_value_billing_credits">積分</string>
<string name="extra_value_billing_dollar">美元</string>
<string name="extra_label_billing_mode_desc">%1$s計費</string>
<string name="extra_value_subscription_active">訂閱中</string>
<string name="extra_value_paid_user">付費用戶</string>
<string name="extra_value_free_user">免費用戶</string>
<string name="extra_label_consumed_credits">已消耗</string>
<string name="extra_label_consumption_ratio">消耗比例</string>
<string name="extra_label_total_credits">總積分</string>
<string name="extra_label_pack_count">權益包</string>
<string name="extra_label_packages">套餐數</string>
<string name="extra_label_frozen">凍結</string>
<string name="extra_label_is_credits_billing">積分計費</string>
<string name="extra_label_is_dollar_usage_billing">美元計費</string>
<string name="extra_label_is_new_user">新用戶</string>
<string name="extra_label_is_paid_user">付費用戶</string>
<string name="extra_label_subscription">訂閱</string>
<string name="extra_value_yes">是</string>
<string name="extra_value_no">否</string>
<string name="credential_title_trae">Trae 憑證</string>
<string name="credential_hint_trae">貼上 Trae 登入態中的 Cloud-IDE-JWT(ey… 開頭)。請求頭以 Authorization: Cloud-IDE-JWT 送出。</string>
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
<string name="field_trae_region">區域</string>
<string name="field_trae_checkin_device_id">簽到裝置 ID(可選)</string>
<string name="field_trae_checkin_device_hint">簽到須使用官方用戶端綁定的裝置 ID。若持續失敗,請在電腦用戶端日誌(AppData\\Roaming\\Trae CN\\logs\\日期目錄\\main.log)中搜尋 did=,將最新的值貼到此處。留空則使用內建預設。</string>
<string name="field_trae_device_id_howto">裝置 ID 怎麼找?(分步指引)</string>
<string name="msg_path_copied">路徑已複製,貼到 Win+R 或檔案總管即可</string>
<string name="field_trae_device_id_howto_title">尋找裝置 ID(4 步)</string>
<string name="field_trae_device_id_howto_step1">1. 在電腦上按 Win+R,貼上下面的路徑後按 Enter(或複製後貼到檔案總管位址列):</string>
<string name="field_trae_device_id_howto_step2">2. 開啟資料夾名稱裡日期最新的那個資料夾。</string>
<string name="field_trae_device_id_howto_step3">3. 用記事本開啟 main.log。</string>
<string name="field_trae_device_id_howto_step4">4. 按 Ctrl+F 搜尋 did=,後面的數字(如 did=895482260673817)就是裝置 ID,複製並貼到上面的輸入框。</string>
<string name="option_region_cn">CN</string>
<string name="option_region_intl">INTL</string>
<string name="credential_title_workbuddy">WorkBuddy 憑證</string>
<string name="credential_hint_workbuddy">貼上騰訊 WorkBuddy(CodeBuddy 同體系)工作階段的 access &amp; refresh token。refresh token 每次使用會輪換。</string>
<string name="field_workbuddy_access">Access Token</string>
<string name="field_workbuddy_refresh">Refresh Token</string>
<string name="error_workbuddy_tokens_required">Access 與 Refresh Token 必填</string>
<string name="credential_title_sub2api">Sub2API 實例</string>
<string name="credential_hint_sub2api">該實例的任何使用者:直接填分配給你的 sk-… API Key 即可查餘額(無需管理員)。沒有 Key 也可用面板帳號信箱+密碼登入。</string>
<string name="field_sub2_base_url">實例基址</string>
<string name="placeholder_sub2_base_url">https://api.example.com</string>
<string name="sub2_auth_header">認證方式</string>
<string name="field_sub2_api_key">API Key(sk-…)</string>
<string name="sub2_or_account">或面板帳號</string>
<string name="field_sub2_email">信箱</string>
<string name="field_sub2_password">密碼</string>
<string name="error_sub2_required">實例基址與(API Key 或 信箱+密碼)必填</string>
<!-- ══════════ Sub2API 用量明細 ══════════ -->
<string name="sub2_usage_title">用量明細</string>
<string name="sub2_usage_subtitle">來自 Sub2API 閘道的每日與分模型用量</string>
<string name="sub2_window_today">今天</string>
<string name="sub2_window_7d">近7天</string>
<string name="sub2_window_30d">近30天</string>
<string name="sub2_window_90d">近90天</string>
<string name="sub2_window_all">全部</string>
<string name="sub2_stat_requests">請求</string>
<string name="sub2_stat_tokens">Tokens</string>
<string name="sub2_stat_cost">花費</string>
<string name="sub2_daily_header">每日用量</string>
<string name="sub2_models_header">按模型</string>
<string name="sub2_model_all">全部模型</string>
<string name="sub2_model_meta">%1$s 次請求 · %2$s tokens</string>
<string name="sub2_no_daily">該時間範圍內暫無每日用量數據</string>
<string name="sub2_no_cost">該時間範圍內無花費</string>
<string name="sub2_model_sel_summary">模型:%1$s · 當前週期累計</string>
<string name="sub2_no_model_data">該週期暫無模型數據</string>
<!-- ══════════ 每日簽到(WorkBuddy / Trae) ══════════ -->
<string name="checkin_title">每日簽到</string>
<string name="checkin_subtitle">每天簽到領積分,連簽有獎勵</string>
<string name="checkin_action">簽到</string>
<string name="checkin_loading">簽到中…</string>
<string name="checkin_success">簽到成功</string>
<string name="checkin_already">今日已簽到</string>
<string name="checkin_already_streak">今日已簽到(連簽 %1$d 天)</string>
<string name="checkin_failed">簽到失敗,請檢查憑證</string>
<string name="checkin_unsupported">該服務不支援簽到</string>
<string name="checkin_action_done">今日已簽到</string>
<string name="checkin_need_credential">請先新增憑證後再簽到。</string>
<string name="checkin_trae_intl">INTL 區簽到介面暫未支援,請用 CN 區帳號</string>
<string name="checkin_trae_device_blocked">該裝置今日簽到次數已達上限</string>
<string name="checkin_trae_retry_later">參與用戶太多,請稍後重試</string>
<string name="checkin_trae_device_hint">簽到須使用官方用戶端綁定的裝置 ID。若持續失敗,請在電腦上打開 Trae 用戶端日誌目錄(AppData\\Roaming\\Trae CN\\logs\\日期目錄\\main.log),搜尋 did= 取得目前綁定的裝置 ID,然後貼到「設定 → Trae → 簽到裝置 ID」。</string>
<string name="checkin_trae_failed_code">簽到失敗(code=%1$d)</string>
<string name="checkin_wb_success_credits">簽到成功 +%1$d 積分</string>
<string name="trae_packs_title">積分權益包</string>
<string name="trae_pack_unlimited">無限額度</string>
<string name="trae_general_credits_title">通用積分</string>
<string name="trae_work_credits_title">Work 專屬積分</string>
<string name="trae_pack_remaining_fmt">剩餘 %1$s/%2$s 分 · 已用 %3$s</string>
<!-- ══════════ Sub2API 面板 WebView 登入(全帳號統計) ══════════ -->
<string name="sub2_panel_login_hint">在開啟的頁面中正常登入,App 自動讀取登入態;也可繼續手動填 API Key</string>
<string name="sub2_panel_login_done">已擷取面板 Token,點擊儲存生效</string>
<string name="sub2_panel_login_done_short">✓ 已擷取面板 Token</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">天</string>
@ -209,7 +348,7 @@
<string name="help_ocgo_7">6. 找到域名 opencode.ai 下名為 auth 的 Cookie</string>
<string name="help_ocgo_8">7. 複製它的 Value 欄位 → auth cookie</string>
<string name="help_ocgo_note">注意:只填 auth 一個 Cookie 即可,不要整個 Cookie 字串</string>
<string name="help_ccgo_title">如何獲取 CommandCode Go 憑據?</string>
<string name="help_ccgo_title">如何獲取 CommandCode 憑據?</string>
<string name="help_ccgo_1">1. 開啟 https://commandcode.ai 並登入 GitHub</string>
<string name="help_ccgo_2">▸ 獲取 API Key(必填):</string>
<string name="help_ccgo_3"> 右上角頭像 → Settings → API Keys → 建立一個 Key</string>
@ -244,6 +383,34 @@
<string name="error_credential_not_configured">憑據未配置</string>
<string name="error_credential_not_configured_expired">憑據未配置或已過期</string>
<string name="error_credential_invalid_reconfigure">憑據無效,請在設定中重新配置</string>
<string name="error_credential_session_expired">登入已過期,請重新登入</string>
<string name="auto_checkin_switch_label">自動簽到</string>
<string name="checkin_badge_today">今日已簽 ✓</string>
<string name="auto_checkin_dialog_title">每日自動簽到</string>
<string name="auto_checkin_line_gained">%1$s 已簽到,積分增加 %2$d</string>
<string name="auto_checkin_line_already">%1$s 今日已簽到</string>
<string name="auto_checkin_line_failed">%1$s 簽到失敗:%2$s</string>
<string name="auto_checkin_line_failed_generic">%1$s 簽到失敗,請手動簽到</string>
<string name="account_manager_title">%1$s · 帳號管理</string>
<string name="account_add">新增帳號</string>
<string name="account_actions">帳號操作</string>
<string name="account_default_badge">預設</string>
<string name="account_fallback_name">帳號 %1$d</string>
<string name="account_set_default">設為預設</string>
<string name="account_rename">重新命名</string>
<string name="account_rename_dialog_title">重新命名帳號</string>
<string name="account_name_label">帳號名稱</string>
<string name="account_delete_title">刪除帳號</string>
<string name="account_delete_confirm">確定刪除該帳號?該帳號的憑據與餘額快取將一併清除。</string>
<string name="account_empty_hint">還沒有帳號,點下方按鈕新增一個。</string>
<string name="account_count_line">%1$s · %2$d 個帳號</string>
<string name="account_new_credential_title">%1$s · 新帳號</string>
<string name="service_detail_title_with_account">%1$s · %2$s</string>
<string name="account_chip_default">%1$s(預設)</string>
<string name="msg_account_default_set">已設為預設帳號</string>
<string name="msg_account_renamed">已重新命名</string>
<string name="msg_account_deleted">帳號已刪除</string>
<string name="action_delete_confirmed">刪除</string>
<string name="error_credential_invalid_relogin">憑據無效,請重新登入</string>
<string name="error_credential_changed_reload">憑據已變更,正在重新載入</string>
<string name="error_credential_changed_retry">憑據已變更,請重新操作</string>
@ -256,6 +423,7 @@
<string name="error_select_model_first">請先選擇模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string>
<string name="error_login_url_missing">未配置登入 URL</string>
<string name="error_login_timeout">登入逾時,請重試</string>
<string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string>
<string name="error_oauth_auth_failed">授權失敗: %1$s</string>
<string name="error_oauth_state_mismatch">State 不匹配,請重試</string>
@ -294,6 +462,7 @@
<string name="widget_quota_with_plan">%1$s · %2$s</string>
<string name="widget_updated_at">更新 %1$s</string>
<string name="widget_no_data">暫無數據</string>
<string name="widget_not_configured">還沒有設定任何服務</string>
<string name="widget_refreshing">重新整理中…</string>
<!-- ══════════ 小技巧(AppTips) ══════════ -->
@ -341,6 +510,7 @@
<string name="count_times">%1$d次</string>
<string name="date_select_day">選擇日期</string>
<string name="date_select_month">選擇月份(任意一天)</string>
<string name="date_pick_range">日期範圍</string>
<string name="date_pick_start">開始日期</string>
<string name="date_pick_end">結束日期</string>
<string name="chart_detail_cost">消費明細</string>
@ -407,7 +577,7 @@
<string name="action_how_get_two_values">如何獲取這兩個值?</string>
<string name="action_open_login_entry">在瀏覽器中開啟登入入口</string>
<string name="hint_opencode_trigger_key">填入 OpenCode Zen API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。</string>
<string name="credential_title_ccgo">CommandCode Go 憑據</string>
<string name="credential_title_ccgo">CommandCode 憑據</string>
<string name="credential_hint_ccgo">需要 API Key(拉餘額)。Session Cookie(可選)在瀏覽器 DevTools 複製,用於拉用量記錄。不填也可正常檢視餘額。</string>
<string name="placeholder_api_key_from_settings">從 Settings → API Keys 生成</string>
<string name="field_session_cookie_optional">Session Cookie(可選)</string>
@ -454,6 +624,8 @@
<string name="action_refresh_balance">重新整理餘額</string>
<string name="action_relogin">重新登入</string>
<string name="action_configure_credential">配置憑據</string>
<string name="action_recharge">儲值</string>
<string name="action_subscribe_recharge">儲值 / 訂閱</string>
<string name="main_card_current_balance">當前餘額</string>
<string name="main_card_5h_usage">5h 實時用量</string>
<string name="main_card_monthly_balance">月度餘額</string>
@ -554,4 +726,62 @@
<string name="month_12">十二月</string>
<string name="field_api_key">API Key</string>
<string name="field_workspace_id">Workspace ID</string>
<string name="sub2_site_probing">正在識別站點…</string>
<string name="sub2_mode_api_key_desc">API Key:僅統計該 Key 的用量</string>
<string name="sub2_mode_panel_desc">面板登錄:全帳號消費統計,並解鎖 Key 管理/分組/兌換</string>
<string name="sub2_panel_oneclick">帳號一鍵登錄(推薦)</string>
<string name="sub2_panel_logining">正在登錄…</string>
<string name="sub2_panel_login">網頁登錄(備用,如開了二次驗證)</string>
<string name="sub2_mode_account">帳號密碼</string>
<string name="sub2_mode_apikey">API Key</string>
<string name="sub2_field_totp">二次驗證碼(選填)</string>
<string name="sub2_home_no_usage">暫無用量數據,下拉刷新</string>
<string name="sub2_panel_manage_title">面板管理</string>
<string name="sub2_panel_load_keys">載入 API Key 列表</string>
<string name="sub2_panel_no_keys">暫無 API Key</string>
<string name="sub2_panel_create">新建 Key</string>
<string name="sub2_panel_redeem">兌換卡密</string>
<string name="sub2_panel_create_title">新建 API Key</string>
<string name="sub2_panel_key_name">Key 名稱</string>
<string name="sub2_panel_group_optional">分組(可選,不選為預設)</string>
<string name="sub2_panel_no_groups">暫無可用分組</string>
<string name="sub2_panel_create_success_title">✓ 新建 Key 成功</string>
<string name="sub2_panel_create_success_hint">以下 Key 僅顯示這一次,請立即複製保存:</string>
<string name="sub2_panel_copy_and_close">複製並關閉</string>
<string name="sub2_panel_redeem_code_label">兌換碼</string>
<string name="sub2_panel_switch_group">切換分組</string>
<string name="sub2_panel_group_column">分組</string>
<string name="sub2_panel_default_group">預設分組</string>
<string name="sub2_panel_never_used">未使用</string>
<string name="sub2_panel_last_used">最近 %1$s</string>
<string name="sub2_panel_key_id">%1$s(ID %2$d)</string>
<string name="sub2_panel_copied">API Key 已複製</string>
<string name="sub2_panel_low_balance">⚠ 餘額不足:剩餘 $%1$s,請及時儲值或兌換卡密</string>
<string name="sub2_models_alltime_suffix">(累計口徑,未按所選週期過濾)</string>
<string name="sub2_panel_keys_header">API Key 列表</string>
<string name="sub2_auth_panel">信箱登錄</string>
<string name="sub2_auth_apikey">API 登錄</string>
<string name="sub2_home_requests">消耗 %1$d 次</string>
<string name="sub2_balance_label">餘額</string>
<string name="sub2_tab_by_model">按模型</string>
<string name="sub2_tab_by_key">按 Key</string>
<string name="sub2_no_key_data">該週期內沒有 API Key 用量</string>
<string name="sub2_key_unused">本週期未使用</string>
<string name="sub2_home_spend">消耗 $%1$.2f</string>
<string name="sub2_panel_delete_confirm_title">刪除 API Key</string>
<string name="sub2_panel_delete_confirm_body">確定刪除「%1$s」嗎?刪除後無法復原,正在使用該 Key 的用戶端會立即失效。</string>
<string name="sub2_trend_account_scope">此圖為帳號整體趨勢(無單模型維度)</string>
<string name="title_update">檢查更新</string>
<string name="update_current_version">目前版本 %1$s(%2$d)</string>
<string name="update_check">檢查更新</string>
<string name="update_checking">正在檢查…</string>
<string name="update_latest">已是最新版本</string>
<string name="update_available">發現新版本 %1$s</string>
<string name="update_download">下載並安裝</string>
<string name="update_downloading">正在下載 %1$d%%</string>
<string name="update_download_finished">下載完成</string>
<string name="update_install">安裝</string>
<string name="update_install_permission_tip">需要開啟「安裝未知應用」權限</string>
<string name="update_enable_permission">前往開啟</string>
<string name="update_failed">更新失敗:%1$s</string>
</resources>

View File

@ -22,6 +22,7 @@
<string name="action_delete">Delete</string>
<string name="action_clear">Clear</string>
<string name="action_save">Save</string>
<string name="action_login_in_app">Sign in within app</string>
<string name="action_jump">Go</string>
<string name="action_prev_page">◀ Prev</string>
<string name="action_next_page">Next ▶</string>
@ -48,6 +49,27 @@
<string name="nav_empty_title">👈 Select a service to view details</string>
<string name="nav_empty_subtitle">Tap a card on the left</string>
<string name="title_settings">Settings</string>
<string name="api_mgmt_title">API Management</string>
<string name="api_mgmt_test">Test</string>
<string name="api_mgmt_add">Add API Key</string>
<string name="api_mgmt_add_picker_title">Add a service</string>
<string name="api_mgmt_all_added">All services have been added</string>
<string name="api_mgmt_empty_title">No entries yet</string>
<string name="api_mgmt_empty_body">Tap the + in the top-right corner to add an API key</string>
<string name="api_mgmt_delete_title">Delete credential?</string>
<string name="api_mgmt_delete_body">This will remove the saved credential for %1$s.</string>
<string name="api_mgmt_unconfigured">Not configured</string>
<string name="api_mgmt_accounts_subtitle">%1$d accounts · tap to manage</string>
<string name="api_mgmt_last_verified">Last verified: %1$s</string>
<string name="api_mgmt_cached_balance">Cached: %1$s %2$s</string>
<string name="api_mgmt_test_success">✅ %1$s connected: %2$s %3$s</string>
<string name="api_mgmt_test_fail">❌ %1$s failed: %2$s</string>
<string name="api_mgmt_err_invalid">Invalid credential</string>
<string name="api_mgmt_err_changed">Credential changed</string>
<string name="api_mgmt_err_ratelimited">Too many requests</string>
<string name="api_mgmt_err_network">Network error</string>
<string name="api_mgmt_err_server">Server error (HTTP %1$d)</string>
<string name="api_mgmt_err_parse">Parse failed</string>
<string name="title_credentials">Credentials</string>
<string name="title_about">About RainyToken</string>
<string name="title_tips">Tips</string>
@ -81,6 +103,9 @@
<!-- ══════════ 仪表盘 ══════════ -->
<string name="dashboard_subtitle">AI Balance Overview</string>
<string name="dashboard_empty_title">No services configured yet</string>
<string name="dashboard_empty_subtitle">Only services with credentials are shown on the home page.</string>
<string name="dashboard_empty_go_settings">Go to Settings</string>
<string name="footer_never_fetched">Never fetched</string>
<string name="footer_just_updated">Updated just now</string>
<string name="footer_minutes_ago">Updated %1$d min ago</string>
@ -111,6 +136,120 @@
<string name="service_desc_commandcode_go">JSON API · $</string>
<string name="service_desc_codex">ChatGPT · Codex quota</string>
<string name="service_desc_ollama">Cookie fetch · Cloud quota</string>
<string name="service_desc_trae">Cloud-IDE-JWT · IDE 额度</string>
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同体系积分</string>
<string name="service_desc_sub2api">自托管面板 · 用户级 sk- Key 余额</string>
<string name="detail_extras_title">Breakdown</string>
<string name="detail_account_info_title">Account info</string>
<string name="detail_resources_title">Resources</string>
<string name="wb_resource_label">#%1$s</string>
<string name="wb_plan_title">Plan credits</string>
<string name="wb_plan_used_total">Used / Total</string>
<string name="wb_plan_in_use">In use</string>
<string name="wb_plan_next_reset">Next cycle</string>
<string name="wb_reward_title">Platform rewards</string>
<string name="wb_reward_expire">Expires: %1$s</string>
<string name="wb_reward_item">Reward %1$d</string>
<string name="extra_label_remaining">Remaining</string>
<string name="extra_label_billing_mode">Billing mode</string>
<string name="extra_value_billing_credits">Credits</string>
<string name="extra_value_billing_dollar">USD</string>
<string name="extra_label_billing_mode_desc">%1$s billing</string>
<string name="extra_value_subscription_active">Subscribed</string>
<string name="extra_value_paid_user">Paid plan</string>
<string name="extra_value_free_user">Free plan</string>
<string name="extra_label_consumed_credits">Consumed</string>
<string name="extra_label_consumption_ratio">Consumption ratio</string>
<string name="extra_label_total_credits">Total credits</string>
<string name="extra_label_pack_count">Entitlement packs</string>
<string name="extra_label_packages">Packages</string>
<string name="extra_label_frozen">Frozen</string>
<string name="extra_label_is_credits_billing">Credits billing</string>
<string name="extra_label_is_dollar_usage_billing">USD billing</string>
<string name="extra_label_is_new_user">New user</string>
<string name="extra_label_is_paid_user">Paid user</string>
<string name="extra_label_subscription">Subscription</string>
<string name="extra_value_yes">Yes</string>
<string name="extra_value_no">No</string>
<string name="credential_title_trae">Trae credentials</string>
<string name="credential_hint_trae">Paste the Cloud-IDE-JWT from your Trae account session (starts with ey…). Used as Authorization: Cloud-IDE-JWT header.</string>
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
<string name="field_trae_region">Region</string>
<string name="field_trae_checkin_device_id">Check-in device ID (optional)</string>
<string name="field_trae_checkin_device_hint">Check-in requires the device ID bound to the official desktop client. If it keeps failing, find the latest did= value in the client log (AppData\\Roaming\\Trae CN\\logs\\&lt;date&gt;\\main.log) and paste it here. Leave blank to use the built-in default.</string>
<string name="field_trae_device_id_howto">How to find the device ID? (step-by-step guide)</string>
<string name="msg_path_copied">Path copied, paste it in Win+R or File Explorer</string>
<string name="field_trae_device_id_howto_title">Find device ID (4 steps)</string>
<string name="field_trae_device_id_howto_step1">1. On your PC, press Win+R, paste the path below and press Enter (or copy it and paste into the File Explorer address bar):</string>
<string name="field_trae_device_id_howto_step2">2. Open the folder with the latest date in its name.</string>
<string name="field_trae_device_id_howto_step3">3. Open main.log with Notepad.</string>
<string name="field_trae_device_id_howto_step4">4. Press Ctrl+F and search did= — the number after it (like did=895482260673817) is your device ID. Copy it and paste into the field above.</string>
<string name="option_region_cn">CN</string>
<string name="option_region_intl">INTL</string>
<string name="credential_title_workbuddy">WorkBuddy credentials</string>
<string name="credential_hint_workbuddy">Paste the access &amp; refresh tokens from your Tencent WorkBuddy (CodeBuddy) session. Refresh token is rotated once per use.</string>
<string name="field_workbuddy_access">Access Token</string>
<string name="field_workbuddy_refresh">Refresh Token</string>
<string name="error_workbuddy_tokens_required">Access and refresh tokens are required</string>
<string name="credential_title_sub2api">Sub2API instance</string>
<string name="credential_hint_sub2api">Any user of the instance: paste the sk-… API key issued to you to see your balance (no admin needed). No key? Use the panel login email &amp; password.</string>
<string name="field_sub2_base_url">Instance base URL</string>
<string name="placeholder_sub2_base_url">https://api.example.com</string>
<string name="sub2_auth_header">Authentication</string>
<string name="field_sub2_api_key">API Key (sk-…)</string>
<string name="sub2_or_account">Or panel account</string>
<string name="field_sub2_email">Email</string>
<string name="field_sub2_password">Password</string>
<string name="error_sub2_required">Base URL and (API Key or email+password) are required</string>
<!-- ══════════ Sub2API 用量明细 ══════════ -->
<string name="sub2_usage_title">Usage breakdown</string>
<string name="sub2_usage_subtitle">Daily and per-model usage from your Sub2API gateway</string>
<string name="sub2_window_today">Today</string>
<string name="sub2_window_7d">7 days</string>
<string name="sub2_window_30d">30 days</string>
<string name="sub2_window_90d">90 days</string>
<string name="sub2_window_all">All</string>
<string name="sub2_stat_requests">Requests</string>
<string name="sub2_stat_tokens">Tokens</string>
<string name="sub2_stat_cost">Cost</string>
<string name="sub2_daily_header">Daily usage</string>
<string name="sub2_models_header">By model</string>
<string name="sub2_model_all">All models</string>
<string name="sub2_model_meta">%1$s requests · %2$s tokens</string>
<string name="sub2_no_daily">No daily usage data in this range</string>
<string name="sub2_no_cost">No spend in this range</string>
<string name="sub2_model_sel_summary">Model: %1$s · in current period</string>
<string name="sub2_no_model_data">No model data in this range</string>
<!-- ══════════ 每日签到(WorkBuddy / Trae) ══════════ -->
<string name="checkin_title">Daily check-in</string>
<string name="checkin_subtitle">Check in every day for credits; streaks earn bonuses</string>
<string name="checkin_action">Check in</string>
<string name="checkin_loading">Checking…</string>
<string name="checkin_success">Check-in successful</string>
<string name="checkin_already">Already checked in today</string>
<string name="checkin_already_streak">Already checked in (streak %1$d days)</string>
<string name="checkin_failed">Check-in failed, please check credentials</string>
<string name="checkin_unsupported">This service does not support check-in</string>
<string name="checkin_action_done">Checked in today</string>
<string name="checkin_need_credential">Add your credentials first to check in.</string>
<string name="checkin_trae_intl">INTL check-in not yet supported, please use a CN account</string>
<string name="checkin_trae_device_blocked">Device check-in limit reached today</string>
<string name="checkin_trae_retry_later">Too many participants, please try again later</string>
<string name="checkin_trae_device_hint">Sign-in requires the device ID bound to the official desktop client. If it keeps failing, open the Trae client log folder (AppData\\Roaming\\Trae CN\\logs\\&lt;date&gt;\\main.log) on your PC and search for did= to get the current bound device ID, then paste it into Settings → Trae → Check-in device ID.</string>
<string name="checkin_trae_failed_code">Check-in failed (code=%1$d)</string>
<string name="checkin_wb_success_credits">Check-in successful +%1$d credits</string>
<string name="trae_packs_title">Entitlement credits</string>
<string name="trae_pack_unlimited">Unlimited</string>
<string name="trae_pack_remaining_fmt">Remaining %1$s/%2$s pts · used %3$s</string>
<string name="trae_general_credits_title">General credits</string>
<string name="trae_work_credits_title">Work credits</string>
<!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ -->
<string name="sub2_panel_login_hint">Sign in normally on the opened page; the app reads the session automatically. Manual API Key still works.</string>
<string name="sub2_panel_login_done">Panel token captured — tap Save to apply</string>
<string name="sub2_panel_login_done_short">✓ Panel token captured</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">d</string>
@ -212,7 +351,7 @@
<string name="help_ocgo_7">6. Find the cookie named auth under opencode.ai</string>
<string name="help_ocgo_8">7. Copy its Value field → auth cookie</string>
<string name="help_ocgo_note">Note: only the auth cookie is needed, not the whole cookie string</string>
<string name="help_ccgo_title">How to get CommandCode Go credentials?</string>
<string name="help_ccgo_title">How to get CommandCode credentials?</string>
<string name="help_ccgo_1">1. Open https://commandcode.ai and sign in with GitHub</string>
<string name="help_ccgo_2">▸ Get API Key (required):</string>
<string name="help_ccgo_3"> Avatar (top right) → Settings → API Keys → Create a Key</string>
@ -247,6 +386,34 @@
<string name="error_credential_not_configured">Credential not configured</string>
<string name="error_credential_not_configured_expired">Credential missing or expired</string>
<string name="error_credential_invalid_reconfigure">Credential invalid, please reconfigure in Settings</string>
<string name="error_credential_session_expired">Login expired, please sign in again</string>
<string name="auto_checkin_switch_label">Auto check-in</string>
<string name="checkin_badge_today">Checked in ✓</string>
<string name="auto_checkin_dialog_title">Daily auto check-in</string>
<string name="auto_checkin_line_gained">%1$s checked in, +%2$d credits</string>
<string name="auto_checkin_line_already">%1$s already checked in today</string>
<string name="auto_checkin_line_failed">%1$s check-in failed: %2$s</string>
<string name="auto_checkin_line_failed_generic">%1$s check-in failed, please check in manually</string>
<string name="account_manager_title">%1$s · Accounts</string>
<string name="account_add">Add Account</string>
<string name="account_actions">Account actions</string>
<string name="account_default_badge">Default</string>
<string name="account_fallback_name">Account %1$d</string>
<string name="account_set_default">Set as Default</string>
<string name="account_rename">Rename</string>
<string name="account_rename_dialog_title">Rename Account</string>
<string name="account_name_label">Account name</string>
<string name="account_delete_title">Delete Account</string>
<string name="account_delete_confirm">Delete this account? Its credential and balance cache will also be removed.</string>
<string name="account_empty_hint">No accounts yet. Tap below to add one.</string>
<string name="account_count_line">%1$s · %2$d accounts</string>
<string name="account_new_credential_title">%1$s · New Account</string>
<string name="service_detail_title_with_account">%1$s · %2$s</string>
<string name="account_chip_default">%1$s (Default)</string>
<string name="msg_account_default_set">Set as default account</string>
<string name="msg_account_renamed">Renamed</string>
<string name="msg_account_deleted">Account deleted</string>
<string name="action_delete_confirmed">Delete</string>
<string name="error_credential_invalid_relogin">Credential invalid, please log in again</string>
<string name="error_credential_changed_reload">Credentials changed, reloading</string>
<string name="error_credential_changed_retry">Credentials changed, please retry</string>
@ -259,6 +426,7 @@
<string name="error_select_model_first">Please select a model first</string>
<string name="error_cookie_not_found">No cookie captured, please make sure you\'re logged in</string>
<string name="error_login_url_missing">No login URL configured</string>
<string name="error_login_timeout">Login timed out, please retry</string>
<string name="error_oauth_url_prefix">URL should start with %1$s</string>
<string name="error_oauth_auth_failed">Authorization failed: %1$s</string>
<string name="error_oauth_state_mismatch">State mismatch, please retry</string>
@ -297,6 +465,7 @@
<string name="widget_quota_with_plan">%1$s · %2$s</string>
<string name="widget_updated_at">Updated %1$s</string>
<string name="widget_no_data">No data</string>
<string name="widget_not_configured">No services configured yet</string>
<string name="widget_refreshing">Refreshing…</string>
<!-- ══════════ 小技巧(AppTips) ══════════ -->
@ -344,6 +513,7 @@
<string name="count_times">%1$d calls</string>
<string name="date_select_day">Pick a date</string>
<string name="date_select_month">Pick a month (any day)</string>
<string name="date_pick_range">Date range</string>
<string name="date_pick_start">Start date</string>
<string name="date_pick_end">End date</string>
<string name="chart_detail_cost">Spending breakdown</string>
@ -410,7 +580,7 @@
<string name="action_how_get_two_values">How to get these two?</string>
<string name="action_open_login_entry">Open login page in browser</string>
<string name="hint_opencode_trigger_key">With an OpenCode Zen API Key, the details page can send one request to activate usage stats. Get it at opencode.ai/settings → API Keys.</string>
<string name="credential_title_ccgo">CommandCode Go credentials</string>
<string name="credential_title_ccgo">CommandCode credentials</string>
<string name="credential_hint_ccgo">Needs an API Key (for balance). Session Cookie (optional) for usage records, copied from browser DevTools. Balance works without it.</string>
<string name="placeholder_api_key_from_settings">Generate at Settings → API Keys</string>
<string name="field_session_cookie_optional">Session Cookie (optional)</string>
@ -457,6 +627,8 @@
<string name="action_refresh_balance">Refresh balance</string>
<string name="action_relogin">Log in again</string>
<string name="action_configure_credential">Configure credential</string>
<string name="action_recharge">Top up / Recharge</string>
<string name="action_subscribe_recharge">Top up / Subscribe</string>
<string name="main_card_current_balance">Current balance</string>
<string name="main_card_5h_usage">5h live usage</string>
<string name="main_card_monthly_balance">Monthly balance</string>
@ -559,4 +731,62 @@
<string name="month_12">Dec</string>
<string name="field_api_key">API Key</string>
<string name="field_workspace_id">Workspace ID</string>
</resources>
<string name="sub2_site_probing">Detecting site…</string>
<string name="sub2_mode_api_key_desc">API Key: tracks usage for this key only</string>
<string name="sub2_mode_panel_desc">Panel login: full-account usage stats; unlocks key management, grouping and redemption</string>
<string name="sub2_panel_oneclick">One-click account login (recommended)</string>
<string name="sub2_panel_logining">Logging in…</string>
<string name="sub2_panel_login">Web login (fallback, e.g. if 2FA is enabled)</string>
<string name="sub2_mode_account">Email login</string>
<string name="sub2_mode_apikey">API key login</string>
<string name="sub2_field_totp">2FA code (optional)</string>
<string name="sub2_home_no_usage">No usage data yet — pull to refresh</string>
<string name="sub2_panel_manage_title">Panel management</string>
<string name="sub2_panel_load_keys">Load API Key list</string>
<string name="sub2_panel_no_keys">No API Key yet</string>
<string name="sub2_panel_create">New Key</string>
<string name="sub2_panel_redeem">Redeem code</string>
<string name="sub2_panel_create_title">New API Key</string>
<string name="sub2_panel_key_name">Key name</string>
<string name="sub2_panel_group_optional">Group (optional, default if unselected)</string>
<string name="sub2_panel_no_groups">No group available</string>
<string name="sub2_panel_create_success_title">✓ API Key created</string>
<string name="sub2_panel_create_success_hint">This Key is shown only once — copy it now:</string>
<string name="sub2_panel_copy_and_close">Copy &amp; close</string>
<string name="sub2_panel_redeem_code_label">Redeem code</string>
<string name="sub2_panel_switch_group">Switch group</string>
<string name="sub2_panel_group_column">Group</string>
<string name="sub2_panel_default_group">Default group</string>
<string name="sub2_panel_never_used">never used</string>
<string name="sub2_panel_last_used">last %1$s</string>
<string name="sub2_panel_key_id">%1$s (ID %2$d)</string>
<string name="sub2_panel_copied">API Key copied</string>
<string name="sub2_panel_low_balance">⚠ Low balance: $%1$s left — top up or redeem a code</string>
<string name="sub2_models_alltime_suffix">(all-time, not limited to the selected period)</string>
<string name="sub2_panel_keys_header">API keys</string>
<string name="sub2_auth_panel">Email login</string>
<string name="sub2_auth_apikey">API key login</string>
<string name="sub2_home_requests">%1$d calls</string>
<string name="sub2_balance_label">Balance</string>
<string name="sub2_tab_by_model">By model</string>
<string name="sub2_tab_by_key">By API key</string>
<string name="sub2_no_key_data">No API key usage in this period</string>
<string name="sub2_key_unused">Not used in this period</string>
<string name="sub2_home_spend">Spend $%1$.2f</string>
<string name="sub2_panel_delete_confirm_title">Delete API key</string>
<string name="sub2_panel_delete_confirm_body">Delete "%1$s"? This cannot be undone and any client using this key will stop working.</string>
<string name="sub2_trend_account_scope">Chart shows the whole account (no per-model breakdown)</string>
<string name="title_update">App update</string>
<string name="update_current_version">Current version %1$s (%2$d)</string>
<string name="update_check">Check for updates</string>
<string name="update_checking">Checking…</string>
<string name="update_latest">You are on the latest version</string>
<string name="update_available">New version %1$s available</string>
<string name="update_download">Download &amp; install</string>
<string name="update_downloading">Downloading %1$d%%</string>
<string name="update_download_finished">Download finished</string>
<string name="update_install">Install</string>
<string name="update_install_permission_tip">Please allow installing apps from unknown sources</string>
<string name="update_enable_permission">Open settings</string>
<string name="update_failed">Update failed: %1$s</string>
</resources>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 进度条颜色:<50% 粉色 / 50-80% 橙色 / >80% 玫红 -->
<color name="widget_bar_5h">#FF85A2</color>
<!-- 进度条颜色:<50% 玫瑰 / 50-80% 橙色 / >80% 玫红(与 App 内状态色一致) -->
<color name="widget_bar_5h">#C9436C</color>
<color name="widget_bar_weekly">#FFA726</color>
<color name="widget_bar_monthly">#E91E63</color>
<color name="widget_bar_monthly">#D64A6E</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="apk_downloads" path="apk_downloads/" />
</paths>

View File

@ -5,4 +5,7 @@
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">124.222.97.212</domain>
</domain-config>
</network-security-config>

View File

@ -0,0 +1,151 @@
package com.rainy.token.data.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for [Sub2ApiRepository.parseUsageDetail](/v1/usage 响应 → 结构化明细)。
*
* 覆盖:平铺结构、{data:{...}} 包装、字段多版本候选(model_name/actual_cost)、
* 异常行跳过、空明细兜底。全部纯 JVM、无网络。
*/
class Sub2ApiRepositoryTest {
/** 通过反射构造一个 Repository(内部方法不依赖 OkHttp/凭据,只测解析)。 */
private fun repo(): Sub2ApiRepository {
val ctor = Sub2ApiRepository::class.java.getDeclaredConstructor(
okhttp3.OkHttpClient::class.java,
CredentialRepository::class.java,
com.rainy.token.data.cache.BalanceCache::class.java
)
ctor.isAccessible = true
return ctor.newInstance(okhttp3.OkHttpClient(), null, null)
}
private val flatSample = """
{
"remaining": 302.29,
"unit": "USD",
"model_stats": [
{"model": "deepseek-v4-flash", "requests": 1200, "input_tokens": 100000, "output_tokens": 50000, "total_tokens": 150000, "cost": 12.34},
{"model": "gpt-5-codex", "requests": 300, "input_tokens": 20000, "output_tokens": 25000, "cost": 5.00},
{"model_name": "claude-sonnet-4", "requests": "80", "actual_cost": 2.5}
],
"daily_usage": [
{"date": "2026-09-10", "requests": 400, "total_tokens": 60000, "cost": 6.1},
{"date": "2026-09-11", "requests": 500, "input_tokens": 30000, "output_tokens": 40000, "cost": 7.2},
{"date": "2026-09-12", "requests": 700, "total_tokens": 90000, "cost": 8.3}
]
}
""".trimIndent()
@Test
fun flatResponse_parsesModelStatsAndDailyUsageFully() {
val detail = repo().parseUsageDetail(flatSample)
assertEquals(3, detail.modelStats.size)
assertEquals("deepseek-v4-flash", detail.modelStats[0].model)
assertEquals(1200L, detail.modelStats[0].requests)
assertEquals(100000L, detail.modelStats[0].inputTokens)
assertEquals(50000L, detail.modelStats[0].outputTokens)
assertEquals(150000L, detail.modelStats[0].totalTokens)
assertEquals(12.34, detail.modelStats[0].cost, 1e-6)
// 第二候选字段:model_name / actual_cost / 字符串数字
assertEquals("claude-sonnet-4", detail.modelStats[2].model)
assertEquals(80L, detail.modelStats[2].requests)
assertEquals(2.5, detail.modelStats[2].cost, 1e-6)
// modelStats 方向 total_tokens 缺失时回退 input+output(与 daily 分支一致)
assertEquals(45000L, detail.modelStats[1].totalTokens)
// 全量(不再被 take(3) 截断)
assertEquals(3, detail.dailyUsage.size)
assertEquals("2026-09-10", detail.dailyUsage[0].date)
assertEquals(700L, detail.dailyUsage[2].requests)
assertEquals(90000L, detail.dailyUsage[2].totalTokens)
assertEquals(8.3, detail.dailyUsage[2].cost, 1e-6)
// total_tokens 缺失时回退 input+output
assertEquals(70000L, detail.dailyUsage[1].totalTokens)
}
@Test
fun dataWrapper_responseNestedUnderDataIsParsed() {
val wrapped = """{"code":0,"data":""" + flatSample + """}"""
val detail = repo().parseUsageDetail(wrapped)
assertEquals(3, detail.modelStats.size)
assertEquals(3, detail.dailyUsage.size)
}
@Test
fun missingSections_returnsEmptyDetailWithoutCrashing() {
val detail = repo().parseUsageDetail("""{"remaining": 1.0}""")
assertTrue(detail.isEmpty)
assertEquals(0, detail.modelStats.size)
assertEquals(0, detail.dailyUsage.size)
}
@Test
fun malformedBody_returnsEmptyDetailWithoutCrashing() {
val detail = repo().parseUsageDetail("not json at all")
assertTrue(detail.isEmpty)
}
@Test
fun malformedRows_skippedIndividuallyValidRowsKept() {
val body = """
{
"model_stats": [
{"model": "good-model", "requests": 10, "cost": 1.0},
{"total_tokens": 999},
"garbage"
],
"daily_usage": [
{"date": "2026-09-11", "requests": 1, "cost": 0.1},
{"cost": 5.0}
]
}
""".trimIndent()
val detail = repo().parseUsageDetail(body)
assertEquals(1, detail.modelStats.size)
assertEquals("good-model", detail.modelStats[0].model)
assertEquals(1, detail.dailyUsage.size)
assertEquals("2026-09-11", detail.dailyUsage[0].date)
}
@Test
fun dayWindowSlicing_usesDateRangeNotRowCount() {
// 基于系统日期构造动态样例,断言按日期过滤(而非按行数)
val today = java.time.LocalDate.now()
val d9 = today.minusDays(9).toString()
val d2 = today.minusDays(2).toString()
val d0 = today.toString()
val body = "{\"remaining\":1.0,\"daily_usage\":[" +
"{\"date\":\"" + d9 + "\",\"requests\":1,\"total_tokens\":10,\"cost\":0.1}," +
"{\"date\":\"" + d2 + "\",\"requests\":2,\"total_tokens\":20,\"cost\":0.2}," +
"{\"date\":\"" + d0 + "\",\"requests\":3,\"total_tokens\":30,\"cost\":0.3}" +
"]}"
val all = repo().parseUsageDetail(body).dailyUsage
assertEquals(3, all.size)
// 近 7 天(今天及前 6 天)→ 排除 9 天前的行
assertEquals(2, all.filter { it.date >= today.minusDays(6).toString() }.size)
// 近 90 天 → 全部 3 行
assertEquals(3, all.filter { it.date >= today.minusDays(89).toString() }.size)
// 解析层保证日期升序
assertEquals(all.map { it.date }, all.map { it.date }.sorted())
}
@Test
fun wrongTypedSections_doesNotCrashAndSkips() {
val body = """
{
"remaining": 1.0,
"model_stats": "not-an-array",
"daily_usage": 123
}
""".trimIndent()
val detail = repo().parseUsageDetail(body)
assertTrue(detail.isEmpty)
}
}

View File

@ -0,0 +1,188 @@
package com.rainy.token.data.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for [TraeRepository.parseCheckin](每日签到 claim 响应 → 语义结果)。
*
* 覆盖真实实测口径:code 0/200 成功、code 1001 已签、消息含「已签到」等账号级已签、
* 消息含「设备」为设备级拦截(提示而非报错)、其它 code 走 FAILED 且带回错误码。
* 全部纯 JVM、无网络(parseCheckin 位于 companion,静态调用,不构造 Repository)。
*/
class TraeRepositoryTest {
private fun parse(body: String) = TraeRepository.parseCheckin(body)
@Test
fun success_parsesPointsAndOutcome() {
val body = """{"code":0,"message":"签到成功","data":{"points":200}}"""
val r = parse(body).getOrThrow()
assertTrue(r.success)
assertFalse(r.alreadyChecked)
assertEquals(200L, r.points)
assertEquals(TraeRepository.CheckinOutcome.SUCCESS, r.outcome)
}
@Test
fun success_withoutPoints_fallsBackTo200() {
// 服务端未回 points 时按 200 兜底(实测默认奖励)
val r = parse("""{"code":200,"message":"ok"}""").getOrThrow()
assertTrue(r.success)
assertEquals(200L, r.points)
assertEquals(TraeRepository.CheckinOutcome.SUCCESS, r.outcome)
}
@Test
fun code1001_marksAlreadyChecked() {
val r = parse("""{"code":1001,"message":"今日已签到"}""").getOrThrow()
assertTrue(r.alreadyChecked)
assertEquals(TraeRepository.CheckinOutcome.ALREADY, r.outcome)
// 已签不应再报成功奖励
assertEquals(0L, r.points)
}
@Test
fun code10001_authoritative_marksAlreadyChecked() {
// 权威口径幂等兜底码是 10001(trae-checkin-helper 逆向),不能误判为 FAILED
val r = parse("""{"code":10001,"message":"Already claimed"}""").getOrThrow()
assertTrue(r.alreadyChecked)
assertEquals(TraeRepository.CheckinOutcome.ALREADY, r.outcome)
}
@Test
fun alreadyKeywordWithoutCode_marksAlreadyChecked() {
// 服务端换 code 但文案不变时仍识别为「账号级已签」
val r = parse("""{"code":5,"message":"您今天已经签到过了"}""").getOrThrow()
assertTrue(r.alreadyChecked)
assertEquals(TraeRepository.CheckinOutcome.ALREADY, r.outcome)
}
@Test
fun englishAlreadyKeyword_marksAlreadyChecked() {
val r = parse("""{"code":7,"message":"Already claimed today"}""").getOrThrow()
assertEquals(TraeRepository.CheckinOutcome.ALREADY, r.outcome)
}
@Test
fun deviceBlock_isNotSuccessAndNotAlready() {
// 设备级拦截:既不是成功,也不能误判成「账号已签」(否则用户以为签过了)
val body = """{"code":9074,"message":"该设备今日签到次数已达上限"}"""
val r = parse(body).getOrThrow()
assertFalse(r.success)
assertFalse(r.alreadyChecked)
assertEquals(TraeRepository.CheckinOutcome.DEVICE_BLOCKED, r.outcome)
}
@Test
fun deviceBlock_englishWording_alsoDetected() {
val r = parse("""{"code":9074,"message":"device limit reached"}""").getOrThrow()
assertEquals(TraeRepository.CheckinOutcome.DEVICE_BLOCKED, r.outcome)
}
@Test
fun tooManyParticipants_isRetryableNotice() {
// 服务端活动/账号级临时限流:提示可稍后重试,而不是硬错误或误判已签
val body = """{"code":9001,"message":"当前参与用户太多,请稍后重试"}"""
val r = parse(body).getOrThrow()
assertFalse(r.success)
assertFalse(r.alreadyChecked)
assertEquals(TraeRepository.CheckinOutcome.RETRYABLE, r.outcome)
}
@Test
fun retryKeyword_isRetryableNotice() {
val r = parse("""{"code":9002,"message":"请求过于频繁,please retry later"}""").getOrThrow()
assertEquals(TraeRepository.CheckinOutcome.RETRYABLE, r.outcome)
}
@Test
fun unknownCode_isFailedAndKeepsCode() {
val r = parse("""{"code":500,"message":"服务异常"}""").getOrThrow()
assertFalse(r.success)
assertFalse(r.alreadyChecked)
assertEquals(TraeRepository.CheckinOutcome.FAILED, r.outcome)
assertEquals(500L, r.code)
}
@Test
fun dataWrappedResponse_isUnwrapped() {
// data 包裹结构(服务端两种布局都出现过)
val body = """{"data":{"code":0,"points":150},"trace":"x"}"""
val r = parse(body).getOrThrow()
assertTrue(r.success)
assertEquals(150L, r.points)
}
@Test
fun malformedJson_returnsFailureNotCrash() {
val result = parse("not-json")
assertTrue(result.isFailure)
}
// ─── parseStatus(status 接口:enable/checked_in 协议校验) ───────────────
private fun parseStatus(body: String) = TraeRepository.parseStatus(body)
@Test
fun status_enabledNotChecked_parsesFields() {
val s = parseStatus("""{"enable":true,"checked_in":false,"credits":100}""").getOrThrow()
assertTrue(s.enable == true)
assertTrue(s.checkedIn == false)
assertEquals(100.0, s.credits!!, 0.0)
}
@Test
fun status_disabled_parsesEnableFalse() {
// 签到活动未开放(enable=false):提示稍后再试,而非错误
val s = parseStatus("""{"code":0,"enable":false,"checked_in":false}""").getOrThrow()
assertEquals(false, s.enable)
assertEquals(false, s.checkedIn)
}
@Test
fun status_dataWrapped_parses() {
val s = parseStatus("""{"data":{"enable":true,"checked_in":true,"credits":200}}""").getOrThrow()
assertTrue(s.enable == true)
assertTrue(s.checkedIn == true)
assertEquals(200.0, s.credits!!, 0.0)
}
@Test
fun status_nonZeroCode_isFailure() {
assertTrue(parseStatus("""{"code":5001,"enable":false,"checked_in":false}""").isFailure)
}
@Test
fun status_missingBooleans_isFailure() {
// enable/checked_in 非布尔:协议异常(调用方应继续走 claim 兜底)
assertTrue(parseStatus("""{"code":0,"enable":1,"checked_in":0}""").isFailure)
}
@Test
fun claim_creditFieldParsesPoints() {
// 社区实测(trae-checkin-helper):claim 成功响应可能用 credit 字段而非 points
val r = parse("""{"code":0,"data":{"credit":150}}""").getOrThrow()
assertTrue(r.success)
assertEquals(150L, r.points)
}
}

View File

@ -0,0 +1,75 @@
package com.rainy.token.data.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for [WorkBuddyRepository.parseCheckin](checkin-activity-status 响应 → 签到状态)。
*
* 覆盖实测字段口径:data 平铺 / {data:{...}} 包裹、snake_case 与 camelCase 双候选、
* 数值字段字符串化容错、字段缺失兜底。全部纯 JVM、无网络
* (parseCheckin 位于 companion,静态调用,不构造 Repository)。
*/
class WorkBuddyRepositoryTest {
private fun parse(body: String) = WorkBuddyRepository.parseCheckin(body)
@Test
fun flatResponse_parsesAllFields() {
val body = """
{"active":true,"today_checked_in":true,"streak_days":5,
"daily_credit":10,"today_credit":10,"activity_name":"Buddy 加油站"}
""".trimIndent()
val s = parse(body)
assertTrue(s.active)
assertTrue(s.todayCheckedIn)
assertEquals(5L, s.streakDays)
assertEquals(10L, s.dailyCredit)
assertEquals(10L, s.todayCredit)
assertEquals("Buddy 加油站", s.activityName)
// 查询模式不带动作语义
assertEquals(WorkBuddyRepository.CheckinOutcome.QUERY, s.outcome)
}
@Test
fun dataWrappedResponse_isUnwrapped() {
val body = """{"code":0,"data":{"active":true,"todayCheckedIn":true,"streakDays":"3"}}"""
val s = parse(body)
assertTrue(s.todayCheckedIn)
// camelCase + 字符串数字都要能解析
assertEquals(3L, s.streakDays)
}
@Test
fun notCheckedInToday_isFalse() {
val s = parse("""{"today_checked_in":false,"streak_days":0}""")
assertFalse(s.todayCheckedIn)
assertEquals(0L, s.streakDays)
}
@Test
fun missingFields_fallBackToDefaults() {
// 字段缺失时:active 默认 true(活动视为进行中)、签到相关默认 0/false
val s = parse("{}")
assertTrue(s.active)
assertFalse(s.todayCheckedIn)
assertEquals(0L, s.streakDays)
assertEquals(0L, s.dailyCredit)
assertEquals(0L, s.todayCredit)
assertEquals("", s.activityName)
}
@Test
fun themeName_isAcceptedAsActivityNameFallback() {
// 部分响应只有 theme_name
val s = parse("""{"theme_name":"每日签到"}""")
assertEquals("每日签到", s.activityName)
}
}

113
docs/FEATURE-PLAN.md Normal file
View File

@ -0,0 +1,113 @@
# RainyToken 二开:功能设计(FEATURE-PLAN)
> 状态:已实现 + 多轮独立审计定稿(v1.0 · 2026-09-11)
> 依据:用户需求 + 实测调研(Sub2API / Trae / WorkBuddy 三家接口均经真实登录态实测,字段与口径已定稿;见 §5)。
---
## 0. 需求清单(用户原话提炼)
1. **新增供应商**:Trae、WorkBuddy(腾讯)、sub2api(自托管)
2. **DeepSeek 支持充值**:App 内 WebView 打开官方充值页
3. **首页仅显示已配置的服务**(有凭据就显示)
4. **新增「API 管理」页**:按供应商集中展示/管理已保存的 API Key(含状态、新增/编辑/删除/测试)
5. **用量单独一页**:独立的用量统计页(不塞进 API 管理页)
6. 约束:**任何人**都能用 sub2api(配置各自的实例地址+凭据),不只是本机;全程遵守 taste.md(独立审计→同代理复审→通过才收尾;实测优先;凭据仅实测不落码;不擅自提交推送)。
---
## 1. 现有扩展点(改前理解结论)
| 层 | 文件 | 新增服务需改动 |
|---|---|---|
| 服务枚举 | `app/src/main/java/com/rainy/token/domain/service/ServiceType.kt` | 加枚举项(storageKey 稳定) |
| 服务配置 | `app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt` | 加 config;`FetchMethod` 视情况扩枚举(如自托管 REST) |
| 凭据模型 | `app/src/main/java/com/rainy/token/domain/model/Credential.kt` | 密封类加子类 |
| 凭据指纹 | `data/repository/CredentialRepository.kt`(`cacheIdentityFingerprint` / `credentialFingerprint` 两处 `when`) | 密封类新增子类后**编译器强制**补分支(无 else 的 when) |
| 刷新用例 | `domain/usecase/RefreshBalanceUseCase.kt`(硬编码 `when(service)` + `Provider<XxxRepository>`) | 加分支 + 注入新仓库 |
| DI | `di/NetworkModule.kt`(@Provides) | 加 `@Provides` 显式构造(规避 KSP 2.x 跨文件误报) |
| 凭据表单 | `ui/settings/CredentialEditScreen.kt` + `CredentialEditViewModel.kt` | 为新凭据类型加表单分支 |
| 详情卡片 | `ui/servicedetail/ServiceDetailScreen.kt`(`when(service)` 分卡) | 加分支或抽象 |
| 图标 | `ui/components/ServiceIcon.kt` | 加分支 + drawable |
| 字符串 | `res/values/strings.xml`(en)+ `values-b+zh+Hans` + `values-b+zh+Hant` | 新增 key 三处同步 |
| 导航 | `ui/RainyTokenNavHost.kt`(Compact 单栈 + Expanded 双窗格两套) | 新页面两处都要接 |
---
## 2. 功能分期
### 阶段 P1(不依赖调研,立即做)
- **首页仅显示已配置**:`DashboardViewModel.loadFromCache()` / `refresh()` 由 `ServiceType.entries.map` 改为**过滤 `state != NOT_CONFIGURED`**;首页空态给引导文案(空列表 → 提示"请先在设置/API 管理里配置服务"+ 配置入口)。
- **DeepSeek 充值**:
- `ServiceConfig` 增加 `externalUrl`(DEEPSEEK = `https://platform.deepseek.com/top_up`)。
- 新增通用 `WebViewPageScreen`(URL 容器,复用 `WebViewLoginScreen` 的 AndroidView WebView 模式,BackHandler + 进度)。
- `ServiceDetailScreen` DS 详情加「充值」按钮 → 路由到 WebView 页;API 管理页同款按钮。
- **API 管理页**:新增 `ui/account/ApiManagementScreen` + ViewModel:列出全部服务(含未配置),各卡片显示图标/名称/状态 chip/Key 掩码/最后验证/操作(编辑→credentialEdit、删除、测试、充值 for DS)。入口:Dashboard 顶栏图标 + 设置页列表项。
- **用量页(独立)**:新增 `ui/usage/UsageStatScreen`:按配置的服务聚合缓存余额与用量指标(amount/unit/monthlySpent/totalQuota/nextResetAt/extras 摘要),可一键全部刷新;并保留既有用量详情/图表/热力入口作为二级页。入口:Dashboard 顶栏图标。
### 阶段 P2(依赖三方调研回报,统一落地)
- **Trae**(调研已回,2026-09-11):
- 取数凭据 = **`Cloud-IDE-JWT`**(RS256 JWT,~14 天有效期),请求头 `Authorization: Cloud-IDE-JWT <jwt>`;CN 需 `X-User-Region: CN` + `X-Device-Id` + 插件 UA;缺 device 绑定会触发 1001/9074 风控。
- 用户获取方式:浏览器登录 Trae 后从 DevTools Network / 授权回调链接复制 JWT(走 traework2api 同款 `/authorization` 回调粘贴流程)。
- 核心端点:
- CN 积分池:`POST https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage`(`user_entitlement_pack_list[].entitlement_base_info.quota.credits_limit − usage.credits_amount`)
- CN 套餐状态:`POST https://api.trae.cn/trae/api/v1/pay/ide_user_pay_status`
- 兑换 token:`POST https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken`
- Intl:`https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage` 等(v1)
- 参考实现:Sliverkiss/traework2api · mmqz/cpa-multi-plugins · diegosouzapw/OmniRoute(trae.ts)
- 凭据模型:`Credential.TraeCredential(jwt, region)`(简化为单字段 JWT + 地区优化显示)。
- **WorkBuddy(腾讯)**(调研已回,2026-09-11):
- 产品=腾讯 AI 办公 Agent(`workbuddy.qq.com` → 301 → `www.codebuddy.cn/work/`),与 CodeBuddy 共用积分(Credits)体系;无公开 API。
- 取数 = **OAuth Bearer token 流程**(非 cookie):设备授权 `POST https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI` → 微信/QQ 扫码 → `GET .../v2/plugin/auth/token?state=` 拿 `accessToken/refreshToken` → 保活刷新 `POST .../v2/plugin/auth/token/refresh`(头 `X-Refresh-Token`)。
- 余额接口:`POST https://www.codebuddy.cn/v2/billing/meter/get-user-resource`,`Authorization: Bearer <accessToken>`,体含 `"ProductCode":"p_tcaca"`,响应 `Accounts[].CycleCapacityRemain/Used/Size` 累加得可用/已用/总额。
- 凭据模型:复用 **Codex 的 OAuth 模式**(`accessToken/refreshToken`),新增 `Credential.WorkBuddyCredential`(含 refresh)。细节待真实账号实测回填。
- 参考实现:Sliverkiss/workbuddy2api · mmqz/cpa-multi-plugins(billing.go)。
- **Sub2API(自托管,任人可用)**(调研已回,2026-09-11,源码实读:`research-sub2api`,HEAD cdb5cfaf):
- 各上游订阅 = 后台「账号(Accounts)」,管理 API 全部在 `{实例}/api/v1/admin/*`,与对外 OpenAI 网关分离。
- 认证:`POST {base}/api/v1/auth/login`(`{"email","password"}`;可被 TOTP-2FA / 验证码 / backend-mode 拦;返 `access_token/refresh_token`);或 `x-api-key: <admin-api-key>`(免登录)。新版有**合规 423 闸门**:未 accept 时 `/admin/*` 全 423,需先 `POST /api/v1/admin/compliance/accept`。
- 查询序列(最小):`GET /admin/accounts`(列表/详情)→ `POST /admin/accounts/usage/batch`(`source=passive`,不打上游、带缓存,适合 App 轮询)→ 按平台打 `openai|grok|cn-providers/.../quota|balance`(实时余额)。
- 字段以 optional 防御式解析(项目近每日发版、响应字段多变)。
- 凭据模型:`Credential.Sub2ApiCredential(baseUrl, email, password, apiKey?)`;`ServiceConfig` 需支持自定义 baseUrl(`FetchMethod.SELF_HOSTED_REST`)。
- 新服务的**表单/测试/详情卡/图标/字符串**全链路补齐;三者的取数接口都用**真实凭据实测**后再定稿字段映射。
---
## 3. 关键设计决策
1. **“有凭据就显示”**:以 `CredentialStatus.State != NOT_CONFIGURED` 为准(任一凭据存在即算配置),不要求最近验证成功(用户已选)。
2. **充值交互**:App 内 WebView(用户已选);WebView 内完成登录+充值,返回键退出页面。
3. **用量页判据**:展示已配置服务的缓存用量(无网也可见);未配置服务不展示。
4. **API 管理与用量分离**:API 页只管 Key/凭据;用量单独一页(用户已选)。
5. **凭据安全**:任何新凭据只进 SecureStorage,指纹化展示;新子类强制补 `CredentialRepository` 指纹分支。
6. **实测优先**:新供应商接口以调研/实测回报为准回填,不臆造字段。
---
## 4. 测试与审计计划
- P1 每完成一块:`.\gradlew.bat :app:assembleDebug`(CI=true)编译验证。
- 代码修改后用**独立 subagent** 审计(无阻断 + 无 UX 问题),修复后**同 subagent 复审**,通过才收尾(taste.md 强制)。
- 不开 Commit/Push(user 未授权)。
- 新供应商接口回填后,用真实凭据实测(仅验证,不落码)。
## 5. 实施状态(2026 goal round 4)
- ✅ P1a 首页仅显示已配置(含空态引导)——独立审计 + 同代理复审通过
- ✅ P1b DeepSeek 充值(App 内 WebView)——审计修复 + 复审通过
- ✅ P1c API 管理页(集中管理/测试/删除/DS 充值入口)——[A] 修复确认 + 复审通过
- ✅ P1d 用量统计页(独立页面,含失败计数/防闪/extras 过滤三修)——随 P2 审计确认生效
- ✅ P2 数据/领域层:TraeRepository / WorkBuddyRepository / Sub2ApiRepository + 凭据子类
+ 指纹双 when + ServiceType/Config(FetchMethod.SELF_HOSTED_REST)/RefreshBalanceUseCase/NetworkModule
—— 独立审计**无阻断**;F1–F4 已修(缓存身份稳定化 / 负数钳制+数值放宽 / 423 透传 / Sub2Api 掩码改实例基址),F1–F4 复审通过
- ✅ P2 专用凭据表单(Trae JWT+区域 / WorkBuddy tokens / Sub2API 实例+认证二选一)三语
- ✅ Sub2API 实测(xxcsn.site,goal round 4):确认【普通用户用 sk- API Key + GET /v1/usage】即可查余额/用量(无需管理员)。
仓库从 admin 方向重写为**用户级**(Bearer sk- → /v1/usage,404 兜底 /api/v1/usage;remaining/balance/unit/isValid/usage.total.cost/model_stats/daily_usage 实测校准;login email/password 备选);
**独立审计通过(无阻断)+ 同代理复审通过**;3 条建议已落实(desc 用户级文案/acceptCompliance KDoc/清导入)
- ✅ 全量最终审计(55682c88):**无[阻断]、可收尾**;建议1(delete 清理表单字段)已修并复审通过;建议2(test 双击竞态)评估不改;提示项(gradle 本地开关/AGP 补丁、充值 WebView 无会话持久化)已记录。
- ✅ Trae / WorkBuddy 实测校准(goal round 5,经用户授权用 logged-in 浏览器调官方接口,全程不落凭据):
- Trae:POST api.trae.cn/trae/api/v2/pay/ide_user_ent_usage 仅 Authorization:Cloud-IDE-JWT(+Content-Type) 即 200;
权威口径 usage_summary.total_amount-consumed_amount;pack 用 display_desc/credits_limit(-1=∞)/usage.credits_amount;
→ TraeRepository 重写(去 X-Device-Id/Region 头、usage_summary 优先、包级兜底)
- WorkBuddy:POST www.workbuddy.cn/billing/meter/get-user-resource-summary → data.Packages[](Cycle{Total,Remain,Used,Frozen}Capacity+CapacityUnit);
→ WorkBuddyRepository 重写(主端点 workbuddy.cn + codebuddy.cn 兜底、refresh 轮换保留)
- ✅ 两仓库实测校准独立审计(924f04e0):**通过(无阻断)**;5 项建议落实(unit 定案 Credits 且生效化/空体 EMPTY_BODY/无数据 ParseError/清未用 import/语义保留)+ 同代理复审通过;微观察②(单包明细单位)修复后再次确认通过 —— **Trae 与 WorkBuddy 实测校准正式定稿**
- ✅ **收尾(goal round 5)**:三家新供应商(Sub2API/Trae/WorkBuddy)全部实测校准+审计+复审定稿;
原始二开目标 ①-④ 全部达成且审计闭环;`assembleDebug`/`assembleRelease` 均绿;
**未提交未推送**(用户授权前保持工作区本地状态)

192
docs/REDEV-GUIDE.md Normal file
View File

@ -0,0 +1,192 @@
# 🌧️ 雨晴Token 二开开发手册
> 本文档面向在本地 fork/二开 `RainyToken`(CATMIAOZHI/Rainytoken)的开发者。
> 记录本机已就绪的构建环境、项目结构地图、五个常见二开方向的落地清单、测试与发布流程、以及本仓库强制协作约定。
> 仓库路径:`D:\work-space\deepseek_harness\雨晴token`
---
## 1. 环境现状(本机已就绪)
| 组件 | 位置 / 版本 | 状态 |
|---|---|---|
| JDK | `C:\Users\lxy\.jdks\openjdk-22.0.1`(另有 `corretto-17.0.11` 备用) | ✅ |
| Android SDK | `D:\Android-SDK`(cmdline-tools + platform-tools + platform android-35 + build-tools 35.0.0) | ✅ |
| Gradle | wrapper **9.1.0**(`gradle/wrapper/gradle-wrapper.properties`,首次自动下载) | ✅ |
| AGP / Kotlin | 9.0.0 / 2.3.10(`gradle/libs.versions.toml`) | 项目自带 |
| SDK 路径配置 | `D:\work-space\deepseek_harness\雨晴token\local.properties` → `sdk.dir=D:/Android-SDK` | ✅ |
| Android Studio | `D:\Android-Studio`(Narwhal 2025.1.1,`bin/studio64.exe`;桌面 + 开始菜单均建快捷方式「Android Studio」);用户环境变量 `ANDROID_HOME`/`ANDROID_SDK_ROOT` 已指向 `D:\Android-SDK` | ✅ |
| Git | 全局代理 `127.0.0.1:12000` 失效;**直连可用**。拉取/推送请用 `git -c http.proxy= -c https.proxy= ...` | ⚠️ |
> ⚠️ 若换了机器/别人接手:删掉 `local.properties`,自己写 `sdk.dir=你的SDK路径`;SDK 组件与 CI 保持一致(`platforms;android-35` + `build-tools;35.0.0`)。
---
## 2. 构建与验证
```bash
# 单元测试(CI 第一步)
./gradlew testDebugUnitTest
# Debug APK(日常二开验证用这个)
./gradlew assembleDebug
# 产物: app/build/outputs/apk/debug/app-debug.apk
# Lint
./gradlew lintDebug
# Release APK(本机必须有 release.jks + 环境变量,见 §7;否则报错属正常防御)
# 想临时本机验证 Release 编译: 设 CI=true 会自动 fallback 到 debug keystore
# set CI=true && ./gradlew assembleRelease
```
**常见坑**
- `java.net.ConnectException` 拉依赖:确认没走失效代理(本机直连可用,无需代理)。
- `SDK location not found`:检查 `local.properties` 的 `sdk.dir`。
- `Release KEYSTORE_PASSWORD ... not set`:这是有意的防御(见 `app/build.gradle.kts` signingConfigs),别绕过。
- `Your project path contains non-ASCII characters`(Windows / AGP 9):目录名含中文(如本仓库 `雨晴token`)AGP 默认硬失败。已在本项目 `gradle.properties` 加 `android.overridePathCheck=true` 绕过(**该行属本地改动**,对 repo 而言是未提交 diff,勿顺手提交;要提交需经 owners 同意)。新机器照此处理。
---
## 3. 项目结构地图
```
app/src/main/java/com/rainy/token/
├── data/
│ ├── cache/ BalanceCache(DataStore 余额缓存)
│ ├── local/ UsageCache + Room(UsageRecordEntity/UsageDao/UsageDatabase/ChartBucket)、SecureStorage(Keystore AES-256-GCM 密文存储)、ChartSettingsStore
│ ├── remote/ DeepSeekApi(Retrofit 接口)
│ ├── debug/ DebugLog(App 内调试日志)
│ └── repository/ 各服务 R/E(OpenCodeGo / CommandCodeGo(+Usage) / Codex / Ollama / DeepSeek)+ CredentialRepository / RepositoryError / RefreshWriteSession / RetryHelper
├── domain/
│ ├── model/ ServiceBalance(统一余额模型)、Credential(密封类三种)、TriggerSummary
│ ├── service/ ServiceType 枚举、ServiceConfigProvider(FetchMethod + 每个服务的 URL/单位)
│ └── usecase/ RefreshBalanceUseCase(余额,五个服务 when 分发)、SyncUsageUseCase、SyncCommandCodeUsageUseCase
├── ui/
│ ├── RainyTokenNavHost.kt Navigation 路由
│ ├── dashboard/ 仪表盘 / 图表 / 详情(ViewModel 按 ServiceType.entries 自动生成卡片)
│ ├── heatmap/ Token 活动热力图
│ ├── servicedetail/ 服务详情(57KB,最大改动区之一)
│ ├── settings/ Settings / CredentialEdit(凭据表单由 FetchMethod 分发)
│ ├── webview/ WebView 登录 / Codex OAuth(PKCE)
│ ├── widget/ OpenCodeGo 桌面小组件
│ ├── components/ ServiceIcon / StatusChip / FormatUtils / UiText
│ └── theme/ Color.kt / Theme.kt / Type.kt(雨晴粉品牌)
└── di/ NetworkModule(Retrofit/OkHttp/Repository @Provides)、StorageModule(DataStore)
```
关键扩展点速查:
- 新增服务入口:**一个枚举 + 一个配置文件**(`ServiceType.kt` + `ServiceConfigProvider.kt`),仪表盘卡片由 `DashboardViewModel` 遍历 `ServiceType.entries` 自动生成。
- 统一数据模型:`ServiceBalance`,所有 Repository 最终都返回它。
- 统一错误:`RepositoryError` 密封类(InvalidCredential / RateLimited / Network / ServerError / ParseError…)。
- 凭据:`Credential` 密封类(ApiKey / Session / Codex)+ `SecureStorage` 加密落盘。
---
## 4. 二开方向指南
### A. 新增/替换 AI 服务商(如 Claude、Gemini、GLM、Kimi…)
> 如果只是"换一个 REST API 余额查询",改动清单如下(按序):
1. `domain/service/ServiceType.kt` —— 追加枚举项 `Xxx(displayName, storageKey)`。**storageKey 要稳定**(枚举名不可用于索引,改名会丢凭据)。
2. `domain/service/ServiceConfigProvider.kt` —— 追加 `ServiceConfig`(选 `FetchMethod.REST_API` 或 `WEBVIEW_SCRAPER`;`displayUnit` 如 `¥`/`$`/`Credits`)。
3. `data/remote/` —— REST 服务新建 Retrofit API 接口(参照 `DeepSeekApi.kt`);页面抓取服务参照 `OllamaRepository` 的 OkHttp + HTML 解析。
4. `data/repository/` —— 新建 `XxxRepository`:实现 `fetchBalance(): ServiceBalance`(可复用 `CredentialRepository` + `BalanceCache`),HTTP 错误映射到 `RepositoryError`。
5. `di/NetworkModule.kt` —— 用 `@Provides @Singleton` 显式提供(**不要用 `@Inject constructor`**,代码注释明确说明 KSP 2.x 误报隐患)。
6. `domain/usecase/RefreshBalanceUseCase.kt` —— `when (service)` 加分支(注意这个类写死了五个服务,新增必改)。
7. UI:
- `ui/components/ServiceIcon.kt` —— 加图标分支。
- `ui/dashboard/DashboardViewModel.kt` —— 卡片默认自动出现(它遍历 `ServiceType.entries` 生成卡片);**注意**:其中的 OPENCODE_GO 特判(:139)只用于刷新桌面小组件,不是"直达详情"。
- 若新服务要"直达详情/直达用法明细"或定制卡片主体,改动点在 `ui/dashboard/DashboardScreen.kt` 的 `when (card.service)` 跳转分支,与 `ui/dashboard/ServiceBalanceCards.kt` 的 `BalanceMainArea` 卡片渲染分支(参照现有 OPENCODE_GO 卡片直达;`ServiceBalanceCards` 有 `else` 通用兜底,新服务不配自定义主体也会正常渲染)。
- `ui/settings/CredentialEditViewModel.kt` —— REST_API 服务自动走 API Key 表单;Cookie 类自动走 Session 表单;特殊形态(如 Codex OAuth PKCE)需自建流程。
8. 本地化:`res/values*/strings.xml` —— 默认 `values/` 为英文(app_name=RainyToken),中文在 `values-b+zh+Hans` / `values-b+zh+Hant`(app_name=雨晴Token),**没有 `values-en` 目录**。
9. 测试:参照 `app/src/test/.../OllamaRepositoryTest.kt` 写单测(含 HTML/JSON 解析样例)。
> 若目标是"替换"某个服务(如把 OpenCodeGo 换成别家),先删除对应枚举/配置/Repository/UseCase 分支,再按上面加新的。
### B. UI / 品牌换皮(不改功能)
| 想改的东西 | 改哪里 |
|---|---|
| 主题主色(雨晴粉) | `ui/theme/Color.kt`(`StrawberryPink` 系)/ `Theme.kt` |
| 排版 | `ui/theme/Type.kt` |
| App 名称 | `res/values*/strings.xml` 的 `app_name`(含小组件 `widget_name`) |
| 桌面图标 | `res/mipmap-anydpi-v26/ic_launcher*.xml` + `drawable/ic_launcher_*` |
| 启动背景/词语 | `drawable/ic_rainy_wordmark.xml`、`RainyBackground.kt` |
| 仪表盘卡片 | `ui/dashboard/DashboardScreen.kt`(38KB 主战场) |
| 服务详情页 | `ui/servicedetail/ServiceDetailScreen.kt`(57KB,最大) |
> 换皮不影响业务逻辑,纯 Compose/资源改动,风险低。改完跑 `assembleDebug` + 截图自查。
### C. 改包名 + 独立发布自己的版本
1. `app/build.gradle.kts`:`namespace`、`applicationId` 改成 `com.你的域名.xxx`;`versionName/versionCode` 另起(如 1.0.0/1)。**严禁只改 applicationId 不改 namespace**(会连带所有 import 断裂)。
2. 全量重命名目录:`app/src/main/java/com/rainy/token/...` → 新包路径(Android Studio 右键 Refactor → Rename Package 最稳)。
3. `ui/theme/Type.kt`/`Theme.kt` 主题名、`strings.xml` app_name、图标 —— 换皮步骤同 §B。
4. `src/test/java/...` 包路径同步重命名;`AndroidManifest.xml` 内类引用是相对名(`.MainActivity`),重命名目录即自动跟随。
5. MIT License 保留:**必须保留原作者版权声明**(`LICENSE` 文件 + README 出处),不得删除/篡改署名。
6. 独立仓库:本地新建 remote(未授权不 push);CI/Release 用的 4 个 secrets(`KEYSTORE_BASE64` / `KEYSTORE_PASSWORD` / `KEYSTORE_ALIAS` / `KEY_PASSWORD`,见 `.github/workflows/release.yml`)在你自己仓库的 Settings → Secrets and variables 配置。
### D. 架构升级 / 大版本重构
当前版本基线:AGP 9.0.0 / Kotlin 2.3.10 / Gradle 9.1.0 / compileSdk 35 / minSdk 31 / targetSdk 35。可考虑方向:
- **依赖升级**:`gradle/libs.versions.toml` 里 compose-bom 2024.10.01、Kotlin 2.3.10 等,升级前看 [AGP release notes](https://developer.android.com/build/releases/gradle-plugin) 与 [compose-bom mapping](https://developer.android.com/develop/ui/compose/bom/bom-mapping)。
- **模块化**:把 `data` / `domain` / `ui` 拆成 Gradle module,便于多人并行 + 复用。
- **跨平台 Desktop(可选)**:项目现在是纯 Android(Compose UI + Android-only 依赖 retrofit/okhttp 大多已跨平台化,但 DataStore/Keystore/Widget/WebView 都有 Android 专属部分),迁移 KMP 工作量大,建议先评估改桌面版的真实收益(用户可在 Windows 部署一个桌面临时版,直接复用 `domain` + `data` 代码,换 Compose Desktop UI + 本地文件存凭据)。
- **测试基建**:现有仅单测(JUnit),可补 Robolectric UI 测试或增加 Repository 集成测试。
> 重构类改动务必走 §6 的审计流程,分批小步提交。
---
## 5. 测试
```bash
./gradlew testDebugUnitTest
# 报告: app/build/reports/tests/testDebugUnitTest/index.html
```
现有测试资产(`app/src/test/`):
- Repository 解析测试:Codex / CommandCodeGo(Plan映射) / CommandCodeUsage / Ollama / OpenCodeGo / Credential / RefreshWriteSession
- UI 逻辑测试:FormatUtils / FormatCodexPrimaryLabel / Heatmap(AllViews, Insights, Stats) / ServiceDetailViewModel
---
## 6. 发布流程(原仓库约定)
CI:`main` 推代码 → test → debug APK / release(debug-signed) APK。
Release:打 **轻量 tag** `v*` → `release.yml` 用 Secrets 解码 `release.jks` 签名出正式包 → 建 GitHub Release 附件。
本地"发布"流程(若沿用):
1. `app/build.gradle.kts` bump `versionCode`/`versionName`
2. 独立 commit:`release: bump to vX.Y.Z`
3. 打 tag:`git tag vX.Y.Z`(轻量)
4. push main + tag(**需用户授权**)
**签名密钥**:正式构建必须在项目根放 `release.jks`,并注入 `KEYSTORE_PASSWORD / KEYSTORE_ALIAS / KEY_PASSWORD` 环境变量;无密钥时本机 Release 会主动报错(防御设计,勿绕过)。
---
## 7. 协作约定(taste.md — 强制)
本项目由水晴喵/雨晴喵维护,本仓库及 fork 二开均须遵守:
1. **修改后独立审计(强制)**:完成任何代码修改后,派发独立 subagent 审计:
- 无阻断问题(编译失败、逻辑错误、数据流断裂、回归风险)
- 无影响用户体验的问题(UI 展示错误、交互异常、数值显示错误、异常路径处理)
2. **修复后复审**:若审计发现并修复问题,必须**复用同一个 subagent**(续接其 task_id)复审,确认解决且无新问题。
3. **审计通过才收尾**:推送 / 发版 / 宣告完成前必须已有审计通过结论。
4. **禁推送**:未经用户明确允许,不得 commit/push。
5. **实测优先**:下结论前用真实凭据/日志/接口实测,不臆断(字段单位、数值含义、异常原因)。
6. **凭据安全**:用户 cookie / API Key / token 仅用于实测,不写入代码、不提交、不泄露。
7. **子代理路径**:派发 subagent 时,prompt 里所有工作区文件路径一律用**完整绝对路径**(如 `D:\work-space\deepseek_harness\雨晴token\app\src\...`),子代理没有工作区附着上下文。
8. **改前理解**:修改任何文件前先理解相关代码及上下文,遵循项目既有惯例,不搞发明创造。
---
## 8. 备忘
- Git 拉取/推送绕过失效代理:`git -c http.proxy= -c https.proxy= push/pull/fetch ...`
- 当前 HEAD:`eecf549`(`main`,v1.6.3 基线)
- 本项目是雨晴系列第 4 个成员(RainyLLM / RainyScanner / Rainy2FA / RainyToken),MIT License

84
docs/UPSTREAM-SYNC.md Normal file
View File

@ -0,0 +1,84 @@
# 上游同步指南(Rainytoken 二开合并流程)
> 目的:上游(CATMIAOZHI/Rainytoken)持续更新时,把二开工作区(本地分支)与上游
> 新基线对齐,冲突可控、可重复、可一键执行。
> 本仓库两条线:**main = 上游基线跟踪**(保持可随时 fetch/rebase);**dev = 二开改动**。
---
## 1. 仓库当前状态
| 分支 | 内容 | 说明 |
|---|---|---|
| `main` | 上游基线(当前 eecf549,v1.6.3 后继续可追) | 不加入二开改动,保持与 origin/main 一致 |
| `dev`(建议名) | 全部二开改动(3 家供应商 + API 管理页 + 用量页 + 充值 + Sub2API 明细升级 + 本指南) | 二开唯一工作分支 |
> 因为当初二开没有分叉提交,**全部二开改动都在工作区**;首次授权后统一 commit 到 `dev`。
> 上游的 5 个提交(v1.6.4)尚未合入二开,按下面的流程合并后二开同时拥有两边功能。
---
## 2. 每轮上游更新的快捷流程(一键脚本)
在项目根目录执行(Git Bash):
```bash
./scripts/sync-upstream.sh
```
脚本自动完成:fetch 上游 → 切到 `dev` → rebase 到 `origin/main` → `assembleDebug` 编译验证。
手动等价流程:
```bash
# 1. 拉上游(直连,绕过失效代理)
git -c http.proxy= -c https.proxy= fetch origin main
# 2. 在二开分支上 rebase 到新基线
git checkout dev
git rebase origin/main
# 3. 若冲突:手动解决相同文件后
git add <冲突文件>
git rebase --continue
# 4. 编译回归
CI=true ./gradlew.bat :app:assembleDebug
```
---
## 3. 已知冲突面(预计最多遇到的)
二开与上游都在改的文件,但**改动区域通常不同**,多为小冲突:
| 文件 | 上游改 | 二开改 | 冲突可能性 |
|---|---|---|---|
| `app/src/main/java/com/rainy/token/domain/service/ServiceType.kt` | CCGO 显示名 | 追加 Trae/WorkBuddy/Sub2API 枚举 | 低(相邻区域,git 自动合) |
| `app/src/main/res/values*/strings.xml` ×3 | CCGO 更名 + 时间选择器 key | sub2_* 系列 + 服务三语 | 低 |
| `app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt` | CCGO 更名 | 新服务图标分支 | 低 |
| `app/src/main/java/com/rainy/token/di/NetworkModule.kt` | CCGO 更名 | 3 个新 Repository @Provides | 低 |
| `app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt` | CCGO 文案 | Sub2API 明细卡分支 + 充值 | 低 |
| `app/src/main/java/com/rainy/token/ui/dashboard/Usage*Screen.kt` | 时间选择器大改 | (二开基本没动) | 无 |
> 尾部逗号/顺序类冲突:每次合并时**统一以「二开语义」为准**重新排布,不影响功能。
> 版本号(`app/build.gradle.kts`):上游 taste.md 已声明**版本号控制权归上游作者**——
> 二开合并上游后**不要自作主张 bump 版本**;由用户在发版时决策。
---
## 4. 变更落地检查清单(每次合并后)
1. `assembleDebug` 绿(脚本已做);
2. 服务枚举/凭据指纹 `when` 无编译告警(新增枚举必有分支);
3. 三语 strings 无缺 key(lint 可查);
4. 若有 UI 改动 → taste.md 要求的独立 subagent 审计;
5. 需要推手机看效果:`adb -s <serial> install -r app/build/outputs/apk/debug/app-debug.apk`。
---
## 5. 建议节奏
- 上游「发版」(release commit)或你看到感兴趣的新功能时再合并,不必每次小提交都跟;
- 合完上游后建议打一个二开本地 tag(`rain-<日期>`)便于回溯;
- 禁止直接 push 到上游;二开仓库如需推送,仅推 `dev` 且需用户授权。

View File

@ -0,0 +1,77 @@
# 实测凭据提取指南(仅用于功能验证,绝不写入代码/提交)
> 目标:为 RainyToken 三家新供应商做「实测优先」验证。
> 凭据只在本机实测时临时使用,用完即弃;不会进入 Git 历史或仓库文件。
## ✅ 实测进度(2026-09,goal round 5)
- **Sub2API**:已用真实 sk- Key 实测通(GET /v1/usage),字段校准完成。
- **Trae**:已实测通(authorized 登录态调 api.trae.cn `ide_user_ent_usage`,HTTP 200),
权威口径 `usage_summary.total_amount - consumed_amount`,无需 X-Device-Id/X-User-Region 头。
- **WorkBuddy**:已实测通(authorized 登录态调 workbuddy.cn `billing/meter/get-user-resource-summary`,
HTTP 200),`data.Packages[].Cycle{Total,Remain,Used,Frozen}Capacity + CapacityUnit`。
- 以下步骤保留作「他人/日后自助提取」参考。
---
## 1. Trae —— 提取 Cloud-IDE-JWT(点击级步骤,5 分钟)
RainyToken 用 `Authorization: Cloud-IDE-JWT <jwt>` 认证(官方 IDE 同款)。
**推荐在浏览器 Web IDE 上抓**(Chrome / Edge 都行):
### 步骤
1. 打开 Chrome 或 Edge,访问 **https://www.trae.cn** 并登录你的 Trae 账号;
2. 按 **F12** 打开开发者工具 → 切到 **Network(网络)** 标签;
- 建议勾选日志左上角的 **Preserve log(保留日志)**,避免跳转丢记录;
3. 保持 DevTools 开着,访问 **https://ide.trae.cn**(或点右上角「进入 IDE / Web IDE」)。页面加载时会自动发一堆请求;
4. 在 Network 顶部的 **Filter(过滤)** 输入框里输入 `ent_usage`;
5. 找到名为 **`ide_user_ent_usage`** 的请求(方法 POST,域名 `api.trae.cn` 或 `grow-normal.trae.ai`):
- 点击它 → 右侧切到 **Headers** → 往下到 **Request Headers**;
- 找到 **`Authorization`** 一行,值是 `Cloud-IDE-JWT eyJ…`;
- 复制 `Cloud-IDE-JWT ` 后面的整段 **`eyJ…`**(JWT 三段、以点分隔,很长);
6. 告诉我:
- 这个 JWT;
- 你的区域:域名/请求是 `api.trae.cn` → **CN**;是 `grow-normal.trae.ai` → **INTL**(对应请求头 `X-User-Region`)。
### 校验(防止复制错)
- 复制的内容应**以 `eyJ` 开头**,形如 `eyJhbGciOiJIUzI1NiJ9.eyJ…。….`(三个点分片);
- 如果抓到的是 `Cloud-IDE-V2` / `Cookie` / `Bearer` 开头的值,则**不是**这个 JWT,换一个请求再看;
- 若 `ent_usage` 过滤为空:到 **账户/配额页**(trae.cn 头像 → 用量/套餐)点一下刷新,同时清空 Filter 输入 `pay` 或 `ide_user` 再找。
### 备选(浏览器方案无效时)
- 桌面 Trae IDE 里查看「额度/套餐」面板并打开系统代理抓包(Charles/Fiddler 需装根证书,较麻烦);
- 或告诉我,我改成其它抓取路径(如 CLI 登录态文件)。
> JWT 约 14 天有效;只用于实测,用完即弃,绝不写入仓库/提交。
---
## 2. WorkBuddy / CodeBuddy —— 需要 accessToken + refreshToken(OAuth Bearer)
腾讯 WorkBuddy 与 CodeBuddy 共用积分体系,实测走官方 CLI 插件协议:
1. 安装 CodeBuddy CLI(`npm i -g @codebuddy/cli` 或官方安装包)并登录;
2. 或直接抓浏览器:登录 https://www.codebuddy.cn / copilot.tencent.com 后在开发者工具
Network 里找 `get-user-resource` 请求的 **Authorization: Bearer <accessToken>**;
3. **refreshToken** 一般可从浏览器的本地存储 / CLI 的 `~/.codebuddy/auth` 之类配置里读到
(名为 `refresh_token` / `X-Refresh-Token` 对应字段)。
> 若你不想装 CLI,告诉我你平时怎么用 WorkBuddy/CodeBuddy,我改写成对应抓法。
---
## 3. Sub2API —— ✅ 已实测确认(xxcsn.site,2026-09):普通用户用自己的 sk- API Key 即可,无需管理员
- 接口:`GET {实例基址}/v1/usage`,请求头 `Authorization: Bearer <你的 sk-… API Key>`
- 返回:`remaining` / `balance`(剩余余额)、`unit`(如 USD)、`usage.total.cost`(累计花费)、
`model_stats[]`(分模型)、`daily_usage[]`(按日)。
- RainyToken 填:实例地址 + 你账号的 `sk-…` Key(推荐);无 Key 可用面板账号邮箱+密码登录(/api/v1/auth/login → Bearer token)。
- 兜底:`/v1/usage` 404 时自动尝试 `/api/v1/usage`。
---
## 留下什么
- 你在下一条消息里按上面任一项贴出凭据(可先只给一个供应商);我会:
1. 直接用 curl 验证官方接口的**真实响应结构**;
2. 对照校准我方仓库的字段解析(`TraeRepository` / `WorkBuddyRepository` / `Sub2ApiRepository`);
3. 在你提供后**当场删除**,不落入任何文件。

136
docs/签到核心请求.md Normal file
View File

@ -0,0 +1,136 @@
# WB(WorkBuddy)与 Trae 每日签到核心请求
> 本文档整理自仓库现有实现(源码为准):
> - `app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt`
> - `app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt`
>
> 其中 **WB = 腾讯 WorkBuddy**(非微博)。所有请求均为 `POST`,body 默认为 `{}`。
## 一、端点汇总
| 服务 | 用途 | URL | Body |
| --- | --- | --- | --- |
| Trae(CN) | 查询签到状态 | `https://api.trae.cn/trae/api/v2/ug/checkin_credits/status` | `{}` |
| Trae(CN) | 执行签到 | `https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim` | `{}`(9074 时换 `{"req_source":2}` → `{"req_source":1}`) |
| WorkBuddy | 查询签到状态(优先) | `https://www.codebuddy.cn/v2/billing/meter/checkin-activity-status` | `{}` |
| WorkBuddy | 查询签到状态(兜底) | `https://www.codebuddy.cn/v2/billing/meter/checkin-status` | `{}` |
| WorkBuddy | 执行签到 | `https://www.codebuddy.cn/v2/billing/meter/daily-checkin` | `{}` |
| WorkBuddy | accessToken 刷新 | `https://copilot.tencent.com/v2/plugin/auth/token/refresh` | 头 `X-Refresh-Token` |
## 二、Trae 核心请求源码
认证头:`Authorization: Cloud-IDE-JWT <jwt>`,`x-device-id` 必须是账号绑定的官方客户端设备 ID。
```kotlin
// 官方签到接口路径
const val CHECKIN_STATUS_PATH = "/trae/api/v2/ug/checkin_credits/status"
const val CHECKIN_CLAIM_PATH = "/trae/api/v2/ug/checkin_credits/claim"
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()
// ...
}
```
### Trae 签到结果判定
- `code = 0 / 200` → **SUCCESS**(成功)
- `code = 1001 / 10001`,或文案含 `已签到 / 已经签到 / 明日再来 / 今日已完成 / 已领取 / already / checked / claimed` → **ALREADY**(今日已签)
- 文案含 `设备 / device / machine` → **DEVICE_BLOCKED**(设备级拦截)
- 文案含 `参与 / 稍后重试 / 火爆 / 繁忙 / 限流 / 排队 / too many / retry / busy / popular / later` → **RETRYABLE**(临时限流)
- 其余 → **FAILED**
### Trae 注意事项
- UA 必须是 `VSCode 1.107.1 (TRAE SOLO CN)`,这是签到走的 VSCode 插件进程身份;与 IDE 主进程 / Web 混用会触发风控。
- `X-Device-Id` 不可用固定 ClientID `en1oxy7wnw8j9n`(所有用户相同),会按设备风控拒绝。
- 仓库内置默认设备 ID:`ACCOUNT_BOUND_DEVICE_ID = "895482260673817"`,账号级,换设备需更新。
- INTL 区签到接口未实测,仓库实现直接不支持。
## 三、WorkBuddy(WB)核心请求源码
认证头:`Authorization: Bearer <accessToken>`。签到域名是 `www.codebuddy.cn`(余额主查询才是 `www.workbuddy.cn`)。
```kotlin
// 查询今日签到状态(两条路径依次 fallback,共享 token 轮换状态)
suspend fun fetchCheckinStatus(accountId: String? = null): Result<CheckinStatus> {
val body = authedPostFirstSuccessful(
credential,
accountId,
"/v2/billing/meter/checkin-activity-status",
"/v2/billing/meter/checkin-status"
) ?: return Result.failure(RepositoryError.Unknown())
return runCatching { parseCheckin(body) }...
}
// 执行签到
suspend fun checkIn(accountId: String? = null): Result<CheckinStatus> {
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
is AuthedPostResult.Ok -> r.body
is AuthedPostResult.Failed -> r.body
// ...
}
// code=0 成功;code=10001 或文案含「已签/今日/already」→ 已签
}
// 带自动 refresh 一次的 POST 核心
private fun authedPost(credential: Credential.WorkBuddyCredential, path: String): AuthedPostResult {
var access = credential.accessToken
var refresh = credential.refreshToken
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()
// 2xx → Ok
// 401/403 且第一次尝试 → tryRefesh(refresh) 换新 token 后重试
// refresh 请求头携带 X-Refresh-Token <refreshToken>
// 轮换出的新凭据必须先落库再用,否则旧 refreshToken 单次消费后失效
}
}
```
### WorkBuddy 签到结果判定
- `code = 0` → **SUCCESS**
- `code = 10001`,或文案含 `已签 / 已经签到 / 今日 / already / checked / claimed` → **ALREADY**(今日已签)
- 其余 → **FAILED**(HTTP 400 body 也可能携带签到业务语义,需透传解析,不能直接当网络错误丢弃)
## 四、通用约定
1. Trae 用 `Cloud-IDE-JWT`,WorkBuddy 用 `Bearer`,两者凭据类型不同,不能混用。
2. 两者签到都幂等:今日已签时服务端返回已签语义,客户端本地也会记录 `lastCheckinDate` 短路,避免重复请求。
3. 401/403:Trae 直接视为凭据失效;WorkBuddy 会尝试用 refreshToken 轮换一次。
4. 不要随机生成设备 ID(Trae)或篡改客户端头,否则会被风控(Trae 9074「参与用户太多」)。

View File

@ -28,4 +28,5 @@ android.aapt2.process.daemon=false
# KSP 2.x 在多文件 @Inject 跨文件依赖时偶发 "could not be resolved" 误报
# 关闭 incremental 模式解决
ksp.incremental=false
ksp.incremental=false
android.overridePathCheck=true

View File

@ -39,6 +39,7 @@ room = "2.7.1"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
androidsvg = "1.4"
[libraries]
# Core
@ -88,6 +89,9 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# SVG(站点 Logo 渲染)
androidsvg = { group = "com.caverock", name = "androidsvg", version.ref = "androidsvg" }
# Test
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }

44
scripts/sync-upstream.sh Normal file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════
# 一键同步上游 Rainytoken 更新到二开分支
# 用法: ./scripts/sync-upstream.sh [分支名] (默认 dev)
# 流程: fetch origin/main → checkout dev → rebase → assembleDebug
# ═══════════════════════════════════════════════════════════════
set -uo pipefail
cd "$(dirname "$0")/.."
BRANCH="${1:-dev}"
REMOTE="origin"
echo "== [1/5] fetch 上游 main =="
git -c http.proxy= -c https.proxy= fetch "$REMOTE" main || { echo "!! fetch 失败(检查网络/代理)"; exit 1; }
echo "== [2/5] 确保分支 $BRANCH 存在并切过去 =="
if ! git show-ref --verify --quiet "refs/heads/$BRANCH"; then
echo "!! 本地分支 $BRANCH 不存在。若二开工作区尚未提交,请先手动 commit 到 $BRANCH。"
exit 1
fi
git checkout "$BRANCH" || exit 1
echo "== [3/5] rebase 到 origin/main =="
if ! git rebase origin/main; then
echo ""
echo "!! 存在冲突。解决方式:"
echo " 1) 打开冲突文件手动解决(参考 docs/UPSTREAM-SYNC.md §3 已知冲突面)"
echo " 2) git add <冲突文件>"
echo " 3) git rebase --continue"
exit 1
fi
echo "== [4/5] 编译验证 =="
if command -v cmd >/dev/null 2>&1; then
CI=true ./gradlew.bat :app:assembleDebug || { echo "!! 编译失败"; exit 1; }
else
CI=true ./gradlew :app:assembleDebug || { echo "!! 编译失败"; exit 1; }
fi
echo "== [5/5] 完成。最近 5 条提交: =="
git log --oneline -5
echo ""
echo "✅ 同步完成。如需推送手机验证:"
echo " adb install -r app/build/outputs/apk/debug/app-debug.apk"

View File

@ -9,6 +9,7 @@
- 无影响用户体验的问题(UI 展示错误、交互异常、数值显示错误、异常路径处理)
2. **修复后复审**:若审计发现问题并完成修复,必须使用**同一个 subagent**(复用 task_id 续接)再次审计,确认问题已解决,避免引入新版问题。
3. **审计通过后才可收尾**:提交推送 / 发版 / 宣告完成之前,必须已有审计通过结论。
4. **审计不选模型**:派发审计 subagent 时不需要手动选择模型,使用默认配置即可;派发后等待审计结果返回。
## 其他协作偏好
@ -17,4 +18,5 @@
- **未经用户允许禁止提交推送**(见 AGENTS.md)。
- **路径规范**:派发 subagent 时,prompt 中所有工作区文件路径必须使用完整绝对路径(以工作区根目录为前缀),子代理没有工作区附着上下文。
- **改前理解**:修改任何文件前先理解相关代码及上下文,遵循项目既有惯例,不搞发明创造。
- **版本号控制权**:版本号由水晴喵控制;未经同意,不得改动版本号(versionCode / versionName)。
- **版本发布惯例**:版本 bump 独立 commit(`release: bump to vX.Y.Z`),轻量 tag 触发自动 Release。

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