theme.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. /* ============================== src/theme.js ==============================
  2. * 设计令牌 + 绘制工具。
  3. *
  4. * 所有颜色/尺寸/字号都取自 Ardot 设计稿(file 734917825641912),
  5. * 页面代码里**不允许出现裸色值或裸尺寸**——要改视觉先改这里。
  6. *
  7. * 为什么用「代码建 View」而不是 XML 布局:
  8. * ① 设计稿用了渐变、径向光晕、多层描边、按状态换色,XML 属性表达不了,
  9. * 最后仍要落到代码里 setBackground(GradientDrawable),反而两套并存;
  10. * ② XML 字面量(E4X)不是标准 JS,PC 侧无法用 node 做语法检查与离线自检,
  11. * 而 AutoX 设备端是没有 linter 的(见规范 SKILL.md「设备端没有门禁」)。
  12. * 纯代码建 View 让整个产物能在 PC 上被语法检查和桩测。
  13. */
  14. var Theme = {};
  15. /* ---------------------------------------------------------------- 颜色 */
  16. Theme.hex = {
  17. bg: "#030014",
  18. tabBar: "#0A0A17",
  19. dialogBg: "#17142B",
  20. accent: "#2EA6FF",
  21. accentText: "#7CC7FF",
  22. accentDeep: "#9381FF",
  23. /* 任务卡图标衬底的紫。设计稿里 UID私信 的图标框是 #9482FF@16%,
  24. * 与主色渐变里的紫(#9381FF)**不是同一个值** —— 不要"统一"成一个。 */
  25. violet: "#9482FF",
  26. textPrimary: "#FFFFFF",
  27. textSecondary: "#CFCCE6",
  28. /* 弹窗里的设置名用的是更亮一档的正文色(设计稿 §6:正文 #CFCCE6 / #E6E4F5) */
  29. textBody: "#E6E4F5",
  30. textMuted: "#9A97B8",
  31. textFaint: "#8E8BA8",
  32. /* 弱提示(区说明 / 「上次运行」/ 底部提示)。比 textFaint 再暗一档,
  33. * 三级文字层次来自设计稿 §6:次要 #9A97B8·#8E8BA8 / 弱提示 #6F6C8A。 */
  34. textDim: "#6F6C8A",
  35. /* 未选中页签的颜色。**不要删**:它一旦缺失,argb(undefined) 会算出 0
  36. * (全透明),非活动页签会整片消失 —— 而且 uiautomator 里它们的
  37. * bounds 是正常的,只有肉眼看得出来,极难定位。
  38. * 取值 2026-10-13 更新为 #8E8BA8:旧的 #7A7794 对比度只有 4.4:1,
  39. * 未达 WCAG AA(规格书 §9 第 5 条已统一改为新值)。 */
  40. tabIdle: "#8E8BA8",
  41. /* 警示色:设计稿里「未开启」状态标签与「确认解绑」按钮用的是两种不同的暖色。
  42. * warn 偏琥珀(权限告缺、卡密到期),danger 偏红(不可逆操作)。 */
  43. warn: "#F5C24B",
  44. danger: "#FF5470",
  45. ok: "#13DDC4",
  46. divider: "#FFFFFF"
  47. };
  48. /* 卡片与标签的填充/描边一律是「白色 + 透明度」,不是独立色值。
  49. * 这样叠在深色背景与叠在渐变光晕上的观感一致。 */
  50. Theme.alpha = {
  51. cardFill: 0.04,
  52. cardFillActive: 0.06,
  53. cardStroke: 0.08,
  54. cardStrokeActive: 0.35,
  55. chipFill: 0.06,
  56. chipStroke: 0.10,
  57. accentChipFill: 0.20,
  58. /* 全局运行条:青蓝 12% 填充(设计稿 B2 的 30:304,无描边) */
  59. runBarFill: 0.12,
  60. runBarStroke: 0.28,
  61. /* 警示图标底 / 任务图标框:设计稿里是 14% 的同色衬底 */
  62. warnFill: 0.14,
  63. iconBoxFill: 0.14,
  64. mask: 0.72,
  65. glowA: 0.30,
  66. glowB: 0.20
  67. };
  68. /* ---------------------------------------------------------------- 尺寸 */
  69. Theme.m = {
  70. screenW: 375,
  71. contentPadding: 20,
  72. cardPadding: 14,
  73. cardRadius: 16,
  74. chipRadius: 8,
  75. pillRadius: 99,
  76. dialogRadius: 24,
  77. gapPage: 16,
  78. gapCard: 12,
  79. gapTight: 8,
  80. gapInline: 10,
  81. titleSize: 22,
  82. sectionSize: 16,
  83. labelSize: 13,
  84. chipSize: 11,
  85. hintSize: 11,
  86. tabSize: 11,
  87. rowHeight: 26,
  88. chipPadV: 6,
  89. chipPadH: 6,
  90. tabBarHeight: 49,
  91. tabIconSize: 22,
  92. runBtnHeight: 48,
  93. runBtnWidth: 335,
  94. runBarRadius: 12,
  95. runBarPadV: 12,
  96. runBarPadH: 10,
  97. warnIconSize: 40,
  98. warnIconRadius: 12,
  99. loginLogoSize: 72,
  100. loginLogoRadius: 20,
  101. loginBtnHeight: 48,
  102. dialogWidth: 315,
  103. statusBarHeight: 62,
  104. safeAreaHeight: 33,
  105. touchTarget: 44,
  106. /* ===== 2026-10-13 规格书新增(§6 尺寸与圆角 / 触控标准)===== */
  107. /* 二级页返回头:高 44,返回热区 44×44,标题 21 SemiBold */
  108. backHeaderHeight: 44,
  109. backHotSize: 44,
  110. backArrowSize: 20,
  111. detailTitleSize: 21,
  112. /* 任务卡(B1/B2):图标框 40 圆角 12 / 图标 22 / 卡名 15 /
  113. * 运行按钮 68×44(触控标准明确写了 68×44) */
  114. taskIconBox: 40,
  115. taskIconSize: 22,
  116. taskIconRadius: 12,
  117. taskNameSize: 15,
  118. taskRunBtnW: 68,
  119. taskRunBtnH: 44,
  120. /* 记录条目(D1/D3):图标框 36 圆角 10 / 图标 20 / 卡片圆角 14 */
  121. recordIconBox: 36,
  122. recordIconSize: 20,
  123. recordIconRadius: 10,
  124. recordCardRadius: 14,
  125. /* 状态徽标(空闲/运行中/未配置)与结果标签:全圆角小胶囊 */
  126. badgePadH: 8,
  127. badgePadV: 3,
  128. /* 底栏整体高度 = 页签行 49 + 安全区 33(设计稿 §6) */
  129. tabBarTotal: 83,
  130. /* 区间输入框:**单框宽 56、数字居中**(§6)。比早期实现的 64 窄,
  131. * 是为了让「标签 + 56 ~ 56」一行放得下而不挤。 */
  132. rangeFieldW: 56,
  133. /* 内容区底部渐隐:y=577 h=80(§6),右侧滚动指示条 x=367 w=3 */
  134. fadeHeight: 80,
  135. indicatorW: 3,
  136. /* 弹窗按钮:行动按钮 44 / 主按钮 46(A4 的去授权是 46) */
  137. dialogBtnHeight: 44,
  138. dialogPrimaryHeight: 46,
  139. /* 日志行(D3):等宽 11 / 行距紧凑 */
  140. logSize: 11
  141. };
  142. /* ---------------------------------------------------------------- 工具 */
  143. /** dp → px。设计稿是 375 逻辑宽,必须换算后再交给原生控件。 */
  144. function dp(value) {
  145. var density = 1;
  146. try {
  147. density = context.getResources().getDisplayMetrics().density;
  148. } catch (e) {
  149. /* 离线桩测环境没有 Android context,退化为 1:1,不影响逻辑正确性 */
  150. density = 1;
  151. }
  152. return Math.round(value * density);
  153. }
  154. /**
  155. * "#2EA6FF" → Java 可用的 ARGB int(负数形式,与 colors.parseColor 同构)。
  156. *
  157. * 色值写错时返回**醒目的洋红**而不是 0。0 是全透明:
  158. * 界面上的表现是「某块东西不见了」,不报错、不留痕 ——
  159. * tabIdle 掉过一次,整排非活动页签就是这么消失的,
  160. * 而 uiautomator 里它们的 bounds 一切正常,只有肉眼能发现。
  161. * 洋红至少让错误在一眼之内就暴露出来。
  162. */
  163. function argb(hex) {
  164. var h = String(hex).replace("#", "");
  165. if (h.length === 6) { h = "FF" + h; }
  166. var value = parseInt(h, 16);
  167. if (isNaN(value)) {
  168. console.log("[theme] 非法色值(已用洋红代替): " + hex);
  169. return -65281; /* 0xFFFF00FF */
  170. }
  171. return value | 0;
  172. }
  173. /** 给 hex 色叠加不透明度,得到 ARGB int。#FFFFFF + 0.06 → 约 #0FFFFFFF */
  174. function alphaOf(hex, alpha) {
  175. var h = String(hex).replace("#", "");
  176. if (h.length === 8) { h = h.substring(2); }
  177. var a = Math.round(Math.max(0, Math.min(1, alpha)) * 255);
  178. return parseInt(("0" + a.toString(16)).slice(-2) + h, 16) | 0;
  179. }
  180. /** 纯色圆角背景。 */
  181. function solidBg(colorInt, radiusDp) {
  182. var drawable = new android.graphics.drawable.GradientDrawable();
  183. drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
  184. drawable.setColor(colorInt);
  185. if (radiusDp) { drawable.setCornerRadius(dp(radiusDp)); }
  186. return drawable;
  187. }
  188. /** 圆角 + 描边(卡片默认形态)。 */
  189. function strokeBg(fillAlpha, strokeAlpha, strokeHex, radiusDp) {
  190. var drawable = new android.graphics.drawable.GradientDrawable();
  191. drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
  192. drawable.setColor(alphaOf("#FFFFFF", fillAlpha));
  193. drawable.setCornerRadius(dp(radiusDp));
  194. drawable.setStroke(dp(1), alphaOf(strokeHex || "#FFFFFF", strokeAlpha));
  195. return drawable;
  196. }
  197. /** 线性渐变(主按钮、选中态)。默认左→右,与设计稿一致。 */
  198. function gradientBg(fromHex, toHex, radiusDp) {
  199. var drawable = new android.graphics.drawable.GradientDrawable();
  200. drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
  201. drawable.setOrientation(android.graphics.drawable.GradientDrawable.Orientation.LEFT_RIGHT);
  202. drawable.setColors([argb(fromHex), argb(toHex)]);
  203. if (radiusDp) { drawable.setCornerRadius(dp(radiusDp)); }
  204. return drawable;
  205. }
  206. /** 径向光晕:中心有色、边缘透明,用来复刻设计稿背景的两团光。 */
  207. function glowBg(hex, alpha) {
  208. var drawable = new android.graphics.drawable.GradientDrawable();
  209. var colors = [alphaOf(hex, alpha), alphaOf(hex, 0)];
  210. drawable.setShape(android.graphics.drawable.GradientDrawable.OVAL);
  211. drawable.setGradientType(android.graphics.drawable.GradientDrawable.RADIAL_GRADIENT);
  212. drawable.setGradientRadius(dp(220));
  213. drawable.setColors(colors);
  214. return drawable;
  215. }
  216. /** 页面根背景:底色 + 两团光晕,所有承载体统一从这里取,避免各自拼。 */
  217. function screenBackground() {
  218. return solidBg(argb(Theme.hex.bg));
  219. }
  220. /**
  221. * 竖向渐隐(透明 → 色),用在内容区底部(设计稿 §6「底部渐隐 y=577 h=80」)。
  222. *
  223. * 为什么要它:内容区是可滚动的,卡片被底栏"硬切"一刀会让人以为列表到头了。
  224. * 加一层渐隐,滚动的连续性一眼可见。
  225. * 颜色取**页面底色**:叠在空内容上时它等于背景,不会露出多余的色块。
  226. */
  227. function fadeBg(hex) {
  228. var drawable = new android.graphics.drawable.GradientDrawable();
  229. drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
  230. drawable.setOrientation(android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM);
  231. drawable.setColors([alphaOf(hex, 0), argb(hex)]);
  232. return drawable;
  233. }
  234. /** 同色衬底(任务图标框 / 警示图标底):hex + 透明度,圆角方块。 */
  235. function tintBg(hex, alpha, radiusDp) {
  236. var drawable = new android.graphics.drawable.GradientDrawable();
  237. drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
  238. drawable.setColor(alphaOf(hex, alpha));
  239. if (radiusDp) { drawable.setCornerRadius(dp(radiusDp)); }
  240. return drawable;
  241. }
  242. /* ------------------------------------------------------------- 基础控件 */
  243. /** 统一的 TextView 工厂,防止每个页面各写一遍字号字色。 */
  244. function text(content, sizeSp, hex, bold) {
  245. var view = new android.widget.TextView(context);
  246. view.setText(String(content === null || content === undefined ? "" : content));
  247. view.setTextSize(sizeSp);
  248. view.setTextColor(argb(hex));
  249. if (bold) {
  250. view.setTypeface(android.graphics.Typeface.DEFAULT_BOLD);
  251. }
  252. return view;
  253. }
  254. /**
  255. * 等宽数字(设计稿里 200 / 30、2026.10.10 用的是 JetBrains Mono)。
  256. * 与 text() 一样带 undefined 兜底——String(undefined) 会变成界面上的 "undefined"。
  257. */
  258. function monoText(content, sizeSp, hex) {
  259. var view = new android.widget.TextView(context);
  260. view.setText(content === null || content === undefined ? "" : String(content));
  261. view.setTextSize(sizeSp);
  262. view.setTextColor(argb(hex));
  263. view.setTypeface(android.graphics.Typeface.MONOSPACE);
  264. return view;
  265. }
  266. function box(widthDp, heightDp) {
  267. var view = new android.view.View(context);
  268. view.setLayoutParams(new android.widget.LinearLayout.LayoutParams(
  269. widthDp >= 0 ? dp(widthDp) : widthDp,
  270. heightDp >= 0 ? dp(heightDp) : heightDp
  271. ));
  272. return view;
  273. }
  274. /**
  275. * 垂直间距。⚠️ 只能用在**竖向** LinearLayout 里。
  276. *
  277. * 它的宽度是 MATCH_PARENT,放进横向容器会占满整行剩余空间,
  278. * 把同行后面那些 weight=1 的 View 挤成 0 宽(真机上表现为「文字全部消失」)。
  279. * 横向间距请用 hgap()。
  280. */
  281. function spacer(heightDp) {
  282. return box(-1, heightDp);
  283. }
  284. /**
  285. * 横向间距。固定宽度、高度 1px。
  286. * 与 spacer 的区别只在宽度策略,但用错了就是「文字不见了」这种难查的现象,
  287. * 所以刻意分成两个名字,让用错的时候在代码上一眼能看出来。
  288. */
  289. function hgap(widthDp) {
  290. var view = new android.view.View(context);
  291. view.setLayoutParams(new android.widget.LinearLayout.LayoutParams(dp(widthDp), 1));
  292. return view;
  293. }
  294. /** 撑开剩余空间,用于「左标题 / 右控件」两栏布局。 */
  295. function flexible() {
  296. var view = new android.view.View(context);
  297. var lp = new android.widget.LinearLayout.LayoutParams(0, 1);
  298. lp.weight = 1;
  299. view.setLayoutParams(lp);
  300. return view;
  301. }
  302. /**
  303. * 横向行。
  304. *
  305. * fill 默认为 true:横向行几乎都是「左标签(weight=1) + 右控件」的结构,
  306. * **必须显式填满宽度**,否则父容器按 WRAP_CONTENT 测量时,
  307. * weight=1 的左侧标签会分到 0 宽 —— 真机上表现为「这一行的文字不见了」。
  308. * 需要包裹内容的场合(例如作为另一行右侧的控件组)显式传 false。
  309. */
  310. function row(fill) {
  311. var layout = new android.widget.LinearLayout(context);
  312. layout.setOrientation(android.widget.LinearLayout.HORIZONTAL);
  313. layout.setGravity(android.view.Gravity.CENTER_VERTICAL);
  314. if (fill === undefined || fill) {
  315. layout.setLayoutParams(new android.widget.LinearLayout.LayoutParams(-1, -2));
  316. }
  317. return layout;
  318. }
  319. /** 竖向容器,同样默认填满宽度(卡片、页面主体都属于这类)。 */
  320. function column(fill) {
  321. var layout = new android.widget.LinearLayout(context);
  322. layout.setOrientation(android.widget.LinearLayout.VERTICAL);
  323. if (fill === undefined || fill) {
  324. layout.setLayoutParams(new android.widget.LinearLayout.LayoutParams(-1, -2));
  325. }
  326. return layout;
  327. }
  328. /** 给 LinearLayout 统一挂 LayoutParams,省掉每处的重复样板。 */
  329. function params(view, widthDp, heightDp, weight) {
  330. var lp = new android.widget.LinearLayout.LayoutParams(
  331. widthDp >= 0 ? dp(widthDp) : widthDp,
  332. heightDp >= 0 ? dp(heightDp) : heightDp
  333. );
  334. if (weight) { lp.weight = weight; }
  335. view.setLayoutParams(lp);
  336. return view;
  337. }
  338. function marginParams(view, widthDp, heightDp, l, t, r, b) {
  339. var lp = new android.widget.LinearLayout.LayoutParams(
  340. widthDp >= 0 ? dp(widthDp) : widthDp,
  341. heightDp >= 0 ? dp(heightDp) : heightDp
  342. );
  343. lp.setMargins(dp(l), dp(t), dp(r), dp(b));
  344. view.setLayoutParams(lp);
  345. return view;
  346. }
  347. Theme.util = {
  348. dp: dp,
  349. argb: argb,
  350. alphaOf: alphaOf,
  351. solidBg: solidBg,
  352. strokeBg: strokeBg,
  353. gradientBg: gradientBg,
  354. glowBg: glowBg,
  355. fadeBg: fadeBg,
  356. tintBg: tintBg,
  357. screenBackground: screenBackground,
  358. text: text,
  359. monoText: monoText,
  360. box: box,
  361. spacer: spacer,
  362. hgap: hgap,
  363. flexible: flexible,
  364. row: row,
  365. column: column,
  366. params: params,
  367. marginParams: marginParams
  368. };
  369. /* 便捷短名:页面代码里 90% 的调用都是这几个,长名前缀会淹掉结构。 */
  370. var U = Theme.util;