"ui"; /*! tiktok-app v2026.10.13 * built 2026-10-29T10:43:51.193Z * modules: theme, store, records, sources, device, service, fingerprint, engine, session, resume, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。 */ (function () { /* ============================== src/theme.js ============================== */ /* ============================== src/theme.js ============================== * 设计令牌 + 绘制工具。 * * 所有颜色/尺寸/字号都取自 Ardot 设计稿(file 734917825641912), * 页面代码里**不允许出现裸色值或裸尺寸**——要改视觉先改这里。 * * 为什么用「代码建 View」而不是 XML 布局: * ① 设计稿用了渐变、径向光晕、多层描边、按状态换色,XML 属性表达不了, * 最后仍要落到代码里 setBackground(GradientDrawable),反而两套并存; * ② XML 字面量(E4X)不是标准 JS,PC 侧无法用 node 做语法检查与离线自检, * 而 AutoX 设备端是没有 linter 的(见规范 SKILL.md「设备端没有门禁」)。 * 纯代码建 View 让整个产物能在 PC 上被语法检查和桩测。 */ var Theme = {}; /* ---------------------------------------------------------------- 颜色 */ Theme.hex = { bg: "#030014", tabBar: "#0A0A17", dialogBg: "#17142B", accent: "#2EA6FF", accentText: "#7CC7FF", accentDeep: "#9381FF", /* 任务卡图标衬底的紫。设计稿里 UID私信 的图标框是 #9482FF@16%, * 与主色渐变里的紫(#9381FF)**不是同一个值** —— 不要"统一"成一个。 */ violet: "#9482FF", textPrimary: "#FFFFFF", textSecondary: "#CFCCE6", /* 弹窗里的设置名用的是更亮一档的正文色(设计稿 §6:正文 #CFCCE6 / #E6E4F5) */ textBody: "#E6E4F5", textMuted: "#9A97B8", textFaint: "#8E8BA8", /* 弱提示(区说明 / 「上次运行」/ 底部提示)。比 textFaint 再暗一档, * 三级文字层次来自设计稿 §6:次要 #9A97B8·#8E8BA8 / 弱提示 #6F6C8A。 */ textDim: "#6F6C8A", /* 未选中页签的颜色。**不要删**:它一旦缺失,argb(undefined) 会算出 0 * (全透明),非活动页签会整片消失 —— 而且 uiautomator 里它们的 * bounds 是正常的,只有肉眼看得出来,极难定位。 * 取值 2026-10-13 更新为 #8E8BA8:旧的 #7A7794 对比度只有 4.4:1, * 未达 WCAG AA(规格书 §9 第 5 条已统一改为新值)。 */ tabIdle: "#8E8BA8", /* 警示色:设计稿里「未开启」状态标签与「确认解绑」按钮用的是两种不同的暖色。 * warn 偏琥珀(权限告缺、卡密到期),danger 偏红(不可逆操作)。 */ warn: "#F5C24B", danger: "#FF5470", ok: "#13DDC4", divider: "#FFFFFF" }; /* 卡片与标签的填充/描边一律是「白色 + 透明度」,不是独立色值。 * 这样叠在深色背景与叠在渐变光晕上的观感一致。 */ Theme.alpha = { cardFill: 0.04, cardFillActive: 0.06, cardStroke: 0.08, cardStrokeActive: 0.35, chipFill: 0.06, chipStroke: 0.10, accentChipFill: 0.20, /* 全局运行条:青蓝 12% 填充(设计稿 B2 的 30:304,无描边) */ runBarFill: 0.12, runBarStroke: 0.28, /* 警示图标底 / 任务图标框:设计稿里是 14% 的同色衬底 */ warnFill: 0.14, iconBoxFill: 0.14, mask: 0.72, glowA: 0.30, glowB: 0.20 }; /* ---------------------------------------------------------------- 尺寸 */ Theme.m = { screenW: 375, contentPadding: 20, cardPadding: 14, cardRadius: 16, chipRadius: 8, pillRadius: 99, dialogRadius: 24, gapPage: 16, gapCard: 12, gapTight: 8, gapInline: 10, titleSize: 22, sectionSize: 16, labelSize: 13, chipSize: 11, hintSize: 11, tabSize: 11, rowHeight: 26, chipPadV: 6, chipPadH: 6, tabBarHeight: 49, tabIconSize: 22, runBtnHeight: 48, runBtnWidth: 335, runBarRadius: 12, runBarPadV: 12, runBarPadH: 10, warnIconSize: 40, warnIconRadius: 12, loginLogoSize: 72, loginLogoRadius: 20, loginBtnHeight: 48, dialogWidth: 315, statusBarHeight: 62, safeAreaHeight: 33, touchTarget: 44, /* ===== 2026-10-13 规格书新增(§6 尺寸与圆角 / 触控标准)===== */ /* 二级页返回头:高 44,返回热区 44×44,标题 21 SemiBold */ backHeaderHeight: 44, backHotSize: 44, backArrowSize: 20, detailTitleSize: 21, /* 任务卡(B1/B2):图标框 40 圆角 12 / 图标 22 / 卡名 15 / * 运行按钮 68×44(触控标准明确写了 68×44) */ taskIconBox: 40, taskIconSize: 22, taskIconRadius: 12, taskNameSize: 15, taskRunBtnW: 68, taskRunBtnH: 44, /* 记录条目(D1/D3):图标框 36 圆角 10 / 图标 20 / 卡片圆角 14 */ recordIconBox: 36, recordIconSize: 20, recordIconRadius: 10, recordCardRadius: 14, /* 状态徽标(空闲/运行中/未配置)与结果标签:全圆角小胶囊 */ badgePadH: 8, badgePadV: 3, /* 底栏整体高度 = 页签行 49 + 安全区 33(设计稿 §6) */ tabBarTotal: 83, /* 区间输入框:**单框宽 56、数字居中**(§6)。比早期实现的 64 窄, * 是为了让「标签 + 56 ~ 56」一行放得下而不挤。 */ rangeFieldW: 56, /* 内容区底部渐隐:y=577 h=80(§6),右侧滚动指示条 x=367 w=3 */ fadeHeight: 80, indicatorW: 3, /* 弹窗按钮:行动按钮 44 / 主按钮 46(A4 的去授权是 46) */ dialogBtnHeight: 44, dialogPrimaryHeight: 46, /* 日志行(D3):等宽 11 / 行距紧凑 */ logSize: 11 }; /* ---------------------------------------------------------------- 工具 */ /** dp → px。设计稿是 375 逻辑宽,必须换算后再交给原生控件。 */ function dp(value) { var density = 1; try { density = context.getResources().getDisplayMetrics().density; } catch (e) { /* 离线桩测环境没有 Android context,退化为 1:1,不影响逻辑正确性 */ density = 1; } return Math.round(value * density); } /** * "#2EA6FF" → Java 可用的 ARGB int(负数形式,与 colors.parseColor 同构)。 * * 色值写错时返回**醒目的洋红**而不是 0。0 是全透明: * 界面上的表现是「某块东西不见了」,不报错、不留痕 —— * tabIdle 掉过一次,整排非活动页签就是这么消失的, * 而 uiautomator 里它们的 bounds 一切正常,只有肉眼能发现。 * 洋红至少让错误在一眼之内就暴露出来。 */ function argb(hex) { var h = String(hex).replace("#", ""); if (h.length === 6) { h = "FF" + h; } var value = parseInt(h, 16); if (isNaN(value)) { console.log("[theme] 非法色值(已用洋红代替): " + hex); return -65281; /* 0xFFFF00FF */ } return value | 0; } /** 给 hex 色叠加不透明度,得到 ARGB int。#FFFFFF + 0.06 → 约 #0FFFFFFF */ function alphaOf(hex, alpha) { var h = String(hex).replace("#", ""); if (h.length === 8) { h = h.substring(2); } var a = Math.round(Math.max(0, Math.min(1, alpha)) * 255); return parseInt(("0" + a.toString(16)).slice(-2) + h, 16) | 0; } /** 纯色圆角背景。 */ function solidBg(colorInt, radiusDp) { var drawable = new android.graphics.drawable.GradientDrawable(); drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); drawable.setColor(colorInt); if (radiusDp) { drawable.setCornerRadius(dp(radiusDp)); } return drawable; } /** 圆角 + 描边(卡片默认形态)。 */ function strokeBg(fillAlpha, strokeAlpha, strokeHex, radiusDp) { var drawable = new android.graphics.drawable.GradientDrawable(); drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); drawable.setColor(alphaOf("#FFFFFF", fillAlpha)); drawable.setCornerRadius(dp(radiusDp)); drawable.setStroke(dp(1), alphaOf(strokeHex || "#FFFFFF", strokeAlpha)); return drawable; } /** 线性渐变(主按钮、选中态)。默认左→右,与设计稿一致。 */ function gradientBg(fromHex, toHex, radiusDp) { var drawable = new android.graphics.drawable.GradientDrawable(); drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); drawable.setOrientation(android.graphics.drawable.GradientDrawable.Orientation.LEFT_RIGHT); drawable.setColors([argb(fromHex), argb(toHex)]); if (radiusDp) { drawable.setCornerRadius(dp(radiusDp)); } return drawable; } /** 径向光晕:中心有色、边缘透明,用来复刻设计稿背景的两团光。 */ function glowBg(hex, alpha) { var drawable = new android.graphics.drawable.GradientDrawable(); var colors = [alphaOf(hex, alpha), alphaOf(hex, 0)]; drawable.setShape(android.graphics.drawable.GradientDrawable.OVAL); drawable.setGradientType(android.graphics.drawable.GradientDrawable.RADIAL_GRADIENT); drawable.setGradientRadius(dp(220)); drawable.setColors(colors); return drawable; } /** 页面根背景:底色 + 两团光晕,所有承载体统一从这里取,避免各自拼。 */ function screenBackground() { return solidBg(argb(Theme.hex.bg)); } /** * 竖向渐隐(透明 → 色),用在内容区底部(设计稿 §6「底部渐隐 y=577 h=80」)。 * * 为什么要它:内容区是可滚动的,卡片被底栏"硬切"一刀会让人以为列表到头了。 * 加一层渐隐,滚动的连续性一眼可见。 * 颜色取**页面底色**:叠在空内容上时它等于背景,不会露出多余的色块。 */ function fadeBg(hex) { var drawable = new android.graphics.drawable.GradientDrawable(); drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); drawable.setOrientation(android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM); drawable.setColors([alphaOf(hex, 0), argb(hex)]); return drawable; } /** 同色衬底(任务图标框 / 警示图标底):hex + 透明度,圆角方块。 */ function tintBg(hex, alpha, radiusDp) { var drawable = new android.graphics.drawable.GradientDrawable(); drawable.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); drawable.setColor(alphaOf(hex, alpha)); if (radiusDp) { drawable.setCornerRadius(dp(radiusDp)); } return drawable; } /* ------------------------------------------------------------- 基础控件 */ /** 统一的 TextView 工厂,防止每个页面各写一遍字号字色。 */ function text(content, sizeSp, hex, bold) { var view = new android.widget.TextView(context); view.setText(String(content === null || content === undefined ? "" : content)); view.setTextSize(sizeSp); view.setTextColor(argb(hex)); if (bold) { view.setTypeface(android.graphics.Typeface.DEFAULT_BOLD); } return view; } /** * 等宽数字(设计稿里 200 / 30、2026.10.10 用的是 JetBrains Mono)。 * 与 text() 一样带 undefined 兜底——String(undefined) 会变成界面上的 "undefined"。 */ function monoText(content, sizeSp, hex) { var view = new android.widget.TextView(context); view.setText(content === null || content === undefined ? "" : String(content)); view.setTextSize(sizeSp); view.setTextColor(argb(hex)); view.setTypeface(android.graphics.Typeface.MONOSPACE); return view; } function box(widthDp, heightDp) { var view = new android.view.View(context); view.setLayoutParams(new android.widget.LinearLayout.LayoutParams( widthDp >= 0 ? dp(widthDp) : widthDp, heightDp >= 0 ? dp(heightDp) : heightDp )); return view; } /** * 垂直间距。⚠️ 只能用在**竖向** LinearLayout 里。 * * 它的宽度是 MATCH_PARENT,放进横向容器会占满整行剩余空间, * 把同行后面那些 weight=1 的 View 挤成 0 宽(真机上表现为「文字全部消失」)。 * 横向间距请用 hgap()。 */ function spacer(heightDp) { return box(-1, heightDp); } /** * 横向间距。固定宽度、高度 1px。 * 与 spacer 的区别只在宽度策略,但用错了就是「文字不见了」这种难查的现象, * 所以刻意分成两个名字,让用错的时候在代码上一眼能看出来。 */ function hgap(widthDp) { var view = new android.view.View(context); view.setLayoutParams(new android.widget.LinearLayout.LayoutParams(dp(widthDp), 1)); return view; } /** 撑开剩余空间,用于「左标题 / 右控件」两栏布局。 */ function flexible() { var view = new android.view.View(context); var lp = new android.widget.LinearLayout.LayoutParams(0, 1); lp.weight = 1; view.setLayoutParams(lp); return view; } /** * 横向行。 * * fill 默认为 true:横向行几乎都是「左标签(weight=1) + 右控件」的结构, * **必须显式填满宽度**,否则父容器按 WRAP_CONTENT 测量时, * weight=1 的左侧标签会分到 0 宽 —— 真机上表现为「这一行的文字不见了」。 * 需要包裹内容的场合(例如作为另一行右侧的控件组)显式传 false。 */ function row(fill) { var layout = new android.widget.LinearLayout(context); layout.setOrientation(android.widget.LinearLayout.HORIZONTAL); layout.setGravity(android.view.Gravity.CENTER_VERTICAL); if (fill === undefined || fill) { layout.setLayoutParams(new android.widget.LinearLayout.LayoutParams(-1, -2)); } return layout; } /** 竖向容器,同样默认填满宽度(卡片、页面主体都属于这类)。 */ function column(fill) { var layout = new android.widget.LinearLayout(context); layout.setOrientation(android.widget.LinearLayout.VERTICAL); if (fill === undefined || fill) { layout.setLayoutParams(new android.widget.LinearLayout.LayoutParams(-1, -2)); } return layout; } /** 给 LinearLayout 统一挂 LayoutParams,省掉每处的重复样板。 */ function params(view, widthDp, heightDp, weight) { var lp = new android.widget.LinearLayout.LayoutParams( widthDp >= 0 ? dp(widthDp) : widthDp, heightDp >= 0 ? dp(heightDp) : heightDp ); if (weight) { lp.weight = weight; } view.setLayoutParams(lp); return view; } function marginParams(view, widthDp, heightDp, l, t, r, b) { var lp = new android.widget.LinearLayout.LayoutParams( widthDp >= 0 ? dp(widthDp) : widthDp, heightDp >= 0 ? dp(heightDp) : heightDp ); lp.setMargins(dp(l), dp(t), dp(r), dp(b)); view.setLayoutParams(lp); return view; } Theme.util = { dp: dp, argb: argb, alphaOf: alphaOf, solidBg: solidBg, strokeBg: strokeBg, gradientBg: gradientBg, glowBg: glowBg, fadeBg: fadeBg, tintBg: tintBg, screenBackground: screenBackground, text: text, monoText: monoText, box: box, spacer: spacer, hgap: hgap, flexible: flexible, row: row, column: column, params: params, marginParams: marginParams }; /* 便捷短名:页面代码里 90% 的调用都是这几个,长名前缀会淹掉结构。 */ var U = Theme.util; /* ============================== src/store.js ============================== */ /* ============================== src/store.js ============================== * 数据模型 + 持久化 + 校验。 * * 这一层只做三件事:给默认值、读回来、写出去。**不做界面**,也不碰设备。 * 页面代码永远通过 Store.get()/Store.set() 拿值,不直接碰 storages。 * * ── 核心设计:默认值只有一个来源 ───────────────────────────── * 所有字段都在下面的 Store.FIELDS 里声明一次(路径 / 类型 / 范围 / 默认值), * `defaults()` 与 `validate()` 都由它推导。 * * 为什么必须这样:早先的版本里默认值写在 defaults()、校验规则写在 validate()、 * 范围写在另一张 LIMITS 表里——三处各写一半,结果就是 * 「LIMITS 遍历时把视频页的 stopCount 也写进了私信页对象,而默认值取的是 * 私信页不存在的 d.tabs.dm.stopCount」→ 真的往配置里塞了个 undefined。 * 一张表就没有这种漂移。 */ var Store = {}; /* v8(2026-10-17):ID提取页从「四张单选脚本卡」改成**三段式** * (① 取材清单 ② 只要这样的账号 ③ 输出)。 * * 四张卡的 23 个字段全部删除,清单改由 `Sources` 模块单独持久化 * (不定长列表进不了这张标量表)。旧配置由 `Sources.migrateLegacy()` * 翻译成清单,用户的选项一个不丢。 * * v7(同日早些时候)是"清语义"那一档:删同义反复的开关、补缺输入的开关、 * 功能设置分三组。那一档的成果(36 个字段、三组结构、置灰方式)v8 全部继承。 * * v11(2026-10-29):补执行「私信休息时间 100~500 → 10~40」。 * 9 号迁移用严格相等判断,碰上字符串型的旧值会静默放过、版本却被抬高, * 从此永久卡在旧默认。这次用 Number() 比较再补一次。 */ Store.SCHEMA_VERSION = 11; Store.STORAGE_NAME = "feizhou_app"; Store.STORAGE_KEY = "config"; /** 业务文本文件的约定目录。界面上的「选择文件」默认指向这里。 */ Store.TEXT_DIR = "/sdcard/Download/tiktok_app"; /* ------------------------------------------------ 下拉/开关的候选值集中定义 */ Store.options = { dedupe: ["ID 模式", "UID 模式"], enterPage: ["粉丝页", "评论页", "主页", "关注页"], /* 「从哪个页面取」与「展开哪个列表」是**两件事**,v7 之前共用一个 enterPage, * 于是 s3(文本裂变)里能选到「评论页」—— 而在"展开文本里这些人的列表"这个语义下, * 评论页根本不是合法值。分开之后 s3 只能选粉丝 / 关注。 */ expandList: ["粉丝列表", "关注列表"], /* 脚本二「视频从哪来」:原来只有一个"自定评论页面提 ID"开关,勾上却没说 * 从哪条视频开始 —— 补上这个来源维度,那个开关才有意义。 */ videoSource: ["当前屏幕", "关键词搜索"], aiGender: ["男", "女"], toggle: ["开启", "关闭"], dedupeMode: ["关注+私信去重", "仅关注去重", "仅私信去重"], filterDate: ["7 天内", "30 天内", "不限"], firstAction: ["都执行", "仅点赞", "仅评论"], langRegion: ["通杀", "仅中文", "仅英文"], nicknameCheck: ["通杀", "仅检测昵称", "仅检测个签"], /* 点赞 / 评论 / 关注概率。设计稿把它们做成固定档位 —— * 养号动作的强度直接决定账号安全,给几个安全档位比让用户随手填 90% 更负责。 */ likeRate: ["10%", "20%", "30%", "50%"], commentRate: ["5%", "10%", "20%", "30%"], followRate: ["5%", "10%", "15%", "30%"] }; /* 候选值 ↔ 存储值的双向映射。 * 界面显示中文,存储里存稳定英文 key——改文案时不会把老数据作废。 */ Store.enum = { dedupe: { "id": "ID 模式", "text": "UID 模式" }, /* 「关注页」是脚本三要用的(设计稿 16:533 的下拉就是它)。 * 新增候选一律**追加在末尾**:位置即下拉里的顺序,插在中间会让老用户 * 下次点开下拉时发现选项换了地方。 */ enterPage: { "fans": "粉丝页", "comment": "评论页", "home": "主页", "follow": "关注页" }, /* v7:只含"能列出一串人的页"。s3 用它,与 enterPage 语义分开。 */ expandList: { "fans": "粉丝列表", "follow": "关注列表" }, videoSource: { "screen": "当前屏幕", "keyword": "关键词搜索" }, aiGender: { "male": "男", "female": "女" }, toggle: { "on": "开启", "off": "关闭" }, dedupeMode: { "follow_dm": "关注+私信去重", "follow": "仅关注去重", "dm": "仅私信去重" }, filterDate: { "7d": "7 天内", "30d": "30 天内", "all": "不限" }, firstAction: { "both": "都执行", "like": "仅点赞", "comment": "仅评论" }, langRegion: { "any": "通杀", "zh": "仅中文", "en": "仅英文" }, nicknameCheck: { "any": "通杀", "nick": "仅检测昵称", "sign": "仅检测个签" }, likeRate: { "r10": "10%", "r20": "20%", "r30": "30%", "r50": "50%" }, commentRate: { "r5": "5%", "r10": "10%", "r20": "20%", "r30": "30%" }, followRate: { "r5": "5%", "r10": "10%", "r15": "15%", "r30": "30%" }, licenseStatus: { "none": "未登录", "active": "已登录", "expired": "已过期" }, /* ★ 2026-10-13 规格书 §1:底栏只剩 **3 个 tab**(任务 / 记录 / 我的)。 * 原来的 id/dm/nurture/video 四个页签降级为「任务」tab 里的四张任务卡, * 各自的详情页 (C1/C6/C8/C9) 变成二级页。v6 迁移负责把老存档的 * activeTab(例如 "nurture")映射成 "tasks",否则老用户启动会落回默认页。 */ tab: { "tasks": "任务", "records": "记录", "mine": "我的" } }; /** 某组枚举的全部 key。 */ function enumKeys(group) { var map = Store.enum[group] || {}; var out = []; for (var k in map) { if (map.hasOwnProperty(k)) { out.push(k); } } return out; } /** * 枚举 key → 显示文案。 * * 关键兜底:key 缺失或非法时**回退到该枚举的第一项**,绝不返回 "undefined"。 * 选择框显示成空白或 "undefined" 是用户最容易看到、也最说不清的一类 bug, * 在数据层一次堵死比在每个页面各写一遍兜底可靠。 */ Store.label = function (group, key) { var map = Store.enum[group]; if (!map) { return key === undefined || key === null ? "" : String(key); } if (key === undefined || key === null || map[key] === undefined) { var first = enumKeys(group)[0]; return first === undefined ? "" : map[first]; } return map[key]; }; /** 当前值在候选列表里的位置,供下拉框设定初始选中项。 */ Store.indexOfLabel = function (group, key) { var list = Store.options[group] || []; return Math.max(0, list.indexOf(Store.label(group, key))); }; /** 依位置取回 key,供下拉框选中后写回。 */ Store.keyAt = function (group, index) { var list = Store.options[group] || []; var label = list[index]; var map = Store.enum[group] || {}; for (var k in map) { if (map.hasOwnProperty(k) && map[k] === label) { return k; } } return label; }; /* ------------------------------------------------------------ 字段声明表 */ /** 生成「勾选 + 展开」两个字段——所有脚本卡都有这两个开关。 */ function cardFields(basePath, checked, expanded) { return [ { path: basePath + ".checked", type: "bool", def: !!checked }, { path: basePath + ".expanded", type: "bool", def: !!expanded } ]; } var F = []; /* ——— 全局 ——— */ F.push( /* values 与 Store.enum.tab 必须一致(曾经少了 "mine": * 切到「我的」后重启会落回 ID提取,因为写入端的归一化认不出这个值)。 * v6 起是三个 tab:任务 / 记录 / 我的。 */ { path: "activeTab", type: "enum", values: ["tasks", "records", "mine"], def: "tasks" }, /* 二级页(任务详情 / 运行详情)也落盘:用户切走再回来,还停在原来那个详情页。 * 存 key 而不是存 View —— 值域与「任务清单」一一对应,非法值一律回退空串(= 一级页)。 */ { path: "activeDetail", type: "enum", values: ["", "id", "dm", "nurture", "video"], def: "" }, { path: "license.code", type: "text", def: "" }, /* 登录页输入框里的卡密**草稿**:手打 16 位卡密时被系统回收进程,重开还在。 * 激活成功后**不清它**:解绑后回到登录页,上次的卡密已经填好,一键即可重新激活 * (它和 `license.code` 在同一个 JSON 里,多留一份不增加暴露面)。 * 顺带避开一个坑:清它会被登录页输入框的「切屏前整体提交」又写回来。 */ { path: "license.draftCode", type: "text", def: "" }, { path: "license.status", type: "enum", values: ["none", "active", "expired"], def: "none" }, { path: "license.expireAt", type: "text", def: "" } ); /* ——— 全局悬浮球(规格书 §10 / H1–H7)——— * 三个字段都是「记忆用户的选择」,没有它们球每次都会跑回默认位置: * · hidden —— 用户点了「隐藏悬浮球」。没有它就只能靠"下次启动又冒出来", * 而那是最招人烦的一种行为;恢复入口在「我的」页(本项目补充,规格书未写)。 * · side / yRatio —— 拖动吸附后的位置。存**比例**而不是像素:换机型、 * 换分辨率、旋转后仍然落在相对合理的位置(存像素就会出现"球跑到屏幕外")。 */ F.push( { path: "ball.hidden", type: "bool", def: false }, { path: "ball.side", type: "enum", values: ["left", "right"], def: "left" }, { path: "ball.yRatio", type: "int", min: 0, max: 1000, def: 500 } ); /* ——— ID提取页 ——— * ★ v8(2026-10-17):四张脚本卡的 **23 个字段全部删除**。 * * 为什么删而不是继续改:四张卡是「起点 × 深度」的 4 个**不完整组合** * (各缺一块、各有别人没有的一块,合起来才完整)。这个结构本身就是"乱"的根 —— * 于是用户想同时抓两种来源时,单选约束直接不允许。 * v8 把它们换成**取材清单**:可累加、每条自带起点与深度、按顺序串行执行。 * * 清单**不在这张表里**:它是不定长列表,放进来就等于让 validate() 去校验数组, * 还会把「路径 → 值控件」那套审计规则带歪。它走 `Sources` 模块自己的 storage key * (照 `Records` 先例,理由见 `src/sources.js` 文件头)。 * * 旧配置由 `Sources.migrateLegacy()` 翻译成清单(`Store.load` 末尾调用): * 用户原来的选择一个都不丢 —— s4 的三个开关甚至会展开成**三条**取材。 */ /* ID提取页的「功能设置」(设计稿 16:733)。 * 门槛类字段(关注量/粉丝量/获赞量/视频量)按「一个控件一个数」拆成 min/max 两项, * 而不是设计稿里的 0 ~ 500000 一个控件 —— 与 UID私信页的上限处理保持同一套规则。 */ F.push( { path: "tabs.id.features.filterPrivate", type: "bool", def: true }, { path: "tabs.id.features.filterNoAvatar", type: "bool", def: true }, { path: "tabs.id.features.regionCodes", type: "text", def: "JP#TW#US" }, { path: "tabs.id.features.langRegion", type: "enum", group: "langRegion", def: "any" }, { path: "tabs.id.features.nicknameCheck", type: "enum", group: "nicknameCheck", def: "any" }, { path: "tabs.id.features.cloudDedupe", type: "bool", def: false }, /* optional 标记的字段允许默认空串:云端去重默认是关的, * 「还没填表名」是一个真实且合理的状态,不该被塞一个假默认值。 * 取值审计靠这个标记把它和「漏了默认值」区分开。 */ { path: "tabs.id.features.cloudTable", type: "text", def: "", optional: true }, /* v7:本地去重从脚本卡收到这里,全页只出现一次。 * 「跨轮去重」是另一件事:与历史已提取的 ID 比对,所以单独一个开关 —— * 原来用「ID 模式 / UID 模式」两个词表达它,用户根本看不出那是在说"要不要跟历史比"。 */ { path: "tabs.id.features.dedupeLocal", type: "bool", def: true }, { path: "tabs.id.features.dedupeHistory", type: "bool", def: false }, { path: "tabs.id.features.aiAvatar", type: "bool", def: false }, { path: "tabs.id.features.aiGender", type: "enum", group: "aiGender", def: "male" }, { path: "tabs.id.features.aiAgeMax", type: "int", min: 0, max: 100, def: 20 }, { path: "tabs.id.features.followMin", type: "int", min: 0, max: 100000000, def: 0 }, { path: "tabs.id.features.followMax", type: "int", min: 0, max: 100000000, def: 500000 }, { path: "tabs.id.features.fansMin", type: "int", min: 0, max: 100000000, def: 0 }, { path: "tabs.id.features.fansMax", type: "int", min: 0, max: 100000000, def: 500000 }, { path: "tabs.id.features.likesMin", type: "int", min: 0, max: 100000000, def: 0 }, { path: "tabs.id.features.likesMax", type: "int", min: 0, max: 100000000, def: 500000 }, { path: "tabs.id.features.videosMin", type: "int", min: 0, max: 100000000, def: 0 }, { path: "tabs.id.features.videosMax", type: "int", min: 0, max: 100000000, def: 500000 }, /* 0 = 不限制。用「0 表示不限」而不是留空,是因为数字输入框留空无法表达。 */ { path: "tabs.id.features.extractLimit", type: "int", min: 0, max: 100000, def: 0 }, /** * 抓完之后是否顺手把 username 换成 TikTok 内部的数字 uid,另存一份映射文件。 * * 默认开:数字 uid 在 App 界面上任何地方都读不到,不在这里换就得事后另跑一次。 * 但每个要多下约 400KB 的主页 SSR(约 2.5 秒),只要 handle 的场景可以关掉。 * 2026-10-28 之前这个开关只存在于引擎配置里(界面写死 true),想关得改代码。 */ { path: "tabs.id.features.resolveUid", type: "bool", def: true }, /** * 运行时长的**下限**(分钟)。 * * 它不是"最长多久",而是"至少跑这么久"—— 引擎会按提取上限自动放大: * 每个收录约 20 秒(开过滤时要逐个开用户页,含被跳过的折算), * 所以 limit=100 会自动给到约 38 分钟,limit=1000 给到 4 小时(封顶)。 * * 界面上写「至少运行 __ 分钟(按提取上限自动放宽,最多 4 小时)」。 */ { path: "tabs.id.features.maxRuntimeMin", type: "int", min: 1, max: 240, def: 30 } ); /* ——— UID私信页 ——— */ F.push.apply(F, cardFields("tabs.dm.scripts.s1", true, true)); F.push( { path: "tabs.dm.scripts.s1.doDm", type: "bool", def: true }, { path: "tabs.dm.scripts.s1.filterDate", type: "enum", group: "filterDate", def: "7d" }, { path: "tabs.dm.scripts.s1.firstAction", type: "enum", group: "firstAction", def: "both" }, { path: "tabs.dm.scripts.s1.uidTextPath", type: "text", def: Store.TEXT_DIR + "/uid_list.txt" } ); F.push.apply(F, cardFields("tabs.dm.scripts.s2", false, false)); F.push( { path: "tabs.dm.aiGender", type: "enum", group: "aiGender", def: "male" }, /* 关注与私信是两个独立动作,各自一个开关 + 一个数量区间。 * 早先把它们挤成「进行关注 / 进行私信」一个开关、「200 / 30」一个控件, * 结果是想单独关掉私信就得连关注一起关掉。 * 数量本身是**区间**(最少关注多少、最多关注多少),所以是一行两项。 */ { path: "tabs.dm.doFollow", type: "bool", def: true }, { path: "tabs.dm.followMin", type: "int", min: 0, max: 5000, def: 100 }, { path: "tabs.dm.followMax", type: "int", min: 0, max: 5000, def: 200 }, { path: "tabs.dm.doDm", type: "bool", def: true }, { path: "tabs.dm.dmMin", type: "int", min: 0, max: 1000, def: 20 }, { path: "tabs.dm.dmMax", type: "int", min: 0, max: 1000, def: 30 }, { path: "tabs.dm.restMin", type: "int", min: 0, max: 3600, def: 10 }, { path: "tabs.dm.restMax", type: "int", min: 0, max: 3600, def: 40 }, { path: "tabs.dm.dedupeMode", type: "enum", group: "dedupeMode", def: "follow_dm" }, { path: "tabs.dm.dmTextPath", type: "text", def: Store.TEXT_DIR + "/dm_text.txt" }, { path: "tabs.dm.commentTextPath", type: "text", def: Store.TEXT_DIR + "/comment_text.txt" } ); /* ——— 养号页 ——— * 参数与脚本卡分开存:即使不勾选脚本,用户也能先把这组参数调好。 * 浏览时长与单次操作都是区间(两个数),概率仍然是固定档位。 */ F.push.apply(F, cardFields("tabs.nurture.scripts.s1", true, false)); F.push( { path: "tabs.nurture.browseMin", type: "int", min: 0, max: 600, def: 3 }, { path: "tabs.nurture.browseMax", type: "int", min: 0, max: 600, def: 8 }, { path: "tabs.nurture.likeRate", type: "enum", group: "likeRate", def: "r30" }, { path: "tabs.nurture.commentRate", type: "enum", group: "commentRate", def: "r10" }, { path: "tabs.nurture.followRate", type: "enum", group: "followRate", def: "r15" }, { path: "tabs.nurture.actionMin", type: "int", min: 0, max: 500, def: 10 }, { path: "tabs.nurture.actionMax", type: "int", min: 0, max: 500, def: 30 } ); /* ——— 视频功能页 ——— * keyword / region 给的是**可直接跑的业务默认值**而不是空串: * 空着的输入框既看不出该填什么,也让「未配置」和「配了空值」无法区分。 */ F.push.apply(F, cardFields("tabs.video.scripts.s1", false, false)); F.push( { path: "tabs.video.keyword", type: "text", def: "fashion" }, { path: "tabs.video.region", type: "text", def: "US" }, { path: "tabs.video.stopMin", type: "int", min: 1, max: 500, def: 1 }, { path: "tabs.video.stopMax", type: "int", min: 1, max: 500, def: 5 }, { path: "tabs.video.intervalMin", type: "int", min: 0, max: 3600, def: 20 }, { path: "tabs.video.intervalMax", type: "int", min: 0, max: 3600, def: 20 }, { path: "tabs.video.actions.follow", type: "bool", def: true }, { path: "tabs.video.actions.dm", type: "bool", def: true }, { path: "tabs.video.actions.like", type: "bool", def: false }, { path: "tabs.video.actions.comment", type: "bool", def: false }, { path: "tabs.video.dmTextPath", type: "text", def: Store.TEXT_DIR + "/dm_text.txt" }, { path: "tabs.video.commentTextPath", type: "text", def: Store.TEXT_DIR + "/comment_text.txt" } ); Store.FIELDS = F; /** * 跨字段约束:上界不得小于下界。 * 单字段的 min/max 表达不了这种关系,所以单独列出来做后处理。 * * 界面上每一个 `W.rangeRow` 都必须在下面有一对 —— * 漏一条的后果是「最小值 500 / 最大值 100」这种自相矛盾的组合能存进配置, * 而且因为区间行的两个数在**同一行**,错位比拆两行时更刺眼。 */ Store.CROSS_RULES = [ { low: "tabs.dm.followMin", high: "tabs.dm.followMax" }, { low: "tabs.dm.dmMin", high: "tabs.dm.dmMax" }, { low: "tabs.dm.restMin", high: "tabs.dm.restMax" }, { low: "tabs.nurture.browseMin", high: "tabs.nurture.browseMax" }, { low: "tabs.nurture.actionMin", high: "tabs.nurture.actionMax" }, { low: "tabs.video.stopMin", high: "tabs.video.stopMax" }, { low: "tabs.video.intervalMin", high: "tabs.video.intervalMax" }, { low: "tabs.id.features.followMin", high: "tabs.id.features.followMax" }, { low: "tabs.id.features.fansMin", high: "tabs.id.features.fansMax" }, { low: "tabs.id.features.likesMin", high: "tabs.id.features.likesMax" }, { low: "tabs.id.features.videosMin", high: "tabs.id.features.videosMax" } ]; /* ---------------------------------------------------------------- 路径工具 */ function isPlainObject(value) { return value !== null && typeof value === "object" && !(value instanceof Array); } function readPath(obj, path) { var segs = String(path).split("."); var node = obj; for (var i = 0; i < segs.length; i++) { if (node === null || typeof node !== "object") { return undefined; } node = node[segs[i]]; } return node; } function assignPath(obj, path, value) { var segs = String(path).split("."); var node = obj; for (var i = 0; i < segs.length - 1; i++) { if (!isPlainObject(node[segs[i]])) { node[segs[i]] = {}; } node = node[segs[i]]; } node[segs[segs.length - 1]] = value; return obj; } /* ---------------------------------------------------------------- 默认值 */ /** 从字段表推导默认配置——默认值只有这一处来源,不会与校验规则漂移。 */ Store.defaults = function () { var cfg = { version: Store.SCHEMA_VERSION }; for (var i = 0; i < Store.FIELDS.length; i++) { var f = Store.FIELDS[i]; assignPath(cfg, f.path, f.def); } return cfg; }; /* ---------------------------------------------------------------- 版本迁移 */ /** * 逐版本迁移。键是「从哪个版本升上来」,函数把配置就地改造成下一版的样子。 * * 参数说明: * cfg —— 已经按当前字段表归一化过的配置(结构完整、类型正确) * src —— **原始输入**。有些旧字段在新版字段表里已被删除, * 归一化后 cfg 里就没有它了,只能从 src 里读回来。 * * 为什么必须有迁移:类型归一化只能处理「类型不对」, * 处理不了「语义变了」——例如 v1 的空串其实等于未设置、 * v2 的一个开关其实要拆成两个。这类差异只有迁移能表达。 */ Store.MIGRATIONS = { 1: function (cfg, problems) { /* v1 里这几项的默认值都是空串,界面上表现为空框 */ var emptyInV1 = [ "tabs.video.keyword", "tabs.video.region", "tabs.dm.dmTextPath", "tabs.dm.commentTextPath", "tabs.video.dmTextPath", "tabs.video.commentTextPath" ]; for (var i = 0; i < emptyInV1.length; i++) { var path = emptyInV1[i]; if (readPath(cfg, path) === "") { var def = Store.defaultOf(path); if (def) { assignPath(cfg, path, def); problems.push(path + " 旧版为空,已补默认值 " + def); } } } }, 2: function (cfg, problems, src) { /* v2 用一个 doFollowDm 同时控制关注与私信,v3 拆成两个独立开关。 * 旧值同时赋给两者,保证升级后行为与升级前一致(都是开就是都开)。 */ var legacy = readPath(src, "tabs.dm.doFollowDm"); if (typeof legacy === "boolean") { assignPath(cfg, "tabs.dm.doFollow", legacy); assignPath(cfg, "tabs.dm.doDm", legacy); problems.push("tabs.dm.doFollowDm 已拆为「进行关注」「进行私信」两个开关,旧值 " + legacy + " 已沿用"); } }, /** * v4 → v5:五处「一个值」改成「一个区间」。 * * 迁移原则是**不改变原有行为**,具体分三类: * · 原本是「上限」的(关注上限 / 私信上限 / 单次操作上限) * → min 给 0(= 不设下界),max 沿用旧上限。 * 旧语义就是「最多 N 个」,升级后仍然是最多 N 个。 * · 原本是「精确个数」的(停止个数) * → min = max = 旧值。这跟设计稿里「间隔时间 20 ~ 20」是同一个表达: * 相等的上下界就是定值。 * · 原本是「档位」的(浏览时长 3 ~ 8 这类) * → 直接取该档的上下界,一一对应。 * 全新安装拿到的则是设计稿给的默认区间(例如 关注数量 100 ~ 200)。 */ 4: function (cfg, problems, src) { /* 旧档位 → 区间。两组档位的 key 本身不重叠(d3_8… / n10…), * 所以用一张平表就够,不必再按字段名分一层。 */ var RANGE_OF = { "d3_8": [3, 8], "d5_10": [5, 10], "d8_15": [8, 15], "n10": [0, 10], "n20": [0, 20], "n30": [0, 30], "n50": [0, 50] }; inheritRange(src, cfg, problems, "tabs.dm.followLimit", 0, "upper", "tabs.dm.followMin", "tabs.dm.followMax", "关注数量"); inheritRange(src, cfg, problems, "tabs.dm.dmLimit", 0, "upper", "tabs.dm.dmMin", "tabs.dm.dmMax", "私信数量"); inheritRange(src, cfg, problems, "tabs.nurture.actionLimit", 0, "upper", "tabs.nurture.actionMin", "tabs.nurture.actionMax", "单次操作", RANGE_OF); inheritRange(src, cfg, problems, "tabs.nurture.browseDuration", 0, "upper", "tabs.nurture.browseMin", "tabs.nurture.browseMax", "浏览时长(分钟)", RANGE_OF); inheritRange(src, cfg, problems, "tabs.video.stopCount", 0, "exact", "tabs.video.stopMin", "tabs.video.stopMax", "停止个数"); }, /** * v5 → v6:底栏从 5 个页签收敛成 3 个 tab(任务 / 记录 / 我的)。 * * 这不是"改个名字":原来的 id/dm/nurture/video 四个页签 * **降级成「任务」tab 里的四张任务卡**,各自的详情页成了二级页。 * 所以老存档里 `activeTab = "nurture"` 必须映射成 `"tasks"` —— * 不映射的话写入端的枚举归一化会把非法值回退成默认值, * 表面上"没问题",但老用户会发现"我上次停在养号页,重启跑到任务列表了", * 而这恰恰是**无法解释**的那类现象。 * * 四个旧页签的**配置数据一个都没动**(`tabs.id.*` / `tabs.dm.*` / … 原样保留), * 只是入口层级变了 —— 迁移只碰入口,不碰业务配置。 */ 5: function (cfg, problems, src) { var OLD_TABS = ["id", "dm", "nurture", "video"]; var oldTab = readPath(src, "activeTab"); if (OLD_TABS.indexOf(oldTab) >= 0) { assignPath(cfg, "activeTab", "tasks"); /* 老用户在某个任务页上 → 直接把他带回那个任务的详情页, * 而不是只扔到任务列表让他自己再点一次。 */ assignPath(cfg, "activeDetail", oldTab); problems.push("activeTab " + oldTab + " → tasks(该任务已升为二级页,已直接停在它的详情页)"); } else if (oldTab === "mine") { assignPath(cfg, "activeTab", "mine"); } }, /** * v6 → v7:ID提取页清语义(需求重整 A 档)。 * * 两件必须做的,都是"类型系统看不出来的语义差异": * * ① **s3 的「进入页面」值域收窄**。原来是共用 `enterPage` 的四个值 * (粉丝页/评论页/主页/关注页),但这个字段在脚本三里的意思是 * "展开文本里这些人的哪个**列表**" —— 评论页和主页根本不是列表。 * 存量值是这两个的,一律落到「关注列表」(脚本三的设计稿默认值)。 * 不处理的话,`normalizeField` 会把非法枚举悄悄退回默认值, * 用户只会看到"我选过的选项自己变了",而不知道发生了什么。 * * ② **去重从卡片搬到功能设置**。旧的 `dedupeEnabled` 是用户对"要不要去重" * 的真实表达,不能因为控件搬家就丢掉 —— 把它继承到 `dedupeLocal`。 * 旧 `dedupe`(ID 模式 / UID 模式)表达的其实是"跟不跟历史比", * 映射到新增的 `dedupeHistory`("UID 模式"= 按输入文本比 = 留了个文本基准, * 语义上更接近"跨轮")。这条映射**不完美**,所以写进 problems 里说清楚。 */ 6: function (cfg, problems, src) { /* ⚠️ v8 起 s3 的「进入页面」字段已经随四张卡一起删除, * 这里不再对它做归一化 —— 那段值现在由 `Sources.migrateLegacy()` * 从原始 src 直接读,翻译成取材的「展开哪个列表」。 * 原来那条 assignPath 会写到一个**已不存在的路径**上,纯属幽灵写入。 */ var oldDedupe = readPath(src, "tabs.id.scripts.s1.dedupeEnabled"); if (typeof oldDedupe === "boolean") { assignPath(cfg, "tabs.id.features.dedupeLocal", oldDedupe); problems.push("「主播去重」已并入功能设置 →「本地去重」,旧值 " + oldDedupe + " 已沿用"); } var oldMode = readPath(src, "tabs.id.scripts.s1.dedupe"); if (oldMode === "text") { assignPath(cfg, "tabs.id.features.dedupeHistory", true); problems.push("旧去重模式「UID 模式」→「跨轮去重」已打开(按输入文本比对历史)"); } }, 9: function (cfg, problems, src) { /* v9 → v10:私信「休息时间」的默认值 100~500 秒 → 10~40 秒。 * * ★ 键的意义是**"从哪个版本升上来"**,不是"升到哪个版本" —— * 执行循环是 `for (v = fromVersion; v < SCHEMA_VERSION; v++) MIGRATIONS[v]`, * 所以想从 v8 升到 v9 要写 `8:`。这条最初写成 `9:`, * 于是 fromVersion=8 时查的是不存在的 `MIGRATIONS[8]`,**静默跳过**, * 设备被标成 v9 而值仍是旧的 100~500。所以这次把版本再抬一版(v10)来补执行。 * * 为什么改这个默认值:它是**每处理完一个 UID 之后**的休息, * 而关注 100~200 + 私信 20~30 是两批不同的人,合计 120~230 个 UID —— * 按旧默认一轮要跑 5~24 小时,实际根本跑不完。 * * 只改**还停在旧默认值**的配置;用户手动调过的保持原样。 * 全新安装(src 为空)读到的是 undefined,条件不成立,保留新默认。 */ var restMin = Number(readPath(src, "tabs.dm.restMin")); var restMax = Number(readPath(src, "tabs.dm.restMax")); if (restMin === 100 && restMax === 500) { assignPath(cfg, "tabs.dm.restMin", 10); assignPath(cfg, "tabs.dm.restMax", 40); problems.push("私信「休息时间」默认值由 100~500 秒改为 10~40 秒" + "(原值会让一轮跑 5~24 小时,已跟随新默认)"); } }, 10: function (cfg, problems, src) { /* v10 → v11:**同一条修复的补执行**(上面的 9 号迁移在真机上漏了)。 * * 为什么还要再来一次:那条迁移当时用 `restMin === 100` 做判断, * 而设备上存的可能是字符串 `"100"`(不同写入路径留下的),严格相等匹配不上 → * 迁移"跑了但什么也没改",版本却被抬到 10,于是**永久卡在 100~500**: * 之后每次启动 fromVersion 都等于 SCHEMA,这条迁移再也不会执行。 * 现象是"界面上写着 100~500 秒,引擎实际按 10~40 跑"(引擎侧有一道读取兜底), * 两边不一致 —— 这比单纯显示旧默认更难查。 * * 这次用 Number() 比较,并且**只认这一对精确的旧默认值**: * 用户自己调过的任何一组数都不会被覆盖。 */ var lo = Number(readPath(src, "tabs.dm.restMin")); var hi = Number(readPath(src, "tabs.dm.restMax")); if (lo === 100 && hi === 500) { assignPath(cfg, "tabs.dm.restMin", 10); assignPath(cfg, "tabs.dm.restMax", 40); problems.push("私信「休息时间」仍是最初的 100~500 秒,已按 10~40 秒修正" + "(原值一轮要跑 5~24 小时)"); } } }; /** * 把一个**已被删除**的旧字段迁移成一对区间字段。 * * 为什么必须从 src 里读:旧字段已经不在 `Store.FIELDS` 里, * 归一化之后的 cfg 里根本没有它 —— 只有原始输入还留着。 * * mode: * "upper" —— 旧值是上限 → min 给 fallback(通常是 0),max = 旧值 * "exact" —— 旧值是精确量 → min = max = 旧值 * mapping —— 若给了映射表,旧值是里面的档位 key,直接取对应的一对数字 */ function inheritRange(src, cfg, problems, legacyPath, fallback, mode, minPath, maxPath, label, mapping) { var legacy = readPath(src, legacyPath); if (legacy === undefined || legacy === null) { return; } var low = fallback; var high = fallback; if (mapping) { var pair = mapping[legacy]; if (!pair) { return; } low = pair[0]; high = pair[1]; } else { var n = Number(legacy); if (isNaN(n)) { return; } low = (mode === "exact") ? n : fallback; high = n; } assignPath(cfg, minPath, low); assignPath(cfg, maxPath, high); problems.push(legacyPath + " → " + label + " " + low + " ~ " + high + "(旧值 " + legacy + " 已沿用)"); } /* ---------------------------------------------------------------- 校验 */ /** 单字段归一化。只有「确实非法」才记问题——缺失属正常迁移,不刷屏。 */ function normalizeField(raw, field, problems) { var missing = raw === undefined || raw === null; if (field.type === "bool") { return missing ? field.def : !!raw; } if (field.type === "int") { var n = Number(raw); if (missing || raw === "" || isNaN(n)) { if (!missing && raw !== "") { problems.push(field.path + " 不是数字(" + raw + "),已用默认值 " + field.def); } return field.def; } n = Math.round(n); if (field.min !== undefined && n < field.min) { problems.push(field.path + " " + n + " 低于下限,已抬到 " + field.min); n = field.min; } if (field.max !== undefined && n > field.max) { problems.push(field.path + " " + n + " 超过上限,已压到 " + field.max); n = field.max; } return n; } if (field.type === "enum") { var keys = field.values || enumKeys(field.group); if (keys.indexOf(raw) >= 0) { return raw; } if (!missing) { problems.push(field.path + " 非法取值(" + raw + "),已回退 " + field.def); } return field.def; } /* text:只认字符串。 * 非字符串(数字/布尔/对象)一律回退默认值,**不做 String() 转换**—— * 配置里出现数字只可能是手改或异常写入,把 42 静默转成 "42" * 会让一个无效的搜索条件直接生效,比回退默认危险得多。 * (用户正常输入的数字也会以字符串形式存进来,不会走到这里。) */ if (typeof raw === "string") { return raw; } if (!missing) { problems.push(field.path + " 不是文本(" + (typeof raw) + "),已用默认值 " + field.def); } return field.def; } /** * 把任意输入收敛成合法配置。返回 { config, problems }。 * * 遍历的是**字段表**而不是输入对象 —— 表外的键会被丢弃, * 这样手改过的脏 JSON、旧版本残留字段都不会穿透到界面。 * * problems 用于运行期把发现的问题打印出来:设备端没有测试框架, * 校验结果必须能被人看见,否则「悄悄用默认值跑」比报错更难查。 */ Store.validate = function (input) { var src = isPlainObject(input) ? input : {}; var problems = []; /* 没写 version 的(很早期的存档)按 0 处理,否则 NaN 的比较恒为 false,迁移会被跳过 */ var fromVersion = Number(input ? input.version : 0); if (isNaN(fromVersion)) { fromVersion = 0; } if (fromVersion !== Store.SCHEMA_VERSION) { problems.push("version " + (input && input.version !== undefined ? input.version : "(无)") + " → " + Store.SCHEMA_VERSION + ",缺失字段已按默认值补齐"); } var cfg = { version: Store.SCHEMA_VERSION }; for (var i = 0; i < Store.FIELDS.length; i++) { var f = Store.FIELDS[i]; assignPath(cfg, f.path, normalizeField(readPath(src, f.path), f, problems)); } /* 迁移放在归一化之后:此时 cfg 已是完整结构,迁移只需处理语义差异 * (例如「v1 的空串其实等于未设置」「v2 的一个开关要拆成两个」这类 * 类型系统看不出来的事)。src 一并传入,用于读取已被删除的旧字段。 */ for (var v = fromVersion; v < Store.SCHEMA_VERSION; v++) { var migrate = Store.MIGRATIONS[v]; if (migrate) { migrate(cfg, problems, src); } } for (var j = 0; j < Store.CROSS_RULES.length; j++) { var rule = Store.CROSS_RULES[j]; var low = readPath(cfg, rule.low); var high = readPath(cfg, rule.high); if (typeof low === "number" && typeof high === "number" && high < low) { problems.push(rule.high + "(" + high + ") 小于 " + rule.low + "(" + low + "),已抬齐"); assignPath(cfg, rule.high, low); } } return { config: cfg, problems: problems }; }; /* ---------------------------------------------------------------- 合并 */ /** * 深合并:patch 覆盖 base 的同名键,patch 没有的键保留 base 的值。 * 只递归「纯对象」——数组整体替换,不逐项合并(数组语义通常是「整份配置」)。 */ Store.merge = function (base, patch) { if (!isPlainObject(patch)) { return base; } var out = {}; var k; for (k in base) { if (base.hasOwnProperty(k)) { out[k] = base[k]; } } for (k in patch) { if (!patch.hasOwnProperty(k)) { continue; } if (isPlainObject(base[k]) && isPlainObject(patch[k])) { out[k] = Store.merge(base[k], patch[k]); } else { out[k] = patch[k]; } } return out; }; /* ---------------------------------------------------------------- 读写 */ function storage() { return storages.create(Store.STORAGE_NAME); } /** 读配置;任何异常都退回默认值,绝不让界面因读取失败而起不来。 */ Store.load = function () { var raw = null; try { raw = storage().get(Store.STORAGE_KEY, null); } catch (e) { console.log("[store] 读取失败,使用默认配置: " + e); } if (typeof raw === "string" && raw.length > 0) { try { raw = JSON.parse(raw); } catch (e2) { console.log("[store] JSON 解析失败,使用默认配置"); raw = null; } } var result = Store.validate(raw); if (result.problems.length > 0) { console.log("[store] 校验修正: " + result.problems.join(" | ")); } return result.config; }; /** 写配置。返回是否成功——界面要据此提示用户,而不是静默丢数据。 */ Store.save = function (config) { try { storage().put(Store.STORAGE_KEY, JSON.stringify(config)); return true; } catch (e) { console.log("[store] 保存失败: " + e); return false; } }; Store.reset = function () { try { storage().remove(Store.STORAGE_KEY); } catch (e) { console.log("[store] 重置失败: " + e); } return Store.defaults(); }; /* 运行期只持有一份实例,页面通过它读写,避免各自 load 出多份副本互相覆盖。 */ Store.state = null; Store.init = function () { Store.state = Store.load(); /* ★ 迁移/修正的结果必须**落盘**。 * * `Store.load()` 内部会跑 SCHEMA 迁移并修正非法值,但它只返回内存里的 config、 * **没有写回存储**。后果是:这次启动迁移过了,下次启动又从旧值开始迁移一遍; * 更糟的是引擎读配置时可能拿到"内存已改、存储未改"的两份不一致数据。 * (私信休息时间的迁移就是这样:逻辑明明正确,真机上却始终不生效。) * * 此时 `Store.state` 已经设好,`Store.save()` 不会再回头调 init,不会递归。 */ try { Store.save(Store.state); } catch (error) { console.log("[store] 迁移结果落盘失败(不影响本次运行): " + error); } return Store.state; }; Store.current = function () { if (!Store.state) { Store.init(); } return Store.state; }; /** 按路径取字段声明;没有契约的路径返回 null(调用方据此决定要不要归一化)。 */ Store.fieldOf = function (path) { for (var i = 0; i < Store.FIELDS.length; i++) { if (Store.FIELDS[i].path === path) { return Store.FIELDS[i]; } } return null; }; /** * 改一个字段并立即落盘。页面唯一的状态写入出口。 * * ★ 写之前先按字段表**归一化**(夹紧越界、回退非法枚举、纠正类型)。 * 「配置里永远没有越界值」这条不变量必须由写入端保证,而不是只靠 load 时的 * `validate()`。少了这一步,界面上直接输入的值会原样落盘:用户填 99999 越过了 * 上限 500,配置里就真的是 99999 —— 要等下一次重启加载才被纠正, * 而用户看到的是「我填的数它照收了,可跑起来又不是那么回事」。 */ Store.set = function (path, value) { var field = Store.fieldOf(path); /* 表外的路径(例如未来新增的临时键)不做归一化,原样写入 */ var next = field ? normalizeField(value, field, []) : value; assignPath(Store.current(), path, next); return Store.save(Store.current()); }; /** * 取字段值。第二参数是缺失时的兜底 —— **页面里凡是喂给界面的取值都应该带上它**, * 否则 undefined 会一路飘到 String() 变成界面上的 "undefined"。 */ Store.get = function (path, fallback) { var value = readPath(Store.current(), path); return value === undefined ? fallback : value; }; /* ------------------------------------------------- 类型安全的取值(界面专用) */ /** 取某字段在字段表里声明的默认值——界面兜底时用它,与数据层保持同一来源。 */ Store.defaultOf = function (path) { for (var i = 0; i < Store.FIELDS.length; i++) { if (Store.FIELDS[i].path === path) { return Store.FIELDS[i].def; } } return undefined; }; /** groupOrValues 既接受枚举组名,也接受候选数组。 */ function keysOf(groupOrValues) { if (groupOrValues instanceof Array) { return groupOrValues; } return enumKeys(groupOrValues); } /** * 取枚举值,**保证返回一个合法 key**。 * 缺失或非法时回退到该枚举第一项,并把这个修正回写到配置里—— * 于是「选择框总有选中项」在数据层就被保证,页面不必各自兜底。 */ Store.getEnum = function (path, groupOrValues) { var keys = keysOf(groupOrValues); var raw = Store.get(path, null); if (raw !== null && keys.indexOf(raw) >= 0) { return raw; } var fallback = keys.length > 0 ? keys[0] : ""; var declared = Store.defaultOf(path); if (declared !== undefined && keys.indexOf(declared) >= 0) { fallback = declared; } Store.set(path, fallback); return fallback; }; /** 取数值,保证返回有限数;非法时回退默认值并回写。 */ Store.getNumber = function (path, def) { var raw = Store.get(path, null); var n = Number(raw); if (raw !== null && raw !== "" && !isNaN(n)) { return n; } var fallback = def === undefined ? Store.defaultOf(path) : def; if (typeof fallback !== "number") { fallback = 0; } Store.set(path, fallback); return fallback; }; /** 取文本,保证返回字符串;缺失或非字符串时回退默认值并回写。 */ Store.getText = function (path, def) { var raw = Store.get(path, null); if (typeof raw === "string") { return raw; } var fallback = def === undefined ? Store.defaultOf(path) : def; if (typeof fallback !== "string") { fallback = ""; } Store.set(path, fallback); return fallback; }; /** 取布尔,保证返回布尔值。 */ Store.getBool = function (path, def) { var raw = Store.get(path, null); if (typeof raw === "boolean") { return raw; } var fallback = def === undefined ? Store.defaultOf(path) : def; if (typeof fallback !== "boolean") { fallback = false; } Store.set(path, fallback); return fallback; }; /* ============================== src/records.js ============================== */ /* ============================== src/records.js ============================== * 运行记录(规格书 D1/D2/D3 的数据来源)。 * * ## 为什么单独一个模块,而不是塞进 Store.FIELDS * Store 管的是「**固定字段**的配置」——一张声明表,每个路径一个值。 * 运行记录是**不定长的列表**(跑一次多一条),塞进字段表就等于 * 让 validate() 去校验一个数组,还把「路径 → 值控件」的那套审计规则带歪。 * 所以它走同一份 storages 的**另一个 key**,自己管读写。 * * ## 记录里存什么 * 只存**界面真的知道的东西**:任务/脚本、结果分类、起止时间、结果文案、 * 结果文件、日志行。不编造没发生过的字段(例如"实际提取了几个 ID"只有 * 引擎回传了才写;没回传就留空,D3 那一行就不显示)。 * * ## 日志从哪来 * 引擎(tiktok-auto)通过 `--log` 通道把真实输出按行 append 到 * run-log.jsonl,界面在结束时读回来。**没有日志就不显示日志卡的空壳** —— * 详见 `Engine.readLog()` 与 §「日志缺失时如实说明」。 */ var Records = {}; /** 与 Store 共用一份 storages,只是换一个 key(见文件头注释)。 */ Records.STORAGE_NAME = "feizhou_app"; Records.STORAGE_KEY = "records"; /** * 最多保留多少条。 * * 上限是**必要的**:记录里含日志行,跑几十次之后 JSON 会长到 * storages 读取明显变慢(它每次整体读字符串)。 * 50 条足够回溯,超出的从尾部丢弃(新记录永远在最前)。 */ Records.MAX = 50; /** 结果分类 → 界面文案 + 色调。tone 直接喂给 W.pill。 */ Records.RESULT = { success: { key: "success", label: "\u5DF2\u5B8C\u6210", tone: "ok" }, stopped: { key: "stopped", label: "\u624B\u52A8\u505C\u6B62", tone: "mute" }, failed: { key: "failed", label: "\u62A5\u9519\u4E2D\u65AD", tone: "danger" } }; /** 归一化结果分类:引擎给的终态与记录分类不是一套词(finished/stopped/failed)。 */ Records.resultOf = function (status) { if (status === "finished" || status === "success") { return Records.RESULT.success; } if (status === "stopped") { return Records.RESULT.stopped; } return Records.RESULT.failed; }; /* ------------------------------------------------------------ 读写 */ function storage() { return storages.create(Records.STORAGE_NAME); } /** 读全部记录(新的在前)。任何异常都退回空数组——记录读不出来不该让界面起不来。 */ Records.list = function () { var raw = null; try { raw = storage().get(Records.STORAGE_KEY, null); } catch (e) { console.log("[records] 读取失败: " + e); } if (typeof raw === "string" && raw.length > 0) { try { raw = JSON.parse(raw); } catch (e2) { console.log("[records] JSON 解析失败,按空处理"); raw = null; } } if (!(raw instanceof Array)) { return []; } /* 逐条清洗:缺 id / 缺任务名的脏条目直接丢掉, * 免得 D1 上出现一行点不开、也说不清是什么的东西。 */ var out = []; for (var i = 0; i < raw.length; i++) { var one = raw[i]; if (!one || typeof one !== "object") { continue; } if (!one.id || !one.taskKey) { continue; } out.push(one); } return out; }; Records.save = function (list) { try { storage().put(Records.STORAGE_KEY, JSON.stringify(list)); return true; } catch (e) { console.log("[records] 保存失败: " + e); return false; } }; /** * 追加一条记录(新的在最前)。 * @returns 落盘后的那条记录(含 id)——调用方(F5 的「查看日志」)要用它跳 D3 */ Records.add = function (record) { var list = Records.list(); var one = record || {}; one.id = one.id || ("rec-" + Date.now() + "-" + Math.floor(Math.random() * 10000)); one.taskKey = one.taskKey || ""; one.taskName = one.taskName || ""; one.scriptKey = one.scriptKey || ""; one.scriptName = one.scriptName || ""; one.result = one.result || "success"; one.startedAt = one.startedAt || 0; one.endedAt = one.endedAt || Date.now(); one.resultText = one.resultText || ""; one.resultFile = one.resultFile || ""; /* uid 映射文件的路径(ID提取且勾了「同时解析数字 uid」时才有)。 * 老记录里没有这个字段 → 归一化成空串,渲染层据此决定要不要多一行。 */ one.mapFile = one.mapFile || ""; /* runId:记录页要靠它和断点里的 runId 对上(见 Resume.isRecordResumable)。 * 老记录没有这个字段 → 归一化成空串,于是永远不会显示「继续」。 */ one.runId = one.runId || ""; one.logs = one.logs instanceof Array ? one.logs : []; list.unshift(one); if (list.length > Records.MAX) { list = list.slice(0, Records.MAX); } Records.save(list); return one; }; /** 按 id 取一条(D3 用)。找不到返回 null。 */ Records.find = function (id) { var list = Records.list(); for (var i = 0; i < list.length; i++) { if (list[i].id === id) { return list[i]; } } return null; }; /** 某个任务最近一条记录(B1 任务卡的「上次…」)。没有则 null。 */ Records.latestOf = function (taskKey) { var list = Records.list(); for (var i = 0; i < list.length; i++) { if (list[i].taskKey === taskKey) { return list[i]; } } return null; }; Records.clear = function () { try { storage().remove(Records.STORAGE_KEY); } catch (e) { console.log("[records] 清空失败: " + e); } }; /* ------------------------------------------------------------ 文案 */ function pad2(n) { return n < 10 ? "0" + n : String(n); } /** * 时长 → 人话。 * * 设计稿上三种形态都出现过(「耗时 12 分 30 秒」/「已运行 41 分钟」/「运行 6 分钟」), * 统一走这一个函数,规则是: * < 1 分钟 → "N 秒" * < 1 小时 → 整分钟说「N 分钟」,带零头说「N 分 M 秒」 * ≥ 1 小时 → "H 小时 M 分" * 为什么整分钟要单独说一句:0 秒是大多数「手动停止」的真实情况, * 写成「12 分 0 秒」会让人以为数据不准。 */ Records.formatDuration = function (ms) { var total = Math.max(0, Math.floor((ms || 0) / 1000)); if (total < 60) { return total + " \u79D2"; } if (total < 3600) { var m = Math.floor(total / 60); var s = total % 60; return s === 0 ? (m + " \u5206\u949F") : (m + " \u5206 " + s + " \u79D2"); } var h = Math.floor(total / 3600); var mm = Math.floor((total % 3600) / 60); return h + " \u5C0F\u65F6 " + mm + " \u5206"; }; /** * 时间点 → 「今天 10:24」/「昨天 09:58」/「10-11 09:58」。 * 相对日期的判断必须用**本机日历**(不是 UTC):跨时区时 UTC 会让 * 「今天」错成昨天,而这条文案的全部意义就在于它读起来直观。 */ Records.formatDay = function (timestamp, nowMs) { var d = new Date(timestamp || 0); var now = new Date(nowMs === undefined ? Date.now() : nowMs); var clock = pad2(d.getHours()) + ":" + pad2(d.getMinutes()); var sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); if (sameDay) { return "\u4ECA\u5929 " + clock; } var yesterday = new Date(now.getTime() - 86400000); var isYesterday = d.getFullYear() === yesterday.getFullYear() && d.getMonth() === yesterday.getMonth() && d.getDate() === yesterday.getDate(); if (isYesterday) { return "\u6628\u5929 " + clock; } return pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + clock; }; /** D1 条目的时间行:「今天 10:24 · 耗时 12 分 30 秒」 */ Records.timeLine = function (record, nowMs) { if (!record) { return ""; } var start = record.startedAt || record.endedAt || 0; var end = record.endedAt || start; var head = Records.formatDay(start, nowMs); if (record.durationMs === undefined || record.durationMs === null) { record.durationMs = Math.max(0, end - start); } return head + " \u00B7 \u8017\u65F6 " + Records.formatDuration(record.durationMs); }; /** * D1 条目的结果行。 * 没给出结果文案时不编 —— 返回空串,由调用方决定这一行要不要出现。 */ Records.resultLine = function (record) { if (!record) { return ""; } var text = record.resultText || ""; if (record.resultFile && text.indexOf(record.resultFile) < 0) { text = text === "" ? record.resultFile : text + " \u2192 " + record.resultFile; } return text; }; /** * B1 任务卡的「上次…」一行。 * 没有记录 → 「还未运行过」(设计稿 C9 的视频功能卡就是这个文案,不是空白)。 */ Records.lastRunLine = function (taskKey, nowMs) { var one = Records.latestOf(taskKey); if (!one) { return "\u8FD8\u672A\u8FD0\u884C\u8FC7"; } var clock = Records.formatDay(one.startedAt || one.endedAt, nowMs); /* 「今天 10:24」太长,卡上只留时分;跨天的记录才带上日期。 */ var short = clock.replace("\u4ECA\u5929 ", "\u4E0A\u6B21 ").replace("\u6628\u5929 ", "\u6628\u5929 "); var tail = Records.resultLine(one); return short + (tail === "" ? "" : " \u00B7 " + tail); }; /* ============================== src/sources.js ============================== */ /* ============================== src/sources.js ============================== * ID提取的「取材清单」—— 从哪儿找人。 * * ## 为什么单独一个模块,而不是塞进 Store.FIELDS * Store 管的是「**固定字段**的配置」:一张声明表,一个路径一个标量值。 * 取材是**不定长列表**(可以 1 条,也可以 5 条),塞进字段表就等于 * 让 validate() 去校验一个数组,还会把「路径 → 值控件」那套审计规则带歪。 * 所以照 `Records` 的先例:**同一份 storages,另一个 key**,自己管读写。 * * ## 为什么是「清单」而不是「四个脚本选一个」 * 旧结构里四张卡是「起点 × 深度」的 4 个**不完整组合**:各缺一块、各有别人没有的一块, * 合起来才完整。于是用户想同时抓两种来源时,单选约束直接不允许。 * 取材是**可累加**的,多条按顺序串行执行(同一台手机只有一个前台,没有并发可言), * 共用同一份去重集合与同一个输出文件 —— 这才对得上用户的真实用法。 * * ## 一条取材长什么样 * `{ id, kind, ...按 kind 决定用哪几个参数..., expand }` * 只有 5 种起点,每种需要的参数由 `FIELDS_OF` 声明;界面据此决定显示哪些输入行。 * **选了什么起点才出现什么输入**,所以不会再有"勾上却无所指"的开关。 */ var Sources = {}; /** 与 Store / Records 共用一份 storages,只是换一个 key(见文件头注释)。 */ Sources.STORAGE_NAME = "feizhou_app"; Sources.STORAGE_KEY = "sources"; /** * 最多几条。 * * 5 条刚好覆盖全部五种起点各一条;再多页面会长到滚不完, * 而串行跑一轮的真实耗时也不允许(每条都要挨个打开用户页)。 */ Sources.MAX = 5; /** * 五种起点。顺序 = 下拉里的顺序 = 从常见到少见。 * * `take` 是"这个起点能取到什么人",只用于界面说明 —— * 执行引擎还没接,所以这里不写任何选择器。 */ Sources.KINDS = [ { key: "anchor", label: "指定账号", take: "它的粉丝 / 关注 / 主页展示的账号" }, { key: "search", label: "搜索结果", take: "用户搜索结果里的人 / 视频的作者" }, { key: "video", label: "某个视频的评论区", take: "评论者" }, { key: "text", label: "文本文件", take: "文件里每一行的 UID" }, { key: "screen", label: "当前屏幕", take: "此刻屏幕上这个列表里的人" } ]; /** * 深度:**最多 2 层**(列表里的人 → 这些人的粉丝)。 * * 第 3 层不给选项是有原因的:100 个粉丝 × 每人 100 个粉丝 = 1 万次用户页打开, * 在真机上不现实,也会让账号看起来异常。这是产品约束,不是实现偷懒。 */ Sources.EXPANDS = [ { key: "", label: "不展开" }, { key: "fans", label: "再取粉丝列表" }, { key: "following", label: "再取关注列表" } ]; /** `anchor` 起点可以取哪几个列表(多选)。 */ Sources.LISTS = [ { key: "fans", label: "粉丝列表" }, { key: "following", label: "关注列表" }, { key: "home", label: "主页展示" } ]; /** `search` 起点的两种类型。 */ Sources.SEARCH_TYPES = [ { key: "users", label: "用户" }, { key: "videos", label: "视频作者" } ]; /** `video` 起点的两种来源。 */ Sources.VIDEO_SOURCES = [ { key: "screen", label: "当前屏幕" }, { key: "keyword", label: "关键词搜索" } ]; /** * 每种起点**需要哪些参数**。界面与归一化都读这一张表。 * * `lists` 是唯一的多值参数(数组),其余都是字符串。 */ Sources.FIELDS_OF = { anchor: ["anchors", "lists"], search: ["searchType", "keyword"], video: ["videoSource", "videoKeyword"], text: ["path"], screen: [] }; /** 该起点是否能再展开一层("当前屏幕"已经是最底了,没得展开)。 */ Sources.canExpand = function (kind) { return kind !== "screen"; }; /* ------------------------------------------------------------ 基础工具 */ function sourceTrim(text) { return String(text === undefined || text === null ? "" : text).replace(/^\s+|\s+$/g, ""); } /** 留空就用业务默认值:空输入框既看不出该填什么,也让"没配"和"配了空"分不开。 */ function withDefault(value, fallback) { var text = sourceTrim(value); return text === "" ? fallback : text; } function sourceKeys(list) { var out = []; for (var i = 0; i < list.length; i++) { out.push(list[i].key); } return out; } /** 生成一个稳定 id。删除与自检都靠它,所以不能只用下标(删除后下标会滑)。 */ Sources.newId = function () { return "src-" + Date.now() + "-" + Math.floor(Math.random() * 10000); }; /* ------------------------------------------------------------ 归一化 */ /** * 把一条取材收拢成合法形状。 * * 两条原则: * ① **只保留与该 kind 有关的参数** —— 用户从「指定账号」切到「文本文件」时, * 旧的 anchors 不该继续躺在数据里(导出配置时会让人以为它还有用); * ② 非法值一律回退到该参数的默认值,**并且不抛异常** —— * 一条坏数据不该让整页渲染不出来(与 Store.normalizeField 同一套态度)。 * * @param raw 原始对象(可能缺字段、类型错、来自旧版本) * @param forceKind 强制用这个 kind(新建时用),不传则读 raw.kind */ Sources.normalize = function (raw, forceKind) { var one = (raw && typeof raw === "object") ? raw : {}; var kinds = sourceKeys(Sources.KINDS); var kind = sourceTrim(forceKind || one.kind); if (kinds.indexOf(kind) < 0) { kind = "anchor"; } var out = { id: sourceTrim(one.id) || Sources.newId(), kind: kind }; var fields = Sources.FIELDS_OF[kind]; for (var i = 0; i < fields.length; i++) { var name = fields[i]; if (name === "lists") { var allowed = sourceKeys(Sources.LISTS); var picked = []; var rawLists = one.lists; if (rawLists instanceof Array) { for (var j = 0; j < rawLists.length; j++) { if (allowed.indexOf(rawLists[j]) >= 0 && picked.indexOf(rawLists[j]) < 0) { picked.push(rawLists[j]); } } } /* 一个都不选 = 这条取材取不到任何人。回退成"粉丝列表"而不是留空: * 留空会让界面出现一条**永远跑不出结果**的取材,而用户看不出为什么。 */ if (picked.length === 0) { picked.push("fans"); } out.lists = picked; } else if (name === "searchType") { out.searchType = sourceKeys(Sources.SEARCH_TYPES).indexOf(one.searchType) >= 0 ? one.searchType : "users"; } else if (name === "videoSource") { out.videoSource = sourceKeys(Sources.VIDEO_SOURCES).indexOf(one.videoSource) >= 0 ? one.videoSource : "screen"; } else if (name === "keyword" || name === "videoKeyword") { out[name] = withDefault(one[name], "fashion"); } else if (name === "path") { out.path = withDefault(one.path, Store.TEXT_DIR + "/uid_list.txt"); } else if (name === "anchors") { /* 账号可以留空:留空 = 不限定、从当前屏幕的列表直接抓。 * 这是真实且合理的状态,所以不塞假默认值(与 Store 里 anchorIds 同一套规则)。 */ out.anchors = sourceTrim(one.anchors); } } /* 深度:screen 起点没有展开这一说;其余非法值一律回退"不展开" */ out.expand = ""; if (Sources.canExpand(kind)) { var expands = sourceKeys(Sources.EXPANDS); if (expands.indexOf(one.expand) >= 0) { out.expand = one.expand; } } return out; }; /* ------------------------------------------------------------ 读写 */ function sourceStorage() { return storages.create(Sources.STORAGE_NAME); } /** * 读全部取材(按顺序)。 * * 读不出来 → 空数组(一条坏数据不该让整个界面起不来); * 超过 MAX → 截断(旧版本可能存过更多条)。 */ Sources.list = function () { var raw = null; try { raw = sourceStorage().get(Sources.STORAGE_KEY, null); } catch (e) { console.log("[sources] 读取失败: " + e); } if (typeof raw === "string" && raw.length > 0) { try { raw = JSON.parse(raw); } catch (e2) { console.log("[sources] JSON 解析失败,按空处理"); raw = null; } } if (!(raw instanceof Array)) { return []; } var out = []; for (var i = 0; i < raw.length && out.length < Sources.MAX; i++) { if (raw[i] && typeof raw[i] === "object") { out.push(Sources.normalize(raw[i])); } } return out; }; Sources.save = function (list) { var safe = (list instanceof Array) ? list.slice(0, Sources.MAX) : []; try { sourceStorage().put(Sources.STORAGE_KEY, JSON.stringify(safe)); return true; } catch (e) { console.log("[sources] 保存失败: " + e); return false; } }; /* ------------------------------------------------------------ 增删改 */ /** 按 id 取一条(界面每次操作都重新读,避免拿渲染时的旧快照覆盖新值)。 */ Sources.find = function (id) { var list = Sources.list(); for (var i = 0; i < list.length; i++) { if (list[i].id === id) { return list[i]; } } return null; }; Sources.isEmpty = function () { return Sources.list().length === 0; }; Sources.count = function () { return Sources.list().length; }; /** 追加一条(同时告诉调用方成不成 —— 满了就不该静默失败)。 */ Sources.add = function (kind) { var list = Sources.list(); if (list.length >= Sources.MAX) { return null; } var one = Sources.normalize({ kind: kind || "anchor" }); list.push(one); Sources.save(list); return one; }; /** * 改一条。`patch` 只合入传进来的键,其余保持。 * * 注意:**改 kind 要重新归一化**(新起点不该继承旧起点的参数)—— * 所以这里走 normalize(patch 合并后, 新 kind),而不是简单 assign。 */ Sources.update = function (id, patch) { var list = Sources.list(); for (var i = 0; i < list.length; i++) { if (list[i].id !== id) { continue; } var merged = {}; var k; for (k in list[i]) { if (list[i].hasOwnProperty(k)) { merged[k] = list[i][k]; } } for (k in patch) { if (patch.hasOwnProperty(k)) { merged[k] = patch[k]; } } merged.id = id; list[i] = Sources.normalize(merged, merged.kind); Sources.save(list); return list[i]; } return null; }; /** * 删第 index 条。 * * 用**下标**而不是 id:界面是按顺序渲染的,用户点的就是"第几条"。 * 删除后其余条目自动前移,不需要重排 id。 */ Sources.removeAt = function (index) { var list = Sources.list(); if (index < 0 || index >= list.length) { return false; } list.splice(index, 1); Sources.save(list); return true; }; Sources.clear = function () { try { sourceStorage().remove(Sources.STORAGE_KEY); } catch (e) { console.log("[sources] 清空失败: " + e); } }; /* ------------------------------------------------------------ 文案 */ /** 按 key 取中文名(找不到回退 key 本身 —— 显示 "zzz" 也比 "undefined" 强)。 */ function labelOf(table, key) { for (var i = 0; i < table.length; i++) { if (table[i].key === key) { return table[i].label; } } return key; } Sources.kindLabel = function (kind) { return labelOf(Sources.KINDS, kind); }; Sources.expandLabel = function (key) { return labelOf(Sources.EXPANDS, key); }; /** * 一条取材 → 一句话摘要。 * * 用在两个地方:取材卡片的标题行(收起时也要看得出这条是干什么的)、 * 以及运行记录里的「当前脚本」行(那里放不下一整段配置)。 */ Sources.describe = function (one) { if (!one) { return ""; } var head = ""; if (one.kind === "anchor") { var lists = []; for (var i = 0; i < one.lists.length; i++) { lists.push(labelOf(Sources.LISTS, one.lists[i]).replace("列表", "")); } var who = sourceTrim(one.anchors) === "" ? "当前屏幕的账号" : one.anchors.split("#")[0]; head = who + " · " + lists.join("+"); } else if (one.kind === "search") { head = (one.searchType === "videos" ? "视频作者" : "用户") + "「" + one.keyword + "」"; } else if (one.kind === "video") { head = one.videoSource === "keyword" ? "评论区「" + one.videoKeyword + "」" : "当前屏幕的评论区"; } else if (one.kind === "text") { var name = String(one.path).split("/").pop(); head = "文本 " + name; } else { head = "当前屏幕"; } if (one.expand) { head += " → " + labelOf(Sources.EXPANDS, one.expand).replace("再取", ""); } return head; }; /** * 整份清单 → 一句话(任务卡与运行记录用)。 * 空清单返回空串,由调用方决定显示「未配置」还是别的东西。 */ Sources.summary = function () { var list = Sources.list(); if (list.length === 0) { return ""; } if (list.length === 1) { return Sources.describe(list[0]); } return Sources.describe(list[0]) + " 等 " + list.length + " 条"; }; /* ------------------------------------------------------------ 旧配置迁移 */ /** 读 Store 那份 sourceStorage 的**原始** JSON(含 v8 之前的四张卡字段)。 */ function readLegacyConfig() { try { var raw = storages.create(Store.STORAGE_NAME).get(Store.STORAGE_KEY, null); if (typeof raw === "string" && raw.length > 0) { return JSON.parse(raw); } if (raw && typeof raw === "object") { return raw; } } catch (e) { console.log("[sources] 读取旧配置失败: " + e); } return null; } function readPathSafe(obj, path) { var segs = String(path).split("."); var node = obj; for (var i = 0; i < segs.length; i++) { if (node === null || typeof node !== "object") { return undefined; } node = node[segs[i]]; } return node; } /** * 旧「进入页面」→ 新的「取哪些列表」。 * * `comment` 没有对应:在"从一个账号取它哪个列表"这个语义下,账号页没有"评论页"。 * 这是 v6 遗留的一处语义模糊,迁移时回退成粉丝列表并记一条日志 —— * 用户的意图在 v8 里可以用「某个视频的评论区」这条取材重新表达。 */ function listsFromEnterPage(value) { if (value === "follow") { return ["following"]; } if (value === "home") { return ["home"]; } if (value === "comment") { console.log("[sources] 旧「进入页面=评论页」在取材清单里没有对应,已按「粉丝列表」迁移"); return ["fans"]; } return ["fans"]; } /** * v7 → v8:把"四张卡里选了一张"翻译成取材清单。**一次性**。 * * 三条原则: * ① **已有清单就不动** —— 用户自己加的取材永远不被覆盖(重装/降级都安全); * ② **能展开成多条就展开** —— 脚本四的三个开关本来就是三种取材范围, * 旧结构只能勾在同一个脚本里,新结构下它们天然是三条独立取材; * ③ 超出 `MAX` 要截断并**说出来**,不静默丢。 * * @returns 迁移后的清单;没得迁移(没旧配置 / 已有清单 / 旧配置一条都没选)返回 null */ Sources.migrateLegacy = function () { if (Sources.list().length > 0) { return null; } var raw = readLegacyConfig(); if (!raw) { return null; } var scripts = readPathSafe(raw, "tabs.id.scripts") || {}; var items = []; /* —— 脚本一:主播账号 + 可选的"搜索用户"和"文本" —— */ var s1 = scripts.s1 || {}; if (s1.checked) { items.push({ kind: "anchor", anchors: s1.anchorIds || "", lists: listsFromEnterPage(s1.enterPage), expand: "" }); if (s1.useTextId) { items.push({ kind: "text", path: s1.textIdPath, expand: "" }); } if (s1.customPage) { items.push({ kind: "search", searchType: "users", keyword: s1.customPageKeyword || "", expand: "" }); } } /* —— 脚本二:评论区(+ 可选展开评论者的粉丝) —— */ var s2 = scripts.s2 || {}; if (s2.checked) { items.push({ kind: "video", videoSource: s2.videoSource || "screen", videoKeyword: s2.videoKeyword || "", expand: s2.commenterFans ? "fans" : "" }); } /* —— 脚本三:文本裂变(旧"进入页面"就是它的展开方向) —— */ var s3 = scripts.s3 || {}; if (s3.checked) { items.push({ kind: "text", path: s3.fissionTextPath, expand: (s3.enterPage === "follow") ? "following" : "fans" }); } /* —— 脚本四:三个开关展开成三条取材(旧结构塞不下,新结构天然支持) —— */ var s4 = scripts.s4 || {}; if (s4.checked) { var kw = s4.keyword || ""; items.push({ kind: "search", searchType: "videos", keyword: kw, expand: s4.anchorFans ? "fans" : "" }); if (s4.commentArea) { items.push({ kind: "video", videoSource: "keyword", videoKeyword: kw, expand: "" }); } if (s4.commenterFans) { items.push({ kind: "video", videoSource: "keyword", videoKeyword: kw, expand: "fans" }); } } if (items.length === 0) { return null; } if (items.length > Sources.MAX) { console.log("[sources] 旧配置翻译出 " + items.length + " 条取材,超过上限 " + Sources.MAX + " 条,已截断(多出来的取材可在界面重新添加)"); items = items.slice(0, Sources.MAX); } var list = []; for (var i = 0; i < items.length; i++) { list.push(Sources.normalize(items[i])); } Sources.save(list); return list; }; /* ============================== src/device.js ============================== */ /* ============================== src/device.js ============================== * 设备数据获取层。 * * 界面上的每一项「状态」都来自这里,而不是写死的假数据: * 无障碍 / 悬浮窗 / 通知 权限、TikTok 是否安装与版本号、屏幕尺寸、脚本版本。 * * 设计原则(规范 R25「设备端危险能力必须显式声明依赖,不作为主路径」): * 本文件所有探测都必须能在「能力缺失」时给出 false / 空值,绝不抛出去。 * AutoX 没有 try-catch 之外的门禁,一次未捕获异常就会让整个界面起不来。 */ var Device = {}; /* TikTok 有多个发行包名,挨个探测;顺序即优先级。 */ Device.TIKTOK_PACKAGES = [ "com.zhiliaoapp.musically", "com.ss.android.ugc.trill", "com.zhiliaoapp.musically.go" ]; /** 通用取值包装:任何异常都退化为 fallback,并留一条日志便于定位。 */ function probe(tag, fn, fallback) { try { var value = fn(); return value === undefined || value === null ? fallback : value; } catch (e) { console.log("[device] " + tag + " 探测失败: " + e); return fallback; } } /* ------------------------------------------------------------ 屏幕与栏位 */ Device.screenWidth = function () { return probe("screenWidth", function () { return device.width; }, 375); }; Device.screenHeight = function () { return probe("screenHeight", function () { return device.height; }, 812); }; /** 系统状态栏高度。取不到时用 24dp 兜底,保证内容不被状态栏压住。 */ Device.statusBarHeight = function () { return probe("statusBarHeight", function () { var res = context.getResources(); var id = res.getIdentifier("status_bar_height", "dimen", "android"); return id > 0 ? res.getDimensionPixelSize(id) : U.dp(24); }, U.dp(24)); }; /** 底部导航栏高度。全面屏手势机型会返回 0,此时由「安全区」常量兜底。 */ Device.navBarHeight = function () { return probe("navBarHeight", function () { var res = context.getResources(); var id = res.getIdentifier("navigation_bar_height", "dimen", "android"); return id > 0 ? res.getDimensionPixelSize(id) : 0; }, 0); }; /* ------------------------------------------------------------ 权限状态 */ /** * 无障碍服务。AutoX 里 auto.service 为 null 即表示未连接。 * 这是本 App 的硬依赖——没有它就点不动 TikTok,所以权限弹窗首行必须显示它。 */ Device.a11yEnabled = function () { return probe("a11y", function () { return auto.service !== null; }, false); }; /** 悬浮窗权限。用于运行时浮层提示,缺失不影响主流程。 */ Device.overlayEnabled = function () { return probe("overlay", function () { return floaty.checkPermission(); }, false); }; /** * 通知权限。Android 13(API 33) 起 POST_NOTIFICATIONS 是运行时权限, * 13 以下默认授予——不能统一按「未授予」处理,否则老机型会误报。 */ Device.notifyEnabled = function () { return probe("notify", function () { var sdk = parseInt(android.os.Build.VERSION.SDK, 10); if (isNaN(sdk) || sdk < 33) { return true; } var granted = context.checkSelfPermission("android.permission.POST_NOTIFICATIONS"); return granted === android.content.pm.PackageManager.PERMISSION_GRANTED; }, true); }; /** * 权限弹窗的展示数据,界面直接渲染,不在页面里重复拼装。 * * 两条约定(设计稿 02 与 12 必须完全一致): * ① 顺序固定为 无障碍服务 → 悬浮窗权限 → 通知权限; * ② 通知标 optional —— 它只在任务完成时提醒你,缺了不影响脚本执行, * 所以既排在最后、也不参与「能不能开始运行」的判定。 * (早先它既没标可选、又排在悬浮窗前面,用户会以为必须开。) */ Device.permissionRows = function () { return [ { key: "a11y", name: "无障碍服务", granted: Device.a11yEnabled() }, { key: "overlay", name: "悬浮窗权限", granted: Device.overlayEnabled() }, { key: "notify", name: "通知权限", granted: Device.notifyEnabled(), optional: true } ]; }; Device.allPermissionsGranted = function () { var rows = Device.permissionRows(); for (var i = 0; i < rows.length; i++) { if (!rows[i].granted) { return false; } } return true; }; /** * 「能不能开始运行」只取决于这两个。 * 与 allPermissionsGranted 分开是必要的:通知是可选权限, * 用它拦人会让用户被一个不影响执行的权限挡在门外。 */ Device.blockingPermissionsGranted = function () { return Device.a11yEnabled() && Device.overlayEnabled(); }; /** * 打开对应权限的设置页。 * 用标准 ACTION 常量而不是猜 Activity 名——猜错会抛 ActivityNotFound。 * 无障碍与悬浮窗没有公开的直跳 Action,退回「应用详情页」这个一定存在的入口。 */ Device.openPermissionSettings = function (key) { var pkg = context.getPackageName(); var activity = null; var action = null; try { if (key === "a11y") { action = "android.settings.ACCESSIBILITY_SETTINGS"; } else if (key === "overlay") { action = "android.settings.action.MANAGE_OVERLAY_PERMISSION"; } else if (key === "notify") { action = "android.settings.APP_NOTIFICATION_SETTINGS"; } if (action) { var intent = new android.content.Intent(action); if (key === "overlay") { intent.setData(android.net.Uri.parse("package:" + pkg)); } else { intent.putExtra("package", pkg); intent.putExtra("android.provider.extra.APP_PACKAGE", pkg); } activity = intent; } } catch (e) { console.log("[device] 构造权限 Intent 失败: " + e); activity = null; } if (!activity) { /* 兜底:应用详情页。所有 Android 版本都有。 */ activity = new android.content.Intent( "android.settings.APPLICATION_DETAILS_SETTINGS", android.net.Uri.parse("package:" + pkg) ); } return probe("openPermission", function () { context.startActivity(activity); return true; }, false); }; /* ------------------------------------------------------------ TikTok 状态 */ /** 探测 TikTok 是否安装及版本。返回 { installed, packageName, versionName }。 */ Device.tiktok = function () { var found = { installed: false, packageName: "", versionName: "" }; for (var i = 0; i < Device.TIKTOK_PACKAGES.length; i++) { var name = Device.TIKTOK_PACKAGES[i]; var info = probe("pkg:" + name, function () { return context.getPackageManager().getPackageInfo(name, 0); }, null); if (info) { found.installed = true; found.packageName = name; found.versionName = info.versionName || ""; break; } } if (found.installed) { console.log("[device] TikTok 已安装 " + found.packageName + " v" + found.versionName); } else { console.log("[device] 未探测到 TikTok 客户端"); } return found; }; /** 版本号归一化:设计稿展示成 v39.5.4 这种形态。 */ Device.formatVersion = function (versionName) { var v = String(versionName || "").trim(); if (!v) { return ""; } return v.charAt(0) === "v" ? v : "v" + v; }; /* ------------------------------------------------------------ 文件选择 */ /** 文本文件的候选目录。只扫这几处,不做全盘遍历(全盘扫在真机上要几十秒)。 */ Device.TEXT_DIRS = ["/sdcard/Download", "/sdcard/Documents", "/sdcard/tiktok_data", "/sdcard"]; /** * 列出可选的文本文件,供「选择文件」按钮弹列表用。 * * 这里刻意**只负责列出、不负责弹窗**:展示交给 UI 层(`W.filePicker` + `Dialog.choose`)。 * 一来设备层不该依赖弹窗组件,二来 `dialogs.select` 这类阻塞式调用在 UI 模式下 * 拿不到返回值,选文件会静默失效。 * * 只扫几个约定目录,不做全盘遍历——全盘扫在真机上要几十秒。 */ Device.listTextFiles = function () { var out = []; var seen = {}; for (var i = 0; i < Device.TEXT_DIRS.length; i++) { var dir = Device.TEXT_DIRS[i]; var names = probe("ls:" + dir, function () { return files.listDir(dir); }, []); for (var j = 0; j < names.length; j++) { var name = names[j]; if (!/\.(txt|json|csv)$/i.test(name)) { continue; } var full = dir + "/" + name; if (seen[full]) { continue; } seen[full] = true; out.push(full); } } out.sort(); return out; }; /* ------------------------------------------------------------ 应用信息 */ Device.appVersionName = function () { return probe("appVersion", function () { return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName; }, "-"); }; Device.deviceLabel = function () { return probe("deviceLabel", function () { return android.os.Build.MODEL + " / Android " + android.os.Build.VERSION.RELEASE; }, "-"); }; /* ============================== src/service.js ============================== */ /* ============================== src/service.js ============================== * 需要「服务端」的业务能力:卡密校验、检查更新。 * * 这两件事的共同点是:真实场景必须联网核验,但**离线也必须能给出确定行为**。 * 所以每个能力都是「远端优先 + 本地降级」,且降级模式会在界面上如实标注, * 不会把本地通过伪装成服务端通过(规范:不编造测试结果/接口字段)。 * * ⚠️ 本地模式的定位必须说清楚: * 仅校验格式,不具备防伪能力。要真防伪,把 CONFIG.licenseEndpoint * 指向你自己的校验接口即可——请求/响应结构见 verifyRemote() 注释。 */ var Service = {}; Service.CONFIG = { /* 留空 = 本地模式。填写后 login() 会先请求远端。 */ licenseEndpoint: "", licenseTimeoutMs: 8000, /* 本地模式下的默认有效期(天),仅用于让「到期时间」这一栏有真实含义。 */ localTrialDays: 30, /* 检查更新的清单地址,留空则用内置版本号自比。 */ updateEndpoint: "" }; /** 脚本版本。设计稿的「版本信息卡」显示的就是它;build.js 会用构建日期覆盖。 */ Service.SCRIPT_VERSION = "2026.10.13"; Service.CHANGELOG = [ "2026.10.11 数量类设置全部改为「最小值 ~ 最大值」区间(关注/私信/休息/浏览/单次/停止/间隔)", "2026.10.11 补齐登录页 / 运行状态条 / 拦截弹窗,ID提取页改单选", "2026.10.10 界面层按设计稿重构,配置项全部落盘", "2026.10.03 接入设备真实状态(权限 / TikTok 版本)", "2026.09.28 首个可运行版本" ]; /* ---------------------------------------------------------------- 卡密 */ /** 去掉空格与连字符并转大写,得到比较用的规范形式。 */ Service.normalizeCode = function (code) { return String(code || "").replace(/[\s-]/g, "").toUpperCase(); }; /** * 格式校验:16 位大写字母或数字。 * 只做形态校验——真伪必须靠服务端,本地无法判定一个随机串是不是有效卡密。 */ Service.isWellFormed = function (code) { return /^[A-Z0-9]{16}$/.test(Service.normalizeCode(code)); }; /** 展示用分组:ABCDEFGHIJKLMNOP → ABCDE-FGHI-JKLM-NOPQ 形态的 4 组。 */ Service.formatCode = function (code) { var n = Service.normalizeCode(code); var out = []; for (var i = 0; i < n.length; i += 4) { out.push(n.substring(i, i + 4)); } return out.join("-"); }; function httpPostJson(url, payload) { var body = JSON.stringify(payload); var response = http.post(url, body, { headers: { "Content-Type": "application/json" }, timeout: Service.CONFIG.licenseTimeoutMs }); if (!response || response.statusCode !== 200) { return null; } try { return JSON.parse(response.body.string()); } catch (e) { console.log("[service] 卡密响应不是合法 JSON"); return null; } } /** * 远端校验。 * 请求:POST {endpoint} {"code":"<规范化卡密>"} * 响应:{"ok":true,"expireAt":"2026-11-09"} 或 {"ok":false,"reason":"卡密不存在"} * 返回 null 表示「没问到」(网络/格式问题),调用方据此决定是否降级为本地模式。 */ Service.verifyRemote = function (code) { if (!Service.CONFIG.licenseEndpoint) { return null; } var data = null; try { data = httpPostJson(Service.CONFIG.licenseEndpoint, { code: code }); } catch (e) { console.log("[service] 卡密远端校验异常: " + e); return null; } if (!data) { return null; } return { ok: !!data.ok, expireAt: data.expireAt || "", reason: data.reason || "卡密无效" }; }; /** 今天 + N 天,格式 yyyy-MM-dd。用于本地模式生成到期时间。 */ Service.datePlusDays = function (days) { var now = new Date(); now.setDate(now.getDate() + Number(days || 0)); var m = now.getMonth() + 1; var d = now.getDate(); return now.getFullYear() + "-" + (m < 10 ? "0" + m : m) + "-" + (d < 10 ? "0" + d : d); }; Service.today = function () { return Service.datePlusDays(0); }; /** 过期判定:只比日期字符串(yyyy-MM-dd 字典序即时间序),不引时区计算。 */ Service.isExpired = function (expireAt) { if (!expireAt) { return false; } return Service.today() > String(expireAt); }; /** * 登录。返回 { ok, status, message }。 * status: "active" | "expired" | "none" */ Service.login = function (code) { var normalized = Service.normalizeCode(code); if (!Service.isWellFormed(normalized)) { return { ok: false, status: "none", message: "卡密格式不对,应为 16 位字母或数字" }; } var remote = Service.verifyRemote(normalized); if (remote) { if (!remote.ok) { return { ok: false, status: "none", message: remote.reason }; } if (Service.isExpired(remote.expireAt)) { return { ok: false, status: "expired", message: "该卡密已于 " + remote.expireAt + " 到期" }; } return { ok: true, status: "active", code: normalized, expireAt: remote.expireAt, message: "登录成功(服务端核验)" }; } /* 本地模式:只有格式校验,如实告知,不伪装成服务端通过。 */ return { ok: true, status: "active", code: normalized, expireAt: Service.datePlusDays(Service.CONFIG.localTrialDays), message: "登录成功(本地模式,未联网核验)" }; }; /** 读当前卡密状态,并顺手处理「已过期但状态还写着 active」的情况。 */ Service.licenseStatus = function () { var status = Store.getEnum("license.status", "licenseStatus"); var expireAt = Store.getText("license.expireAt"); var code = Store.getText("license.code"); if (status === "active" && Service.isExpired(expireAt)) { Store.set("license.status", "expired"); status = "expired"; } return { status: status, code: code, expireAt: expireAt }; }; /** * 状态 → 界面文案与配色 key。集中在这里,避免页面各自判断。 * * ⚠️ 返回值里必须带 `status` —— 页面除了文案还要按状态分支 * (已登录才显示「解绑」,否则显示「去登录」)。 * 早先只返回 text/tone/expireAt,页面写 `display.status === "active"` 永远是 false, * 结果是「已登录」旁边挂着「去登录」,自相矛盾但没有任何报错。 */ Service.licenseDisplay = function () { var st = Service.licenseStatus(); if (st.status === "active") { return { status: st.status, text: "已登录", tone: "ok", expireAt: st.expireAt || "—" }; } if (st.status === "expired") { return { status: st.status, text: "已过期", tone: "warn", expireAt: st.expireAt || "—" }; } return { status: st.status, text: "未登录", tone: "warn", expireAt: "—" }; }; Service.logout = function () { Store.set("license.status", "none"); Store.set("license.code", ""); Store.set("license.expireAt", ""); }; /* ---------------------------------------------------------------- 更新 */ /** * 检查更新。远端不可达时退化为「与内置版本号自比」, * 结论同样如实标注来源,避免把「没连上」说成「已是最新」。 */ Service.checkUpdate = function () { var current = Service.SCRIPT_VERSION; if (Service.CONFIG.updateEndpoint) { var data = null; try { var response = http.get(Service.CONFIG.updateEndpoint, { timeout: 8000 }); if (response && response.statusCode === 200) { data = JSON.parse(response.body.string()); } } catch (e) { console.log("[service] 检查更新失败: " + e); } if (data && data.version) { return { latest: String(data.version), hasUpdate: String(data.version) > current, source: "remote", note: data.note || "" }; } return { latest: current, hasUpdate: false, source: "unknown", note: "检查失败,请稍后重试" }; } return { latest: current, hasUpdate: false, source: "local", note: "已是最新" }; }; /* ============================== src/fingerprint.js ============================== */ /** * fingerprint.js — 配置指纹(界面侧) * * ★ **逐字节同算法的一份拷贝**,源头在 `tiktok-auto/src/fingerprint.js`。 * * 为什么必须有两份:界面要在"点继续之前"就判断断点还有没有效(不折腾用户手机), * 引擎要在"启动 App 之前"再判断一次(用户也可能直接改配置手起引擎)。 * 项目没有共享模块机制,只能各放一份。 * * **改算法必须两边一起改**:两侧算出的指纹不同 = 界面说"能续跑"、引擎说"配置变了"。 * `tiktok-auto/tools/pc-selftest-id.js` 有一组断言专门比对两份实现的结果。 */ var Fingerprint = (function () { 'use strict'; var VERSION = 1; /** * 是不是数组。 * * 不用 `v instanceof Array`:跨 realm(PC 自检把 Node 造的对象喂进 vm 沙箱)时 * `instanceof` 会返回 false,那会让"数组"被当成普通对象序列化,两边算出的指纹就不一样了。 * * 也不用 `toString` 配 `Function.prototype.call` 的写法:那个关键字加左括号 * 命中 MCP 危险调用黑名单(`tools/build.js` 的 `DANGEROUS_RULES`),会让每次下发都要人工确认。 * 优先 `Array.isArray`(ES5,AutoX 的 Rhino 支持),没有时退回 `.apply`。 */ function isArray(v) { if (typeof Array.isArray === 'function') { return Array.isArray(v); } return Object.prototype.toString.apply(v, []) === '[object Array]'; } /** 自有键,排序后返回(键序无关 → 同一份配置换个键序不会让断点失效) */ function ownKeys(v) { var keys = []; for (var k in v) { if (Object.prototype.hasOwnProperty.apply(v, [k]) === true) { keys.push(k); } } keys.sort(); return keys; } /** * 稳定序列化:对象按键名排序、数组保持顺序、每个值带类型前缀。 * * 类型前缀是必须的 —— 否则 `"1"`(字符串)和 `1`(数字)会序列化成同一个串, * 而界面上"粉丝下限"这类字段确实可能一个是空串一个是 0。 */ function canon(v) { if (v === null || v === undefined) { return 'n'; } if (isArray(v)) { var parts = []; for (var i = 0; i < v.length; i += 1) { parts.push(canon(v[i])); } return '[' + parts.join(',') + ']'; } if (typeof v === 'object') { var keys = ownKeys(v); var out = []; for (var j = 0; j < keys.length; j += 1) { out.push(keys[j] + ':' + canon(v[keys[j]])); } return '{' + out.join(',') + '}'; } if (typeof v === 'number') { return 'd' + String(v); } if (typeof v === 'boolean') { return v ? 'T' : 'F'; } return 's' + JSON.stringify(String(v)); } /** * djb2-xor 摘要。 * * 末尾拼上原文长度:32 位散列碰撞概率本来就不高,加个长度等于多一维区分, * 而且是"免费"的(长度本来就要算)。输出是 base36,比十六进制短。 */ function digest(text) { var s = String(text); var h = 5381; for (var i = 0; i < s.length; i += 1) { h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0; /* |0 截成 32 位,避免精度丢失 */ } return (h >>> 0).toString(36) + '-' + s.length.toString(36); } /** 把一条取材归一化成"只含参与指纹的字段",缺项补默认 */ function ofSource(one, index) { var s = one || {}; return { key: s.key || ('i' + index + ':' + (s.kind || '')), kind: s.kind || '', anchors: s.anchors || '', lists: isArray(s.lists) ? s.lists : [], searchType: s.searchType || '', keyword: s.keyword || '', videoSource: s.videoSource || '', videoKeyword: s.videoKeyword || '', path: s.path || '', expand: s.expand || '' }; } /** * 算 `id` 段的指纹。 * * **分项存**(sources / filter / output 各一份)而不只存一个总串: * 界面要能说清"是**取材**改过了"而不是含糊的"配置变了",用户才知道该改回去什么。 * * @param id `config.id` 整段(界面传 `Engine.buildIdConfig().config.id`, * 引擎传解析后的 `config.id` —— 两者是同一份数据) */ function ofIdSection(id) { var section = id || {}; var list = isArray(section.sources) ? section.sources : []; var sources = []; for (var i = 0; i < list.length; i += 1) { sources.push(ofSource(list[i], i)); } var f = section.filter || {}; var o = section.output || {}; var sourcesHash = digest(canon(sources)); var filterHash = digest(canon({ private: f.private === true, noAvatar: f.noAvatar === true, langRegion: f.langRegion || 'any', nicknameCheck: f.nicknameCheck || 'any', follow: f.follow || {}, fans: f.fans || {}, likes: f.likes || {} })); var outputHash = digest(canon({ dir: o.dir || '', historyPath: o.historyPath || '', dedupeHistory: o.dedupeHistory !== false })); return { v: VERSION, sources: sourcesHash, filter: filterHash, output: outputHash, all: digest(sourcesHash + filterHash + outputHash) }; } /** * 比两份指纹。 * @returns { same, changed: ['sources'|'filter'|'output'] } */ function compare(a, b) { if (!a || !b || a.v !== VERSION || b.v !== VERSION) { return { same: false, changed: ['sources', 'filter', 'output'] }; } var changed = []; if (a.sources !== b.sources) { changed.push('sources'); } if (a.filter !== b.filter) { changed.push('filter'); } if (a.output !== b.output) { changed.push('output'); } return { same: changed.length === 0, changed: changed }; } return { VERSION: VERSION, canon: canon, digest: digest, ofIdSection: ofIdSection, compare: compare }; }()); /* ============================== src/engine.js ============================== */ /* ============================== src/engine.js ============================== * 执行引擎桥接:把界面收集的配置交给 tiktok-auto,再把它的进度读回来。 * * ## 为什么走文件,不走内存 * 界面与被唤起的引擎是**两个独立的 ScriptEngine**:各有各的全局作用域, * 界面里的对象传不过去(实测确认)。所以两边只通过约定好的四个文件说话: * * run-config.json 界面 → 人/追溯:完整配置快照(人可读) * engine-config.json 界面 → 引擎:映射后的引擎配置 * run-state.json 引擎 → 界面:进度与终态 * stop.flag 界面 → 引擎:停止请求(内容是本次运行的 runId) * * 文件通道是**异步**的,所以界面这边的规则是:**所有动作以状态文件的回报为准**, * 而不是"我点了就当我做了"。点「停止运行」后界面显示"正在收尾"、 * 等到引擎真的上报 stopped 才切回未运行 —— 否则界面说"已停止"而 TikTok 还在自己动, * 用户不知道该信谁。 * * ## 为什么不删 stop.flag * 停止靠**比内容**而不是靠"文件在不在":只有内容等于本次 runId 才算数。 * 于是不必删除它(删除既有竞态,`files.remove` 又命中 MCP 危险调用黑名单), * 上一轮残留的标志也不会误杀下一轮。 * * ## 能力边界要如实说 * tiktok-auto 目前实现的是「搜索 → 逐个看 → 评论」。界面「执行操作」里的 * 关注 / 私信 / 点赞**尚未接入**,`buildConfig()` 会把它们逐条记进 notes, * 由调用方决定怎么告知用户 —— **绝不静默忽略**: * 用户勾了点赞却什么都没发生,比明确告知"尚未支持"糟得多。 */ var Engine = {}; Engine.DIR = "/sdcard/tiktok_app"; Engine.CONFIG_PATH = Engine.DIR + "/engine-config.json"; Engine.STATE_PATH = Engine.DIR + "/run-state.json"; Engine.STOP_FLAG_PATH = Engine.DIR + "/stop.flag"; /** * 引擎的日志文件(jsonl,一行一条)。 * * 2026-10-13 新增:D3 运行详情里的「执行日志」要看的就是它。 * 为什么走文件而不是读 console:引擎是**另一个 ScriptEngine**, * 它的 stdout 界面根本读不到(见本文件头的通道说明)。 * 引擎侧由 `logger.setSink` → `files.append` 落盘(tiktok-auto/src/bridge.js)。 */ Engine.LOG_PATH = Engine.DIR + "/run-log.jsonl"; /** 一条记录最多带走多少行日志(记录要落盘,不能让日志把它撑爆)。 */ Engine.LOG_MAX_LINES = 200; /** 引擎产物在项目里的相对位置(项目模式下从这个位置找) */ Engine.SCRIPT_RELATIVE = "tiktok-auto/dist/tiktok-auto.js"; /** 本次运行的标识。由界面生成并同时告诉引擎与停止标志,三方比对。 */ Engine.RUN_ID = ""; /** 上次定位到的引擎产物路径;null 表示还没找过 */ Engine.scriptPath = null; /** * 是否**确实在引擎列表里见过**子引擎。 * * 用途唯一:给"引擎没写终态就没了"的兜底判定加一道保险。 * `engines.all()` 在不同运行时里的可见性不完全一致 —— 如果它从来就不报告子引擎, * 那"看不到子引擎"这件事毫无信息量,此时宁可不判定,也不要误把正在跑的运行结束掉。 */ Engine.sawChild = false; /* ------------------------------------------------------------ 运行标识 */ /** * 生成本次运行的标识。 * 用「时间戳 + 随机串」:同一秒内连点两次也不会撞, * 而且人在日志里一眼能看出这是哪一次运行。 */ Engine.newRunId = function () { return "run-" + Date.now() + "-" + Math.floor(Math.random() * 100000); }; /* ------------------------------------------------------------ 定位引擎 */ /** 探测用的候选路径,按「最可能正确」排序。 */ Engine.candidates = function () { var list = []; /* ① 入口脚本同级 —— 项目模式下最准。 * 用 myEngine().source 而不是 files.cwd():后者依赖运行方式, * 前者永远是入口脚本的真实路径。 */ try { var source = String(engines.myEngine().source || ""); var cut = source.lastIndexOf("/"); if (cut > 0) { list.push(source.substring(0, cut) + "/" + Engine.SCRIPT_RELATIVE); } } catch (e) { /* 取不到就靠后面的候选 */ } /* ② 扫一层项目目录:产物可能来自另一个项目文件夹 */ try { var names = files.listDir("/sdcard/Scripts"); for (var i = 0; i < names.length; i += 1) { var dir = "/sdcard/Scripts/" + names[i]; if (files.isDir(dir)) { list.push(dir + "/" + Engine.SCRIPT_RELATIVE); } } } catch (e) { /* 没这个目录就跳过 */ } /* ③ 扁平部署:产物被单独拷到 Download(手工推送时的常见形态) */ list.push("/sdcard/Download/tiktok-auto.js"); list.push(Engine.DIR + "/tiktok-auto.js"); return list; }; /** * 找到引擎产物。 * @returns 路径;找不到返回空串(调用方负责给出可操作的提示) */ Engine.locate = function () { var list = Engine.candidates(); for (var i = 0; i < list.length; i += 1) { try { if (files.exists(list[i])) { Engine.scriptPath = list[i]; return list[i]; } } catch (e) { /* 单个候选探测失败不该中断整轮查找 */ } } Engine.scriptPath = null; return ""; }; /* ------------------------------------------------------------ 配置映射 */ function trimAll(text) { return String(text).replace(/^\s+|\s+$/g, ""); } /** * 读文本文件的行,跳过空行与 `#` 开头的注释行。 * 读不到(文件不存在 / 权限问题)时返回空数组 —— 调用方按"没配"处理。 */ function readLines(path) { var out = []; try { if (!path || !files.exists(path)) { return out; } var lines = String(files.read(path)).split(/\r?\n/); for (var i = 0; i < lines.length; i += 1) { var text = trimAll(lines[i]); if (text !== "" && text.charAt(0) !== "#") { out.push(text); } } } catch (e) { console.log("[engine] 读取文本失败 " + path + ": " + e); } return out; } function randomInt(min, max) { var low = Math.floor(min); var high = Math.floor(max); if (high < low) { high = low; } return low + Math.floor(Math.random() * (high - low + 1)); } /** 秒 → 毫秒,并给一个下限:0 秒会让"观看"立刻返回,等于没看 */ function seconds(ms) { return Math.max(1000, Math.floor(ms) * 1000); } /** 分钟 → 毫秒。下限给 1 分钟:填 0 会让"浏览时长"立刻到点,一轮都跑不完 */ function minutesToMs(value) { return Math.max(60000, Math.floor(value) * 60 * 1000); } /** * 概率档位(`r30`)→ 小数(0.30)。 * * 认不出来就返回 0 —— **宁可不做,不可乱做**:把非法值当成某个默认概率, * 用户看到的档位和引擎实际执行的概率就悄悄不一致了。 */ function rateOf(key) { var matched = String(key || "").match(/^r(\d+)$/); if (!matched) { return 0; } return Number(matched[1]) / 100; } /** * 界面配置 → 引擎配置。 * * @param task 当前要跑的任务(`App.startRun` 传进来的那个对象,含 `task` 字段) * @returns { config, notes } * notes 是「用户表达了意图、但引擎这次做不到」的条目,逐条是人话。 */ Engine.buildConfig = function (task, opts) { /* 两条链路的配置形状完全不同(视频链路是"搜索→看→评",ID提取是"取材清单"), * 所以在这里分派,而不是往一个函数里塞两套 if —— 那样两边都会变得难读且易改错。 */ if (task && task.task === "id") { return Engine.buildIdConfig(opts); } if (task && task.task === "nurture") { return Engine.buildNurtureConfig(); } if (task && task.task === "dm") { return Engine.buildDmConfig(); } return Engine.buildVideoConfig(); }; /** * UID 私信参数 → 引擎配置。 * * 界面的「关注数量」「私信数量」都是**区间**,引擎每次运行在区间里随机取一个数 —— * 固定不变会让每次运行发出去的量完全一样,反而更容易被看出是脚本。 * * 两批是**完全不同的人**:前 `follow.max` 个只关注,接下来 `send.max` 个只发私信。 * 所以 UID 文件里的号要够多(至少 关注上限 + 私信上限)。 */ Engine.buildDmConfig = function () { var notes = []; /* 两个脚本是单选(页面侧 `toggleDmScript` 保证互斥)。万一都勾着(手改过配置、 * 或将来某次改动破坏了互斥),按脚本一处理 —— 那是"有明确输入"的那条, * 比静默换源安全;但**必须说出来**,否则用户以为跑的是脚本二。 */ var s1On = Store.getBool("tabs.dm.scripts.s1.checked"); var s2On = Store.getBool("tabs.dm.scripts.s2.checked"); var useS2 = s2On && !s1On; var script = useS2 ? "s2" : "s1"; if (s1On && s2On) { notes.push("两个私信脚本都勾着,本轮按脚本一执行(这一页是单选)"); } var followOn = Store.getBool("tabs.dm.doFollow"); var sendOn = Store.getBool("tabs.dm.doDm"); var followLo = Store.getNumber("tabs.dm.followMin"); var followHi = Store.getNumber("tabs.dm.followMax"); var sendLo = Store.getNumber("tabs.dm.dmMin"); var sendHi = Store.getNumber("tabs.dm.dmMax"); var restLo = Store.getNumber("tabs.dm.restMin"); var restHi = Store.getNumber("tabs.dm.restMax"); /* ★ 兜底:设备上可能还留着**最初的旧默认** 100~500 秒(迁移在某些历史版本上没生效)。 * 那个值会让一轮跑 5~24 小时,实际跑不完 —— 所以这里再兜一次。 * 只在"正好等于旧默认"时改,并记一条 note 让用户知道(要用旧值就显式改一下)。 */ if (restLo === 100 && restHi === 500) { restLo = 10; restHi = 40; notes.push("休息时间仍是最初的 100~500 秒(一轮要跑 5~24 小时),本次按 10~40 秒执行"); } var pool = readLines(Store.getText("tabs.dm.dmTextPath")); if (sendOn && pool.length === 0) { notes.push("私信文案文件是空的,本轮发不出私信(请检查「随机私信文本」)"); } if (useS2) { notes.push("脚本二「自己粉丝私信」:从你自己的粉丝列表取材(先回关 " + "「关注数量」个,再给「私信数量」个发私信)"); } var config = { task: "dm", dm: { script: script, /* 脚本二不用这个路径(人从粉丝列表采);传空串而不是留着旧路径 —— * 留着会让日志里出现一个"看起来在用的文件",排障时误导。 */ uidPath: useS2 ? "" : Store.getText("tabs.dm.scripts.s1.uidTextPath"), /* 每次运行在区间里随机取一个数 */ follow: { enabled: followOn, max: followOn ? randomInt(followLo, followHi) : 0 }, send: { enabled: sendOn, max: sendOn ? randomInt(sendLo, sendHi) : 0 }, rest: { minMs: seconds(restLo), maxMs: seconds(restHi) }, dedupeMode: Store.getEnum("tabs.dm.dedupeMode", "dedupeMode"), filterDate: Store.getEnum("tabs.dm.scripts.s1.filterDate", "filterDate"), firstAction: Store.getEnum("tabs.dm.scripts.s1.firstAction", "firstAction"), pool: pool, commentPool: readLines(Store.getText("tabs.dm.commentTextPath")), guard: { maxRuntimeMs: 5 * 60 * 60 * 1000 } }, runtime: { dryRun: false, maxRuntimeMs: 0 } }; return { config: config, notes: notes }; }; /** * 养号参数 → 引擎配置。 * * 界面只有 7 个字段,其中两对是区间: * · 浏览时长(分钟) → nurture.browse * · 单次操作 → nurture.rounds(**是轮数,不是时间间隔**) * · 点赞/关注概率 → 档位 r30 → 0.30 * · 评论概率 → **不接**(见下) */ Engine.buildNurtureConfig = function () { var notes = []; var roundsMin = Store.getNumber("tabs.nurture.actionMin"); var roundsMax = Store.getNumber("tabs.nurture.actionMax"); var browseMin = Store.getNumber("tabs.nurture.browseMin"); var browseMax = Store.getNumber("tabs.nurture.browseMax"); var likeRate = rateOf(Store.getEnum("tabs.nurture.likeRate", "likeRate")); var followRate = rateOf(Store.getEnum("tabs.nurture.followRate", "followRate")); var commentWanted = rateOf(Store.getEnum("tabs.nurture.commentRate", "commentRate")); /* ★ 评论强制 0(2026-10-28 实测结论)。 * * 养号是在**首页信息流**里跑的。那里的评论面板一打开,右侧竖排动作栏 * (关注/点赞/评论/收藏/分享/音乐)的无障碍节点仍然留在树里,且与输入框同高; * 而真正那个"发送"按钮**在无障碍树里根本没有对应节点** * (把输入框整行右侧的所有节点 dump 出来,只有动作栏那六个 * + 每条评论自己的赞/表情 + 作者头像,没有发送键)。 * * 引擎是 `execScriptFile` 起的子脚本,`gesture()` 在这个环境里无效、 * `shell('input tap')` 没有注入权限(SecurityException)—— * 唯一能用的无障碍点击打不到一个"树里不存在"的按钮。 * * 所以这里如实传 0 并把原因写进 notes,让界面告诉用户"本轮不会评论", * 而不是传一个会让引擎白试一轮、最后只留下一条 warning 的概率。 */ if (commentWanted > 0) { notes.push("评论:当前 TikTok 版本不支持自动发送(发送键不在无障碍树里),本轮不会评论"); } var config = { task: "nurture", nurture: { /* 「单次操作」= 轮数(看一个视频 + 可能互动,算一轮) */ rounds: { min: roundsMin, max: roundsMax }, /* 「浏览时长」= 墙钟上限。与轮数**各自独立**,谁先到谁停 */ browse: { minMs: minutesToMs(browseMin), maxMs: minutesToMs(browseMax) }, likeRate: likeRate, commentRate: 0, followRate: followRate }, comments: { enabled: false, pool: [] }, runtime: { dryRun: false, maxRuntimeMs: 0 } }; return { config: config, notes: notes }; }; /* ------------------------------------------------------------ ID 提取 */ /** ID 提取的产物目录(与其它通道文件同根,方便一起清理/查看) */ Engine.ID_DIR = "/sdcard/Download/tiktok_app"; /** * 断点文件(ID提取的"跑到哪了")。 * * 与产物同目录:用户清产物时看得见它;而且它引用的是 runFile 的绝对路径, * 放在一起语义最直白。引擎侧同一份路径由 `id.resume.path` 传下去。 */ Engine.ID_PROGRESS_PATH = Engine.ID_DIR + "/id_progress.json"; /** * 界面 → 引擎(task = 'id')。 * * ## 取材清单几乎是原样透传 * `Sources` 模块当初就是**照着引擎侧的契约**设计的(`kind` + 每种起点的参数 + `expand`), * 所以这里不需要再翻译一遍字段名。少一层翻译就少一处"路径拼错=静默丢值"的机会。 * * ## 做不到的项一律进 notes * 这是项目一贯的规矩:用户勾了却什么都没发生,比明确告知"这次不生效"糟得多。 */ Engine.buildIdConfig = function (opts) { var notes = []; var base = "tabs.id.features."; var sources = Sources.list(); var engineSources = []; for (var i = 0; i < sources.length; i++) { var one = sources[i]; /* `key` 就是界面的 `id`:断点按它认"哪条取材做完了"。 * 以前刻意不带(引擎不需要),但续跑必须带 —— 否则断点里的条目和配置里的 * 条目只能靠下标对齐,用户删一条就全错位。 */ engineSources.push({ key: one.id || "", kind: one.kind, anchors: one.anchors || "", lists: one.lists || [], searchType: one.searchType || "users", keyword: one.keyword || "", videoSource: one.videoSource || "screen", videoKeyword: one.videoKeyword || "", path: one.path || "", expand: one.expand || "" }); } var config = { task: "id", id: { sources: engineSources, filter: { private: Store.getBool(base + "filterPrivate"), noAvatar: Store.getBool(base + "filterNoAvatar"), langRegion: Store.getEnum(base + "langRegion", "langRegion"), nicknameCheck: Store.getEnum(base + "nicknameCheck", "nicknameCheck"), /* 四组区间:界面是"最小值 / 最大值"两格,0 表示该侧不设限 */ follow: { min: Store.getNumber(base + "followMin"), max: Store.getNumber(base + "followMax") }, fans: { min: Store.getNumber(base + "fansMin"), max: Store.getNumber(base + "fansMax") }, likes: { min: Store.getNumber(base + "likesMin"), max: Store.getNumber(base + "likesMax") }, videos: { min: Store.getNumber(base + "videosMin"), max: Store.getNumber(base + "videosMax") } }, output: { dir: Engine.ID_DIR, historyPath: Engine.ID_DIR + "/uid_extracted.txt", dedupeHistory: Store.getBool(base + "dedupeHistory"), dryRun: false, /* 0 = 不限。引擎另有「单次最长运行」与「解析 uid 独立预算」两道时长护栏 */ limit: Store.getNumber(base + "extractLimit"), /* 抓完之后换数字 uid。默认开(数字 uid 在界面上读不到,不换就得事后另跑), * 每个约多花 2.5 秒 —— 「输出」卡片上给了开关,用户可关。 */ resolveUid: Store.getBool(base + "resolveUid") }, guard: { /* 抓取阶段最长多久(分钟 → 毫秒)。到点收尾,已抓的不丢。 */ maxRuntimeMs: Math.max(1, Store.getNumber(base + "maxRuntimeMin")) * 60 * 1000 }, /** * 断点续跑。**只有点「继续」时才为 true**。 * * 引擎侧看到它才会去读断点文件;普通「开始运行」传 false, * 于是"想从头跑一次"永远是从头跑(不会被上一轮的断点悄悄接管)。 */ resume: { enabled: !!(opts && opts.resume), path: Engine.ID_PROGRESS_PATH } }, runtime: { configPath: Engine.CONFIG_PATH, dryRun: false, maxRuntimeMs: 0 } }; notes = notes.concat(Engine.unsupportedNotes({ task: "id" })); /* 「本地去重」关掉时要说清楚后果:它不只是"少一道过滤", * 而是同一个 ID 会在产物文件里重复出现,下游私信会重复发。 */ if (!Store.getBool(base + "dedupeLocal")) { notes.push("\u672C\u5730\u53BB\u91CD\uFF1A\u5DF2\u5173\u95ED\uFF0C\u540C\u4E00\u4E2A ID \u53EF\u80FD\u91CD\u590D\u5199\u5165"); } return { config: config, notes: notes }; }; /* ------------------------------------------------------------ 视频链路 */ Engine.buildVideoConfig = function () { var notes = []; var keyword = Store.getText("tabs.video.keyword"); var stopMin = Store.getNumber("tabs.video.stopMin"); var stopMax = Store.getNumber("tabs.video.stopMax"); var intervalMin = Store.getNumber("tabs.video.intervalMin"); var intervalMax = Store.getNumber("tabs.video.intervalMax"); var doComment = Store.getBool("tabs.video.actions.comment"); var pool = readLines(Store.getText("tabs.video.commentTextPath")); if (doComment && pool.length === 0) { notes.push("\u8BC4\u8BBA\u6587\u672C\u91CC\u6CA1\u6709\u53EF\u7528\u884C\uFF0C" + "\u672C\u6B21\u4F1A\u7528\u5185\u7F6E\u9ED8\u8BA4\u8BC4\u8BBA"); } var config = { target: { keyword: keyword, /* 「停止个数 最小值 ~ 最大值」= 每次运行在这个区间里随机停 —— * 固定不变会让每次运行的行为完全一样,反而更容易被看出是脚本。 */ maxVideos: randomInt(stopMin, stopMax), resultTab: "Top", pickStrategy: "random" }, /* 界面的「间隔时间(秒)」映射为**每个视频的观看时长**。 * 引擎目前没有单独的"两个视频之间的停顿"配置,而"看 20 秒再操作" * 与"每 20 秒处理一个"在行为上是一回事。 */ watch: { minMs: seconds(intervalMin), maxMs: seconds(intervalMax) }, /* 点赞 / 关注:与评论同级的两个动作开关。 * 界面上「执行操作」是四项多选,用户勾了才做 —— 没勾就一个都不点。 */ likes: { enabled: Store.getBool("tabs.video.actions.like") }, follows: { enabled: Store.getBool("tabs.video.actions.follow") }, comments: { /* ★ 勾了「评论」才发评论。没勾就只是浏览 —— * 引擎侧据此跳过评论动作,而不是照默认值照发。 * 用户的意图优先于引擎的默认行为。 */ enabled: doComment, avoidRepeatWithinRun: true, verifyInList: true }, runtime: { configPath: Engine.CONFIG_PATH, dryRun: false, maxRuntimeMs: 0 } }; if (pool.length > 0) { config.comments.pool = pool; } notes = notes.concat(Engine.unsupportedNotes({ task: "video" })); return { config: config, notes: notes }; }; /** * 引擎这次做不到、但用户可能勾了的项。 * * 单独抽出来是因为它只跟「已实现的动作集合」有关, * 将来引擎接入了点赞,**只改这一个函数**就够。 */ Engine.unsupportedNotes = function (task) { var notes = []; if (task && task.task === "id") { return Engine.unsupportedIdNotes(); } /* 点赞与关注已由引擎实现(src/interact.js),不再报"尚未实现"。 * 只剩私信一项还在等(见 UID私信 任务)。 */ if (Store.getBool("tabs.video.actions.dm")) { notes.push("\u79C1\u4FE1\u52A8\u4F5C\uFF1A\u5F15\u64CE\u5C1A\u672A\u5B9E\u73B0"); } return notes; }; /** * ID 提取里"用户可能配了、但这次不生效"的项。 * * 判据是**偏离默认值**,而不是"有值" —— 这一条很关键: * 功能设置里有好几项的默认值本身就是非空的(`regionCodes = "JP#TW#US"`、 * `aiAgeMax = 20`、`videosMax = 500000`)。若按"有值就报", * 每次运行都会冒出一串"未生效",把真正需要注意的那条淹没 —— * 而 note 的本意是"你表达了意图,这次做不到",默认值并不代表任何意图。 */ Engine.unsupportedIdNotes = function () { var notes = []; var base = "tabs.id.features."; /** 这一项被用户改过吗(与默认值不同) */ function changed(path) { return String(Store.get(path)) !== String(Store.defaultOf(path)); } /* AI 三项:需要模型,本次不做。界面上是灰行,理论上改不动; * 老配置里可能残留过值,所以仍然按"改过才报"来处理。 */ if (changed(base + "aiAvatar") && Store.getBool(base + "aiAvatar")) { notes.push("AI \u5934\u50CF\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165"); } if (changed(base + "aiGender") && Store.getBool(base + "aiGender")) { notes.push("AI \u6027\u522B\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165"); } if (changed(base + "aiAgeMax") && Store.getNumber(base + "aiAgeMax") > 0) { notes.push("AI \u5E74\u9F84\u8BC6\u522B\uFF1A\u5C1A\u672A\u63A5\u5165"); } /* 云端去重:契约已定但服务端没接。降级为本地去重并**说出来**,绝不静默。 */ if (Store.getBool(base + "cloudDedupe")) { notes.push("\u4E91\u7AEF\u53BB\u91CD\uFF1A\u670D\u52A1\u672A\u63A5\u5165\uFF0C\u672C\u6B21\u53EA\u7528\u672C\u5730\u53BB\u91CD"); } /* 视频量门槛:真机实测用户页**只显示 关注/粉丝/获赞 三组数字,没有视频数**。 * 引擎侧对这条的处理是"放行 + 告知"(见 tiktok-auto/src/filters.js 文件头)。 * 默认上限 500000 在语义上等于"不限",所以只有用户真的收窄了才报。 */ if (changed(base + "videosMin") || changed(base + "videosMax")) { notes.push("\u89C6\u9891\u91CF\u95E8\u69DB\uFF1A\u7528\u6237\u9875\u4E0D\u663E\u793A\u8BE5\u6570\u5B57\uFF0C\u672C\u6B21\u4E0D\u53C2\u4E0E\u8FC7\u6EE4"); } /* 无头像靠像素判定,纯黑白照片会被误判 —— 用户勾了就得先知道这个局限。 */ if (Store.getBool(base + "filterNoAvatar")) { notes.push("\u65E0\u5934\u50CF\u68C0\u6D4B\uFF1A\u6309\u50CF\u7D20\u8272\u5F69\u5224\u5B9A\uFF0C\u7EAF\u9ED1\u767D\u7167\u7247\u4F1A\u88AB\u8BEF\u5224"); } /* 「提取区域」刻意**不进 notes**:它在界面上是一行灰字 + 「暂不支持」胶囊, * 用户已经看得到;每次运行再报一次只会变成噪音。它的值仍保留在配置里, * 将来接上数据源时直接生效。 */ return notes; }; /** * notes → 一句能放在 toast 里的话(没有就返回空串)。 * * 只取每条的**短名**(中文冒号前的部分)拼起来:三条完整说明拼起来有四十多个字, * 那种长度的 toast 用户根本来不及读。完整内容在 run-config.json 里,随时可查。 */ Engine.summarizeNotes = function (notes) { if (!notes || notes.length === 0) { return ""; } var names = []; for (var i = 0; i < notes.length; i += 1) { names.push(String(notes[i]).split("\uFF1A")[0]); } return names.join("/") + " \u672C\u6B21\u4E0D\u4F1A\u751F\u6548"; }; /* ------------------------------------------------------------ 启动 */ /** * 准备一次运行:定位产物 + 构建配置。 * * @param task 当前要跑的任务(含 `task` 字段);不传按视频链路处理(向后兼容) * @returns { ok, scriptPath, config, notes, error } * error 是给用户看的(说清楚"缺什么、放哪"),不是技术异常串。 */ Engine.prepare = function (task, opts) { var path = Engine.locate(); if (!path) { return { ok: false, error: "\u627E\u4E0D\u5230\u6267\u884C\u5F15\u64CE\u4EA7\u7269\uFF08" + Engine.SCRIPT_RELATIVE + "\uFF09\u3002\n\n" + "\u8BF7\u5728 PC \u4E0A\u6267\u884C node tiktok-auto/tools/build.js \u540E\uFF0C" + "\u5C06 tiktok-auto/dist/tiktok-auto.js \u653E\u5230 \u9879\u76EE\u6839\u76EE\u5F55\u4E0B,\n" + "\u6216\u76F4\u63A5\u63A8\u5230 /sdcard/Download/tiktok-auto.js\u3002" }; } var built = Engine.buildConfig(task, opts); /* 「关键词为空」是**视频链路**的硬前提(空关键词搜索必失败)。 * ID 提取与养号都不需要关键词:前者可以只从粉丝列表或文本文件取材, * 后者是在首页信息流里随机刷、根本没有目标。 * 所以这条拦截必须按任务分支 —— 不分支的话,养号一点运行就会 * 崩在 `built.config.target.keyword`(target 根本不存在,TypeError), * 而且崩得**毫无提示**(AutoX 上脚本直接退出,界面回桌面)。 */ if (task && task.task === "id") { if (!built.config.id.sources || built.config.id.sources.length === 0) { return { ok: false, scriptPath: path, error: "\u8BF7\u5148\u5728\u300CID\u63D0\u53D6\u300D\u9875\u6DFB\u52A0\u81F3\u5C11\u4E00\u6761\u53D6\u6750\u3002" }; } return { ok: true, scriptPath: path, config: built.config, notes: built.notes }; } if (task && task.task === "nurture") { /* 养号没有"必填项"要拦:轮数与时长都有默认值,概率档位也总是合法的。 * 直接放行(真正的保护在引擎侧:config.validate 会校验 rounds/browse)。 */ return { ok: true, scriptPath: path, config: built.config, notes: built.notes }; } if (task && task.task === "dm") { /* 私信也走这里早返回 —— 它的配置里同样**没有 `target`**。 * 不早返回的话会落到下面那句 `built.config.target.keyword` 上直接 TypeError, * 而且崩得毫无提示(脚本退出、界面回桌面,只有 logcat 能看到)。 */ return { ok: true, scriptPath: path, config: built.config, notes: built.notes }; } if (trimAll(built.config.target.keyword) === "") { return { ok: false, scriptPath: path, error: "\u5173\u952E\u8BCD\u4E3A\u7A7A\uFF0C\u8BF7\u5148\u5728\u300C\u89C6\u9891\u529F\u80FD\u300D\u9875\u586B\u5199\u8981\u641C\u7D22\u7684\u5173\u952E\u8BCD\u3002" }; } return { ok: true, scriptPath: path, config: built.config, notes: built.notes }; }; /** 把引擎配置落盘。返回是否成功。 */ Engine.writeConfig = function (config) { try { files.ensureDir(Engine.DIR + "/"); files.write(Engine.CONFIG_PATH, JSON.stringify(config, null, 2)); return true; } catch (e) { console.log("[engine] 引擎配置落盘失败: " + e); return false; } }; /** * 唤起引擎(另一个 ScriptEngine,不是当前脚本的线程)。 * * 参数用 `--key value` 的平铺形式传:Rhino 会把数组包装成 {"0":..,"1":..}, * 桥那边按数字下标取,两边约定越简单越不容易出错。 */ Engine.launch = function (runId, scriptPath, opts) { try { var argv = [ "--config", Engine.CONFIG_PATH, "--state", Engine.STATE_PATH, "--stop-flag", Engine.STOP_FLAG_PATH, "--log", Engine.LOG_PATH, "--run-id", runId ]; /* 只有点「继续」才带这两个参数。普通「开始运行」绝不能带 —— * 不然用户想从头跑一次,结果它接着上次跑。 */ if (opts && opts.resume) { argv.push("--resume"); argv.push("--checkpoint"); argv.push(Engine.ID_PROGRESS_PATH); } var handle = engines.execScriptFile(scriptPath || Engine.scriptPath, { arguments: argv }); if (!handle) { return { ok: false, error: "\u5F15\u64CE\u542F\u52A8\u8FD4\u56DE\u7A7A\uFF08\u53EF\u80FD\u88AB\u7CFB\u7EDF\u62E6\u622A\uFF09" }; } return { ok: true }; } catch (e) { console.log("[engine] 唤起失败: " + e); return { ok: false, error: String(e) }; } }; /* ------------------------------------------------------------ 日志回读 */ /** * 读引擎这次运行写下的日志(D3 的「执行日志」)。 * * 三条原则: * · **一行坏了只丢一行**:jsonl 逐行 parse,坏行跳过 —— 进程被杀时最后一行 * 常常是半截的,整份 JSON.parse 会直接全丢; * · 只取**最近** LOG_MAX_LINES 行:记录要落盘,日志不能把它撑到几百 KB; * · 读不到就返回空数组,由页面如实说明「本次没有回传日志」, * 绝不编一行"看起来像日志"的东西。 */ Engine.readLog = function () { var out = []; try { if (!files.exists(Engine.LOG_PATH)) { return out; } var lines = String(files.read(Engine.LOG_PATH)).split("\n"); var start = Math.max(0, lines.length - 1 - Engine.LOG_MAX_LINES); for (var i = start; i < lines.length; i++) { var text = String(lines[i]).replace(/^\s+|\s+$/g, ""); if (text === "") { continue; } try { var one = JSON.parse(text); if (one && one.text) { out.push(one); } } catch (e) { /* 半截行:跳过这一行,其余照收 */ } } } catch (e2) { console.log("[engine] 读取运行日志失败: " + e2); } return out; }; /* ------------------------------------------------------------ 状态回读 */ /** 读引擎上报的状态。没有 / 读坏了都返回 null。 */ Engine.readState = function () { try { if (!files.exists(Engine.STATE_PATH)) { return null; } var parsed = JSON.parse(String(files.read(Engine.STATE_PATH))); return parsed && typeof parsed === "object" ? parsed : null; } catch (e) { return null; } }; /** 运行中(含正在收尾)—— 界面据此继续显示状态条 */ Engine.isActiveStatus = function (status) { return status === "starting" || status === "running" || status === "stopping"; }; /** 终态 —— 界面据此结束运行态并告知结果 */ Engine.isTerminalStatus = function (status) { return status === "finished" || status === "stopped" || status === "failed"; }; /** 请求停止:写停止标志(内容 = runId,引擎比对通过才会停) */ Engine.requestStop = function (runId) { try { files.ensureDir(Engine.DIR + "/"); files.write(Engine.STOP_FLAG_PATH, String(runId)); return true; } catch (e) { console.log("[engine] 写停止标志失败: " + e); return false; } }; /** * 引擎是否还活着。 * * 用途只有一个:**兜底判定"引擎没写终态就没了"**(被系统杀掉、被手工停掉)。 * 查不出来时返回 true(当作还活着)—— 宁可多显示一会儿"运行中", * 也不要误判成"已结束"而把运行态清掉、让用户以为跑完了。 */ Engine.childAlive = function () { try { var mine = engines.myEngine(); var all = engines.all() || []; for (var i = 0; i < all.length; i += 1) { var one = all[i]; if (one && String(one.id) !== String(mine.id)) { return true; } } return false; } catch (e) { return true; } }; /** 复位内存态。**不删文件**:状态靠 runId 比对,残留自然失效。 */ Engine.reset = function () { Engine.RUN_ID = ""; }; /* ============================== src/session.js ============================== */ /* ============================== src/session.js ============================== * 运行会话状态:跑没跑、跑的是哪个脚本、跑了多久。 * * 为什么单独一个模块,而不是塞进 Store: * Store 是**持久化**的配置,而「正在运行」是**进程内**的瞬时状态。 * 把它写进配置会有两个后果: * ① 上次没点「停止运行」就杀进程,下次启动界面会显示成「运行中」—— * 可 TikTok 那边根本没人在驱动,用户等半天以为卡住了; * ② 计时起点会被迫落盘,进程重启后算出来的时长是错的。 * 所以这里只存在于内存里,冷启动一律是「未运行」。 * * 真正的执行由 tiktok-auto 那套脚本负责,本模块只负责 * 「界面上的运行态」与「给执行侧的那份运行配置」保持一致。 */ var Session = {}; Session.running = false; Session.tab = ""; Session.scriptKey = ""; Session.scriptName = ""; Session.startedAt = 0; /** 本次运行的标识。与引擎上报的状态、停止标志三者比对,缺一不可。 */ Session.runId = ""; /** 引擎上报的阶段文案("搜索 fashion" / "已完成 2/5"…)。空串表示还没收到。 */ Session.phase = ""; /** 进度计数,由引擎上报。仅用于状态条,不参与判断。 */ Session.done = 0; Session.total = 0; /** 已发出停止请求、正在等引擎确认。 */ Session.stopping = false; /** 发出停止请求的时刻,用于超时兜底(引擎不确认也不能一直等)。 */ Session.stopRequestedAt = 0; /* 计时器句柄。用 setInterval 而不是 threads.start: * UI 模式下 setInterval 的回调跑在主线程的 Looper 上,可以安全改 View; * 而 threads.start + ui.run 那条链路在真机上改 TextView 会崩 * (见 page-mine.js 里 checkUpdateAsync 的注释)。 */ Session.tickHandle = null; /** * 启动一次运行。 * * @param runId 引擎桥的运行标识。界面用它过滤状态文件 —— * 设备上可能残留上一轮的状态,不比对就会把旧结果当成本轮结果。 */ Session.start = function (tab, scriptKey, scriptName, runId) { Session.running = true; Session.tab = tab; Session.scriptKey = scriptKey; Session.scriptName = scriptName; Session.startedAt = Date.now(); Session.runId = runId || ""; Session.phase = ""; Session.done = 0; Session.total = 0; Session.stopping = false; Session.stopRequestedAt = 0; }; Session.stop = function () { Session.running = false; Session.tab = ""; Session.scriptKey = ""; Session.scriptName = ""; Session.startedAt = 0; Session.runId = ""; Session.phase = ""; Session.done = 0; Session.total = 0; Session.stopping = false; Session.stopRequestedAt = 0; }; /** * 运行条主文案(设计稿 B2:`● UID私信 · 脚本一 · 运行中`)。 * * 三段拼装:任务名 · 脚本短名 · 状态。 * 脚本短名与任务名相同时**不重复显示**(视频功能页只有一个脚本, * 拼出来会变成「视频功能 · 关键词视频功能 · 运行中」,白占一半宽度)。 * * 「正在收尾」是刻意保留的一个中间态:点了停止之后引擎还要几秒才真的停手 * (要退出当前等待、回到信息流)。这时界面如果说「已停止」, * 而 TikTok 还在自己动,用户不知道该信谁 —— 如实说"正在收尾"更对。 */ Session.barText = function () { var taskName = Pages.taskName(Session.tab); var short = Pages.shortScriptName(Session.scriptName); /* 脚本短名与任务名重合(或已包含任务名,如「视频功能」⊂「关键词视频功能」)时 * 不再重复一遍:拼出来会变成「视频功能 · 关键词视频功能 · 运行中」,白占一半宽度。 */ var dup = short === "" || short === taskName || short.indexOf(taskName) >= 0; var head = dup ? taskName : (taskName + " \u00B7 " + short); if (Session.stopping) { return head + " \u00B7 \u6B63\u5728\u6536\u5C3E"; } if (Session.phase) { return head + " \u00B7 " + Session.phase; } return head + " \u00B7 \u8FD0\u884C\u4E2D"; }; /** 兼容旧调用点(自检里按这个名字断言过)。语义就是 barText。 */ Session.statusText = function () { return Session.barText(); }; /** 已运行毫秒数。未运行时返回 0,避免调用方拿到负数。 */ Session.elapsedMs = function () { if (!Session.running || !Session.startedAt) { return 0; } return Math.max(0, Date.now() - Session.startedAt); }; /** 毫秒 → 01:24:37 形态。固定两位,等宽字下不会左右跳。 */ Session.formatElapsed = function (ms) { var total = Math.floor(ms / 1000); var h = Math.floor(total / 3600); var m = Math.floor((total % 3600) / 60); var s = total % 60; return pad2(h) + ":" + pad2(m) + ":" + pad2(s); }; function pad2(n) { return n < 10 ? "0" + n : String(n); } /** * 每秒推一次计时。 * * onTick 只做「改一个 TextView 的文字」,不重建界面—— * 每秒重建整页会让滚动位置归零,用户根本没法边跑边看配置。 */ Session.startTimer = function (onTick) { Session.stopTimer(); onTick(); Session.tickHandle = setInterval(function () { /* 回调里任何异常都不能让它继续每秒抛一次,否则日志会被刷爆 */ try { onTick(); } catch (e) { console.log("[session] 计时刷新失败: " + e); Session.stopTimer(); } }, 1000); }; Session.stopTimer = function () { if (Session.tickHandle !== null) { clearInterval(Session.tickHandle); Session.tickHandle = null; } }; /* ============================== src/resume.js ============================== */ /** * resume.js — 断点续跑的界面侧(读断点、判断能不能续、清空) * * ## 职责边界 * 界面**不写**断点 —— 断点是引擎在跑的时候写的(它才知道"这条取材走完了")。 * 界面只做三件事:读出来、判断还能不能用、在用户点「继续」时把开关传给引擎。 * 唯一会写的一处是「清空断点」:覆盖成 `state:"none"`(**不删文件**, * `remove(` 命中 MCP 危险调用黑名单,见 `tiktok-auto/tools/build.js`)。 * * ## 为什么判断要放在界面 * 用户点「继续」之前就得知道"这个断点还用不用得上",否则会出现 * 点了继续 → 引擎启动 → 打开 TikTok → 才发现配置变了 → 白折腾一趟。 * 引擎侧也会再判一次(有人可能直接改配置手起引擎),两处判据一致 * (指纹算法在 `fingerprint.js`,两份逐字节相同)。 * * ## 与记录页的关系 * 断点里带 `runId`,记录里也带 `runId` —— 只有两者相等才认为"这条记录就是那个断点"。 * 不这么做的话,用户会在一堆历史「手动停止」记录上都看到「继续」, * 而其中只有最后一条是真的能续的。 */ var Resume = (function () { 'use strict'; var PATH = Engine.ID_PROGRESS_PATH; var VERSION = 1; function nameOf(key) { if (key === 'sources') { return '取材'; } if (key === 'filter') { return '过滤条件'; } if (key === 'output') { return '输出设置(目录 / 跨轮去重)'; } return key; } /** 读断点。解析不了就当"没有断点"(界面不显示提示条,绝不报错吓人) */ function read() { try { if (!files.exists(PATH)) { return null; } var text = String(files.read(PATH) || ''); if (text.replace(/\s/g, '') === '') { return null; } var cp = JSON.parse(text); if (!cp || cp.v !== VERSION) { return null; } return cp; } catch (e) { console.log("[resume] 断点文件读不懂(当作没有断点): " + e); return null; } } function stoppedAt(cp) { var list = cp && cp.sources ? cp.sources : []; for (var i = 0; i < list.length; i++) { if (list[i].status !== 'done') { return list[i].label || ''; } } return ''; } function countBy(cp) { var list = cp && cp.sources ? cp.sources : []; var done = 0; for (var i = 0; i < list.length; i++) { if (list[i].status === 'done') { done += 1; } } return { done: done, total: list.length }; } /** * 一次读盘算出"能不能续",供提示条与记录页共用。 * * @returns { * exists, cp, valid, finished, reason, message, changed[], * extracted, runFile, stoppedAt, doneSources, totalSources * } */ function snapshot() { var cp = read(); var out = { exists: !!cp, cp: cp, valid: false, finished: false, reason: '', message: '', changed: [], extracted: 0, runFile: '', stoppedAt: '', doneSources: 0, totalSources: 0 }; if (!cp) { out.reason = 'no-checkpoint'; return out; } out.runFile = cp.runFile || ''; out.stoppedAt = stoppedAt(cp); out.extracted = (cp.counts && typeof cp.counts.extracted === 'number') ? cp.counts.extracted : 0; var prog = countBy(cp); out.doneSources = prog.done; out.totalSources = prog.total; if (cp.state === 'none') { out.reason = 'cleared'; return out; } if (cp.state === 'done') { out.finished = true; out.reason = 'last-complete'; return out; } var sig = Fingerprint.ofIdSection(Engine.buildIdConfig().config.id); var cmp = Fingerprint.compare(cp.sig, sig); if (!cmp.same) { var names = []; for (var i = 0; i < cmp.changed.length; i++) { names.push(nameOf(cmp.changed[i])); } out.changed = cmp.changed; out.reason = 'config-changed'; out.message = names.join('、') + '改过了,只能重新开始'; return out; } if (!out.runFile || !files.exists(out.runFile)) { out.reason = 'run-file-missing'; out.message = '上次的结果文件已被删除'; return out; } out.valid = true; out.reason = 'ok'; return out; } /** 现在能不能点「继续」(还要叠加"当前没有别的任务在跑") */ function canResume(snap) { var s = snap || snapshot(); return s.valid === true && Session.running !== true; } /** * 这条运行记录能不能续。 * * 判据必须精确到"就是那次运行",否则一堆历史记录上都会冒出「继续」: * · 任务必须是 ID 提取 * · 结果必须是 stopped(手动停止)或 failed(被杀/异常退出) * · 断点本身还得有效(配置没变、结果文件还在) * · **记录里的 runId 必须等于断点里的 runId** */ function isRecordResumable(record, snap) { var s = snap || snapshot(); if (!record || record.taskKey !== 'id') { return false; } if (record.result !== 'stopped' && record.result !== 'failed') { return false; } if (!s.valid || !s.cp) { return false; } return !!(record.runId && s.cp.runId && record.runId === s.cp.runId); } /** 提示条上那句副文案 */ function detail(snap) { var s = snap || snapshot(); if (s.valid) { var where = s.stoppedAt ? '停在「' + s.stoppedAt + '」' : '上次没跑完'; return where + ' · 继续会追加到同一个文件'; } if (s.finished) { return '上次已跑完'; } return s.message || ''; } /** 清空断点 = 覆盖成空壳。**不删文件** */ function clear() { try { files.write(PATH, JSON.stringify({ v: VERSION, state: 'none', updatedAt: Date.now() })); return true; } catch (e) { console.log("[resume] 清空断点失败: " + e); return false; } } return { PATH: PATH, read: read, snapshot: snapshot, canResume: canResume, isRecordResumable: isRecordResumable, detail: detail, nameOf: nameOf, clear: clear }; }()); /* ============================== src/widgets.js ============================== */ /* ============================== src/widgets.js ============================== * 通用组件工厂。 * * 设计稿里的每一类元素(卡片 / 设置行 / 下拉 / 开关 / 胶囊按钮 / 状态标签 / * 勾选框 / 页签 / 主按钮)在这里各有一个构造函数,页面只做「拼装」。 * 目的很直接:同一元素在 5 个页面里必须长得一样, * 布局参数一旦散落到页面代码,就会出现「这个页的卡片圆角是 16、那个页是 12」。 * * 所有构造函数都返回 Android View;需要后续改动的额外返回 setter。 */ var W = {}; /* ---------------------------------------------------------------- 卡片 */ /** * 圆角卡片容器(vertical)。 * active=true 用青蓝描边 35%,false 用白 8% —— 对应设计稿里「已勾选脚本卡」的差异。 */ W.card = function (active) { var card = U.column(); card.setBackground(active ? U.strokeBg(Theme.alpha.cardFillActive, Theme.alpha.cardStrokeActive, Theme.hex.accent, Theme.m.cardRadius) : U.strokeBg(Theme.alpha.cardFill, Theme.alpha.cardStroke, "#FFFFFF", Theme.m.cardRadius)); card.setPadding(U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding)); return card; }; /** 按序把子 View 加进容器,并按 gap 插入间距,避免每处手写 spacer。 */ W.stack = function (container, children, gapDp) { var gap = gapDp === undefined ? 12 : gapDp; for (var i = 0; i < children.length; i++) { if (!children[i]) { continue; } if (i > 0 && gap > 0) { container.addView(U.spacer(gap)); } container.addView(children[i]); } return container; }; /** 区标题(「脚本选择」「功能设置」这类分段标题)。 */ W.sectionTitle = function (label) { return U.text(label, Theme.m.sectionSize, Theme.hex.textPrimary, true); }; /* ------------------------------------------------------------ 顶部标题区 */ /** * 页面顶部:大标题 + 副标题 + 右侧设置入口。 * 5 个页签共用同一个标题区,所以文案由页面传入而不是写死在这里。 */ W.pageHeader = function (title, subtitle, onTapSettings) { var left = U.column(); U.params(left, 0, -2, 1); left.addView(U.text(title, Theme.m.titleSize, Theme.hex.textPrimary, true)); left.addView(U.spacer(4)); left.addView(U.text(subtitle, 12, Theme.hex.textMuted, false)); var header = U.row(); header.addView(left); if (onTapSettings) { var gear = U.text("\u2699", 18, Theme.hex.textSecondary, false); gear.setGravity(android.view.Gravity.CENTER); U.params(gear, 22, 22); gear.setOnClickListener(function () { onTapSettings(); }); header.addView(gear); } return header; }; /* ------------------------------------------------------------ 脚本卡 */ /** * 脚本卡头部一行:勾选框/单选框 + 脚本名 +(摘要)+ 展开箭头。 * * 勾选与展开是两个独立命中区——合并成一个的话, * 用户想展开却把脚本关掉是必然事件。 * * mode: "checkbox"(默认)| "radio"。设计稿的 ID 提取页是单选 * (「同一时间仅可运行一个脚本」),其余页是可多选的勾选。 * 互斥由页面负责——组件只管画和报「点完应该是什么状态」。 * * radio 的「再点一下取消」是有意为之:设计稿专门画了「未选择脚本」这一屏 * (16:23 + 「请先选择要运行的脚本」拦截弹窗),必须有一条路能回到那个状态。 * 严格单选一旦选过就再也回不去,那一屏就成了永远看不到的死代码。 * * disabled: 运行中不允许切换脚本(设计稿 09 的底部提示就是这么写的)。 */ W.scriptHeader = function (opts) { var isRadio = opts.mode === "radio"; var disabled = !!opts.disabled; var box = isRadio ? W.radio(opts.checked, 18) : W.checkbox(opts.checked, 18); /* 名称用「内容宽度 + 弹性占位顶开」,而不是 weight=1。 * weight=1 的写法在右侧存在 MATCH_PARENT 控件时会被挤成 0 宽(真机踩过), * 用 flexible() 占位则无论同行其它控件怎么变都成立。 */ var nameView = U.text(opts.name, Theme.m.labelSize, disabled ? Theme.hex.textMuted : Theme.hex.textPrimary, true); nameView.setSingleLine(true); nameView.setEllipsize(android.text.TextUtils.TruncateAt.END); var head = U.row(); head.addView(box.root); head.addView(U.hgap(Theme.m.gapInline)); head.addView(nameView); head.addView(U.flexible()); if (opts.summary) { head.addView(U.text(opts.summary, Theme.m.chipSize, Theme.hex.textFaint, false)); head.addView(U.hgap(6)); } var arrowBox = U.row(false); arrowBox.setGravity(android.view.Gravity.CENTER); U.params(arrowBox, 16, 16); var arrow = W.arrow(opts.expanded); arrowBox.addView(arrow.root); head.addView(arrowBox); box.root.setOnClickListener(function () { if (disabled) { return; } /* 两种模式都是「取反」——单选组也允许点已选中项来取消, * 详见上面 radio 的说明。互斥(同组只留一个)由页面处理。 */ var next = !box.isChecked(); box.set(next); opts.onToggleCheck(next); }); arrowBox.setOnClickListener(function () { /* 展开只影响看到多少设置,不影响「要不要跑」,运行中也不必禁用 */ opts.onToggleExpand(); }); return { root: head, nameView: nameView, arrow: arrow, checkbox: box }; }; /* ---------------------------------------------------------------- 勾选框 */ /** * 勾选框。选中 = 青蓝实心 + 白色对勾;未选中 = 透明底 + 灰描边。 * 用「字符当对勾」而不是 VectorDrawable:省掉资源文件,且离线桩测能直接验证状态。 */ W.checkbox = function (checked, sizeDp) { var size = sizeDp || 16; var radius = size <= 16 ? 4 : 5; var view = U.text("", 11, Theme.hex.textPrimary, true); view.setGravity(android.view.Gravity.CENTER); U.params(view, size, size); var state = { checked: !!checked }; function paint() { if (state.checked) { view.setText("\u2713"); view.setBackground(U.solidBg(U.argb(Theme.hex.accent), radius)); } else { view.setText(""); view.setBackground(U.strokeBg(0, 1, Theme.hex.tabIdle, radius)); } } paint(); return { root: view, set: function (value) { state.checked = !!value; paint(); }, isChecked: function () { return state.checked; } }; }; /* ---------------------------------------------------------------- 单选框 */ /** * 单选框(设计稿 01「脚本一」的 16:1072)。 * * 与勾选框的差别不只是外形,更是**语义**:一组里同时只能有一个被选中 * (设计稿写得很明确——「单选:同一时间仅可运行一个脚本」)。 * 所以这个组件只负责画与报状态,「互斥」由页面负责清掉同组其它项。 */ W.radio = function (checked, sizeDp) { var size = sizeDp || 18; var view = U.text("", Theme.m.chipSize, Theme.hex.textPrimary, true); view.setGravity(android.view.Gravity.CENTER); U.params(view, size, size); /* 选中 = 青蓝实心圆 + 白色圆点;未选中 = 透明底 + 灰描边圆。 * 圆点用 "●"(U+25CF)而不是画 VectorDrawable:与勾选框同一套思路, * 省掉资源文件,且离线桩测能直接断言状态。 */ var state = { checked: !!checked }; function paint() { if (state.checked) { view.setText("\u25CF"); view.setTextSize(7); view.setBackground(U.solidBg(U.argb(Theme.hex.accent), 99)); } else { view.setText(""); view.setBackground(U.strokeBg(0, 1, Theme.hex.tabIdle, 99)); } } paint(); return { root: view, set: function (value) { state.checked = !!value; paint(); }, isChecked: function () { return state.checked; } }; }; /* ---------------------------------------------------------------- 箭头 */ /** 展开 / 收起箭头。字符 U+25BE / U+25B4 在 Android 默认字体里必然存在。 */ W.arrow = function (expanded) { var view = U.text(expanded ? "\u25B4" : "\u25BE", 12, Theme.hex.textFaint, false); view.setGravity(android.view.Gravity.CENTER); return { root: view, set: function (value) { view.setText(value ? "\u25B4" : "\u25BE"); } }; }; /** * 指向右侧的箭头(运行记录条目 / 可进入下一级的行)。 * * 用 U+203A(单右角引号)而不是 U+2192:后者在部分 Android 字体里带 emoji 变体, * 会渲染成彩色箭头(页签图标踩过同一个坑)。U+203A 在默认字体里稳定是字形。 */ W.chevronRight = function (sizeSp, hex) { var view = U.text("\u203A", sizeSp || 18, hex || Theme.hex.textFaint, false); view.setGravity(android.view.Gravity.CENTER); return view; }; /* ------------------------------------------------------------ 设置行 */ /** * 「左标签 + 右控件」设置行;右控件可省略(此时是纯文案行)。 * * 左侧标签用内容宽度 + flexible() 顶开,**不用 weight=1**: * 当右控件自身是 MATCH_PARENT(例如整行输入框)时, * weight=1 的标签会被挤成 0 宽,在真机上就是「这一行的标签不见了」。 */ W.settingRow = function (label, rightView, gapDp) { var nameView = U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false); nameView.setSingleLine(true); var line = U.row(); line.addView(nameView); line.addView(U.flexible()); if (rightView) { line.addView(U.hgap(gapDp === undefined ? 6 : gapDp)); line.addView(rightView); } return line; }; /* ------------------------------------------------------------ 勾选设置行 */ /** * 「勾选框 + 标签 +(可选右侧控件)」设置行。 * * 设计稿里这类行有三种排布: * · 单独一行(过滤私密账户); * · 一行两个(过滤私密账户 | 过滤无头像账户); * · 勾选后才出现右侧控件(云端去重 → 表名 + 连通测试)。 * 第三种由调用方按状态自己拼,前两种用这个函数。 * * 命中区含整个标签:只让 16dp 的方框可点,真机上很难戳中。 */ /** * 勾选行。 * * @param disabled 置灰且不可点。**为「功能还没做,但字段要保留」的场景准备的**: * 给了界面一个"开得起来、却什么也不会发生"的开关,比不给更糟 * (用户会以为它生效了)。置灰至少是诚实的。 * 勾选状态**照常显示**——它代表"用户上次的选择",不是"现在能改的选择"。 */ W.checkRow = function (label, checked, onToggle, disabled) { var box = W.checkbox(checked, 16); var off = !!disabled; var labelView = U.text(label, Theme.m.labelSize, off ? Theme.hex.textMuted : Theme.hex.textSecondary, false); labelView.setSingleLine(true); var line = U.row(false); line.addView(box.root); line.addView(U.hgap(8)); line.addView(labelView); line.setPadding(0, U.dp(4), 0, U.dp(4)); if (off) { line.setAlpha(0.55); /* 不可点:这里挂一个**空监听**而不是不挂 —— 不挂的话父容器(卡片) * 的点击会继续冒泡上去,用户点这一行会触发别处的动作。 * 注意 setOnClickListener(function(){}) 与 setOnClickListener(null) 行为不同: * 后者等于"没有监听器",照样冒泡(见 README 铁律)。 */ line.setOnClickListener(function () { }); } else { line.setOnClickListener(function () { var next = !box.isChecked(); box.set(next); onToggle(next); }); } return { root: line, isChecked: box.isChecked }; }; /** * 灰行:**只读展示 + 「暂不支持」标记**。 * * 专门给"设置项在界面上占着位置、但执行侧还没有能力实现"的项用 * (提取区域 / AI 头像识别 / AI 性别 / AI 年龄)。 * * 为什么不是"灰掉的勾选框":勾选框本身就在说"这个可以改", * 灰只是说"现在不能改",两者叠起来仍然在暗示它是个可选项。 * 而这里的事实是"它现在根本没有效果" —— 所以不给可操作控件, * 只把用户上次填的值显示出来(保留值、将来接上就能用),点一行看原因。 */ W.mutedRow = function (label, valueText, onTap) { var line = U.row(false); var labelView = U.text(label, Theme.m.labelSize, Theme.hex.textMuted, false); labelView.setSingleLine(true); line.addView(labelView); line.addView(U.flexible()); if (valueText) { var valueView = U.text(valueText, Theme.m.chipSize, Theme.hex.textFaint, false); valueView.setSingleLine(true); line.addView(valueView); line.addView(U.hgap(6)); } line.addView(W.chip("暂不支持", "mute").root); line.setPadding(0, U.dp(4), 0, U.dp(4)); if (onTap) { line.setOnClickListener(onTap); } else { line.setOnClickListener(function () { }); } return { root: line }; }; /* ------------------------------------------------------------ 胶囊 / 标签 */ /* 胶囊的四套色调集中在这里,W.chip 与 W.repaintChip 共用, * 避免「改了一处忘了另一处」导致状态切换后颜色不更新。 */ W.TONES = { "default": { fill: 0.06, stroke: 0.10, text: "#CFCCE6" }, "accent": { fill: 0.20, stroke: 0, text: "#7CC7FF" }, "warn": { fill: 0.16, stroke: 0, text: "#F5C24B" }, "ok": { fill: 0.16, stroke: 0, text: "#13DEC4" }, /* 不可逆操作用 danger,与「未开启」的琥珀色区分开—— * 两种暖色混用会让用户分不清「提示」和「警告」。 */ "danger": { fill: 0.16, stroke: 0, text: "#FF5470" }, "mute": { fill: 0.08, stroke: 0, text: "#8E8BA8" } }; W.paintTone = function (view, tone, radiusDp) { var t = W.TONES[tone] || W.TONES["default"]; view.setTextColor(U.argb(t.text)); view.setBackground(t.stroke > 0 ? U.strokeBg(t.fill, t.stroke, "#FFFFFF", radiusDp) : U.solidBg(U.alphaOf("#FFFFFF", t.fill), radiusDp)); return view; }; /** * 胶囊元素的统一出口:下拉框、按钮、状态标签都用它,只是色调与圆角不同。 */ W.chip = function (label, tone, minWidthDp, radiusDp) { var view = U.text(label, Theme.m.chipSize, W.TONES["default"].text, true); view.setGravity(android.view.Gravity.CENTER); view.setSingleLine(true); W.paintTone(view, tone || "default", radiusDp === undefined ? Theme.m.chipRadius : radiusDp); view.setPadding(U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV), U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV)); if (minWidthDp) { view.setMinWidth(U.dp(minWidthDp)); } return { root: view, setText: function (v) { view.setText(String(v)); }, setTone: function (nextTone) { W.paintTone(view, nextTone, Theme.m.chipRadius); } }; }; /** 状态标签(全圆角),用于「未登录 / 已安装 v39.5.4 / 已是最新」。 */ W.pill = function (label, tone) { var t = W.TONES[tone || "mute"] || W.TONES["mute"]; var view = U.text(label, Theme.m.chipSize, t.text, true); view.setGravity(android.view.Gravity.CENTER); view.setSingleLine(true); view.setBackground(U.solidBg(U.alphaOf("#FFFFFF", t.fill), Theme.m.pillRadius)); view.setPadding(U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV), U.dp(Theme.m.chipPadH), U.dp(Theme.m.chipPadV)); return { root: view, setText: function (v) { view.setText(String(v)); } }; }; /* ------------------------------------------------------------ 下拉框 */ /** * 下拉框。 * * 为什么既不用 Spinner 也不用 dialogs.select: * · Spinner 的弹出列表走系统浅色主题,在深色界面里是一块刺眼的白板, * 要改得自定义 Adapter 与主题; * · `dialogs.select` 在 UI 模式下**拿不到返回值**(阻塞会死锁,运行时直接返回 undefined), * 结果是「选完写回 undefined」,配置被写坏、重启又变回默认值。 * 所以用自绘的 Dialog.choose:样式统一由 Theme 控制,结果走回调。 */ W.dropdown = function (group, valuePath, onChange) { /* 用 getEnum 而不是 get:它保证拿回一个**合法 key**, * 缺失或非法时回退到该枚举的默认项并回写配置。 * 于是这个胶囊在第一次渲染时就一定有值,不会出现空框。 */ var chip = W.chip(Store.label(group, Store.getEnum(valuePath, group)), "default", 60); chip.root.setOnClickListener(function () { Dialog.choose({ options: Store.options[group], currentIndex: Store.indexOfLabel(group, Store.getEnum(valuePath, group)), onConfirm: function (index) { var key = Store.keyAt(group, index); Store.set(valuePath, key); chip.setText(Store.label(group, key)); if (onChange) { onChange(key); } } }); }); return { root: chip.root, set: function (key) { chip.setText(Store.label(group, key)); } }; }; /* ------------------------------------------------------------ 开关 */ /** * 二元开关。设计稿用的是「胶囊文字按钮」而不是滑块, * 所以这里做成 chip:开 = 青蓝高亮,关 = 灰。 */ W.toggle = function (on, onChange) { var chip = W.chip(on ? Store.label("toggle", "on") : Store.label("toggle", "off"), on ? "accent" : "default", 44); var state = !!on; chip.root.setOnClickListener(function () { state = !state; chip.setText(state ? Store.label("toggle", "on") : Store.label("toggle", "off")); chip.setTone(state ? "accent" : "default"); if (onChange) { onChange(state); } }); return { root: chip.root, isOn: function () { return state; }, set: function (v) { state = !!v; chip.setText(state ? Store.label("toggle", "on") : Store.label("toggle", "off")); chip.setTone(state ? "accent" : "default"); } }; }; /* ------------------------------------------------------------ 多选开关行 */ /** * 「执行操作」那种一行多选:关注 / 私信 / 点赞 / 评论。 * 每一项独立高亮、互不影响,所以不能复用 W.toggle(它是二元开关)。 */ W.toggleRow = function (items, onChange) { /* 这一行是作为「设置行的右侧控件」使用的,必须包裹内容—— * 用默认的 fill=true 会占满整行,把左侧标签挤成 0 宽。 */ var line = U.row(false); var chips = {}; for (var i = 0; i < items.length; i++) { (function (item) { var chip = W.chip(item.label, item.on ? "accent" : "default", 36); var state = !!item.on; chip.root.setOnClickListener(function () { state = !state; chip.setTone(state ? "accent" : "default"); if (onChange) { onChange(item.key, state); } }); chips[item.key] = { root: chip.root, isOn: function () { return state; }, set: function (v) { state = !!v; chip.setTone(state ? "accent" : "default"); } }; if (line.getChildCount() > 0) { line.addView(U.hgap(6)); } line.addView(chip.root); })(items[i]); } return { root: line, chips: chips }; }; /* ------------------------------------------------------------ 输入框 */ /** * 常驻输入框 —— **直接在栏位里改**,不再「点一下弹出输入框再填」。 * * 上一版为什么绕开常驻 EditText(理由已逐条失效,记在这里免得又被改回去): * ① 怕 TextWatcher 这种 Java 接口构造不出来 —— 已在目标设备上实测通过: * `new android.text.TextWatcher({...})` 与 `new View.OnFocusChangeListener({...})` * 都能构造,真机逐字输入每键都触发回调。 * ② 怕忘绑监听 → 「能打字但永远不保存」(用户会报成「没有默认值」)—— * 监听现在写在本函数内部,全项目只有这一个输入框入口,不存在漏绑的余地。 * * ★ 程序化回写必须防自触发:真机上 `setText` 会**同步**触发 TextWatcher * (实测顺序是 before → on → after 全部跑完,setText 才返回)。 * 不挡的话「失焦正规化」会把夹紧后的值再存一遍,来回震荡。 * * ★ 数字框在「从尾部退格」的过程中**不写配置**:把 2000 清成 2 会经过 200、20 * 这些**恰好合法**的中间值,用户清空后走开,配置就停在 2 上。 * 退格期间不写,失焦时一次性提交(那时文本已定稿;空串则回退原值)。 * 文本字段不受这条限制 —— 半截文本本身就是「用户正在输入的内容」,没有这个问题。 * * opts: * path 绑定配置路径 → 自动「读初值 / 输入即落盘 / 失焦正规化」 * value 未绑定 path 时的初值(一次性取值的场合,如登录页卡密) * placeholder 空值提示 * numeric true → 数字键盘,按数值解析与落盘 * widthDp 固定宽度。横向布局里**必须**给:默认 MATCH_PARENT 会把同行标签挤成 0 宽 * fill true → 宽度撑满(独占一行的输入框,如登录页卡密) * align "right" 等(数字在区间里居中对齐,见下) * onLinkedChange 自己改完**连带改了别的字段**时的回调(只刷兄弟,不刷自己) * onSubmit 软键盘回车 */ W.inlineField = function (opts) { /* 绑定方式有两种: * · `path` —— 绑 Store 的字段(绝大多数控件) * · `read`/`write` —— 绑"不是 Store 字段"的值(取材清单里的账号/关键词/路径)。 * 后者必须自己提供读写,因为 Sources 是另一个储物柜。 */ var hasWriter = typeof opts.write === "function"; var bound = hasWriter || (typeof opts.path === "string" && opts.path !== ""); var numeric = !!opts.numeric; var edit = new android.widget.EditText(context); edit.setTextSize(Theme.m.chipSize); edit.setTextColor(U.argb(Theme.hex.textSecondary)); edit.setHintTextColor(U.argb(Theme.hex.textFaint)); edit.setHint(opts.placeholder || ""); edit.setSingleLine(true); edit.setPadding(U.dp(8), U.dp(7), U.dp(8), U.dp(7)); if (numeric) { edit.setInputType(android.text.InputType.TYPE_CLASS_NUMBER); /* 数字短、盒子窄,居中比左右对齐更稳(设计稿里两种都有,取一致的居中) */ edit.setGravity(android.view.Gravity.CENTER); } else if (opts.align === "right") { edit.setGravity(android.view.Gravity.RIGHT); } edit.setImeOptions(android.view.inputmethod.EditorInfo.IME_ACTION_DONE); /* 横向布局里宽度必须显式给死,否则 MATCH_PARENT 会吃掉整行(真机踩过的坑)。 * fill: true 用于独占一行的输入框(登录页卡密)。 */ var width = -2; if (opts.fill) { width = -1; } else if (opts.widthDp) { width = U.dp(opts.widthDp); } U.params(edit, width, -2); var idleBg = U.strokeBg(Theme.alpha.chipFill, Theme.alpha.chipStroke, "#FFFFFF", Theme.m.chipRadius); /* 可编辑之后必须看得出来「现在改的是哪一格」,聚焦时换青蓝描边 */ var activeBg = U.strokeBg(Theme.alpha.chipFill, 0.55, Theme.hex.accent, Theme.m.chipRadius); edit.setBackground(idleBg); /* 程序化回写期间置位,挡住 TextWatcher —— 见函数头第 ★ 条 */ var writing = false; /* 上一次的栏位文字,只用来判断「这一下是不是在退格」 */ var lastText = ""; function readStore() { if (typeof opts.read === "function") { var fromCallback = opts.read(); return fromCallback === undefined || fromCallback === null ? "" : String(fromCallback); } if (bound && typeof opts.path === "string" && opts.path !== "") { return numeric ? String(Store.getNumber(opts.path)) : Store.getText(opts.path); } return opts.value === undefined || opts.value === null ? "" : String(opts.value); } function paint() { writing = true; try { edit.setText(readStore()); /* ★ 用 String(...) 取长度,不要写 `edit.getText().length()`: * 真机上 getText() 返回 android.text.Editable(有 length() 方法), * 但测试桩返回的是 JS 字符串(length 是属性不是函数)—— * 写成 `.length()` 会让回写在桩上**每次都抛异常**, * 于是「回写之后光标在哪」这类问题在 PC 侧永远测不出来。 */ edit.setSelection(String(edit.getText()).length); } catch (e) { console.log("[input] paint 失败 " + opts.path + ": " + e); } finally { /* 一定要走 finally:抛异常时若把 writing 留在 true, * 这个输入框就**永远不会再写配置**了(静默哑掉)。 */ writing = false; } lastText = String(edit.getText()); } /** 把栏位文字写进配置。空串 / 非数字一律不写(保留原值,由 paint 兜底显示)。 */ function commit() { if (!bound) { return false; } var raw = String(edit.getText()).trim(); /* 写到哪里:`opts.write` 优先(取材清单这类不在字段表里的值), * 否则写 Store(绝大多数控件走这条)。 */ if (hasWriter) { opts.write(raw); if (opts.onCommitted) { opts.onCommitted(raw); } return true; } if (numeric) { if (raw === "") { return false; } var n = Number(raw); if (isNaN(n)) { return false; } Store.set(opts.path, n); } else { Store.set(opts.path, raw); } /* 上界不得小于下界。★ 只让**兄弟**刷新(回调由调用方提供), * 绝不能刷自己这一格 —— 用户可能正在其中打字。 */ if (syncCrossRules() && opts.onLinkedChange) { opts.onLinkedChange(); } return true; } edit.setText(readStore()); lastText = String(edit.getText()); edit.addTextChangedListener(new android.text.TextWatcher({ beforeTextChanged: function () { }, onTextChanged: function () { }, afterTextChanged: function (editable) { if (writing || !bound) { return; } var now = String(editable === null || editable === undefined ? "" : editable); var isBackspacing = lastText.length > now.length && lastText.indexOf(now) === 0; lastText = now; /* 见函数头第 ★ 条:数字框退格期间不落盘,等失焦时一次提交 */ if (numeric && isBackspacing) { return; } commit(); } })); /* 失焦:先提交(把退格期间跳过的编辑落地),再回写正规化后的值 —— * 没有后一步,用户永远看不到「99999 其实被夹到了 500」。 */ edit.setOnFocusChangeListener(new android.view.View.OnFocusChangeListener({ onFocusChange: function (view, hasFocus) { edit.setBackground(hasFocus ? activeBg : idleBg); if (hasFocus) { return; } commit(); paint(); if (opts.onBlur) { opts.onBlur(); } } })); var handle = { root: edit, edit: edit, paint: paint, commit: commit, value: function () { return String(edit.getText()).trim(); } }; /* 登记进「本页待提交输入框」:切页签 / 按开始运行这类**不会先失焦**的动作 * 之前要整体提交一次,否则「只退格、没失焦就走」的那次编辑会丢。 * 绑 Store 字段的进 pendingFields(审计会据此判断"这个字段有控件"), * 绑 Sources 之类非字段值的进 pendingExtra(不进审计口径)。 */ if (bound) { if (typeof opts.path === "string" && opts.path !== "") { W.pendingFields[opts.path] = handle; } else { W.pendingExtra.push(handle); } } return handle; }; /* path → 输入框。**每次渲染页面开始时清空**(见 W.beginPage)—— * 留成全局累积的话,切走的那一页的输入框还挂在表里, * 后面任意一次提交都会把它的旧文字重新写回配置,把别处刚改的值顶掉。 */ W.pendingFields = {}; /** 开始渲染一个新页面:清空待提交表。调用方要先 `commitPendingInputs()` 再调这个。 */ W.beginPage = function () { W.pendingFields = {}; W.pendingExtra = []; }; /* 不绑定配置路径的输入框(例如 ID提取「取材清单」里的账号/关键词/路径)。 * 与 pendingFields 分开是**必须的**:pendingFields 同时被取值审计当作 * 「这个字段有界面控件」的证据,而取材字段根本不在字段表里 —— * 混在一起会让审计报「绑定了表外路径」。 */ W.pendingExtra = []; /** * 提交当前页面上所有绑定过配置的输入框。 * * 什么时候必须调:**页面要被重建 / 配置要被读取,而用户可能还停在某个输入框里**。 * Android 里点一个不可聚焦的按钮并不会让 EditText 失焦,所以「按开始运行」时 * 栏位内容可能还没落盘 —— 这正是「界面显示的值」和「真正跑起来的值」不一致的来源。 */ W.commitPendingInputs = function () { for (var path in W.pendingFields) { if (W.pendingFields.hasOwnProperty(path)) { W.pendingFields[path].commit(); } } for (var i = 0; i < W.pendingExtra.length; i++) { W.pendingExtra[i].commit(); } }; /** * 通用单选胶囊:**不绑 Store 路径**,选完只回调一个下标。 * * 为什么需要它:`W.dropdown` 绑的是 `Store` 的一个枚举路径, * 而取材清单存在 `Sources` 里(不是字段),它的"起点 / 深度"这类选择只能走回调。 * * @param opts.label 当前显示的文案 * @param opts.options 候选文案数组 * @param opts.index 当前下标(**函数**,点击时现算 —— 渲染之后状态可能已经变了) * @param opts.onPick 选中回调 (index) */ W.chooser = function (opts) { var chip = W.chip(opts.label, opts.tone || "default", opts.minWidthDp || 76); chip.root.setOnClickListener(function () { var current = typeof opts.index === "function" ? opts.index() : opts.index; if (current === undefined || current === null || current < 0) { current = 0; } Dialog.choose({ options: opts.options, currentIndex: current, onConfirm: function (index) { chip.setText(opts.options[index]); opts.onPick(index); } }); }); return { root: chip.root, set: function (text) { chip.setText(text); } }; }; /** * 文本输入(关键词 / 主播区域 / 国家简码 / 云表名 / 文件路径)。 * * 用 EditText 直接输入而不是 `dialogs.rawInput`:后者在 `"ui";` 模式下 * 是阻塞式调用、拿不到返回值(老坑),而且多点一步。 */ W.textField = function (path, placeholder, widthDp) { return W.inlineField({ path: path, placeholder: placeholder, widthDp: widthDp || 108 }); }; /** * 让跨字段约束立即生效(上界不得小于下界)。 * * 拆成独立的上下限输入框之后,这个约束更容易被触发: * 用户可能先把上限改成 50、再回头改下限。没有这一步,界面上就会出现 * 「下限 500 / 上限 50」这种自相矛盾的组合,要等下一次 load 才被纠正。 * * 返回**是否发生了连带修改** —— 调用方据此决定要不要重绘整页。 * 大多数情况下只改了自己那一项,不必重绘。 */ function syncCrossRules() { var changed = false; for (var i = 0; i < Store.CROSS_RULES.length; i++) { var rule = Store.CROSS_RULES[i]; var low = Store.getNumber(rule.low); var high = Store.getNumber(rule.high); if (high < low) { Store.set(rule.high, low); changed = true; } } return changed; } /** * 数字输入(设计稿里的「停止个数 1 ~ 5」「关注数量 100 ~ 200」都用它)。 * * 第 4 个参数 onLinkedChange 用于「改这一项**连带改了别项**」的情形 * (见 syncCrossRules):把休息时间下限调到 2000,被抬齐的上限那一格也得刷新。 * ★ 回调里只刷**兄弟**那一格,不刷自己 —— 自己这一格正被用户编辑着。 */ W.numberField = function (path, hint, widthDp, onLinkedChange) { return W.inlineField({ path: path, placeholder: hint, numeric: true, widthDp: widthDp || 56, onLinkedChange: onLinkedChange }); }; /** * 「最小值 ~ 最大值」——一行一个区间,两个数**各自独立、直接在栏位里改**。 * * 与「上下限各占一行」的区别只在排版: * · 两个数各自一个输入框,互不干扰(改最小值不会顺带动最大值); * · `~` 只是把「这两个数是一对」表达出来,不是把两个值塞进一个控件。 * 一行放下之后功能设置卡短了一半,四个门槛不用滚很久才看完。 * * 为什么不像开关那样拆成两行:开关是**两个独立的动作**(进行关注、进行私信), * 而这里的 min/max 是**同一个量的下界与上界**,分开写会让人以为 * 「关注量下限」和「关注量上限」是两个互不相干的设置。 * * ★ 联动(syncCrossRules)只刷**兄弟**那一格:用户正在输的那一格绝不能被回写抢输入。 * 联动也不重建整页 —— 重建会把滚动位置清零,用户刚滚到功能设置区就被弹回顶部。 */ W.rangeRow = function (pathMin, pathMax, label, separator, widthDp) { var minField = null; var maxField = null; var w = widthDp || 56; minField = W.numberField(pathMin, "", w, function () { if (maxField) { maxField.paint(); } }); maxField = W.numberField(pathMax, "", w, function () { if (minField) { minField.paint(); } }); var line = U.row(); line.addView(U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false)); line.addView(U.flexible()); line.addView(minField.root); line.addView(U.hgap(6)); line.addView(U.text(separator || "~", Theme.m.labelSize, Theme.hex.textMuted, false)); line.addView(U.hgap(6)); line.addView(maxField.root); return { root: line, values: function () { return { min: Store.getNumber(pathMin), max: Store.getNumber(pathMax) }; } }; }; /* ------------------------------------------------------------ 文件选择行 */ /** * 弹出文件列表并把选中结果写进 store,然后回调刷新。 * * 列文件交给 Device,弹窗交给 Dialog —— 用 `dialogs.select` 的话 * 在 UI 模式下拿不到返回值,选文件会静默失效(老坑)。 */ function pickTextFileInto(storePath, onPicked) { var list = Device.listTextFiles(); if (list.length === 0) { Dialog.alert("未找到可用文件", "把 .txt / .json / .csv 文件放到 /sdcard/Download 下,\n" + "回到这里就能选到了。"); return; } Dialog.choose({ title: "选择文件", options: list, currentIndex: 0, onConfirm: function (index) { var picked = list[index]; /* storePath 可以为空:取材清单这类"不在字段表里的路径"只走回调, * 不写 Store(否则会往配置里塞一个表外字段)。 */ if (storePath) { Store.set(storePath, picked); } if (onPicked) { onPicked(picked); } toast("已选择:" + picked); } }); } /** * 独立的「选择文件」按钮。 * * 单独抽出来是因为 ID提取页的「使用文本 ID」那一行是 * 「勾选框 + 弹性占位 + 路径框 + 按钮」的结构,而 filePicker 会自带一个左侧标签, * 直接复用会出现同一行两个标签。 */ W.filePickerButton = function (storePath, onPicked, label) { var button = W.chip(label || "\u9009\u62E9\u6587\u4EF6", "accent"); button.root.setOnClickListener(function () { pickTextFileInto(storePath, onPicked); }); return button; }; /** * 「路径框 + 选择文件」组合行。设计稿里凡是「随机私信文本 / 已筛 UID 文本」都用它。 * * 路径本身就是个文本值,所以它也是**可直接编辑**的输入框(可以手输/粘贴路径, * 不一定非得走「选择文件」)。文件不存在时把字调暗,让「配了路径但文件没放」 * 一眼看得出来。 */ W.filePicker = function (label, storePath, buttonLabel) { var field = W.inlineField({ path: storePath, placeholder: "\u6587\u672C\u8DEF\u5F84", widthDp: 108, align: "right" }); function refreshPath() { field.paint(); var p = Store.getText(storePath); var exists = false; try { exists = p !== "" && files.exists(p); } catch (e) { exists = false; } field.edit.setTextColor(U.argb(p === "" ? Theme.hex.textFaint : (exists ? Theme.hex.textSecondary : Theme.hex.textMuted))); } refreshPath(); var button = W.filePickerButton(storePath, refreshPath, buttonLabel); var nameView = U.text(label, Theme.m.labelSize, Theme.hex.textSecondary, false); nameView.setSingleLine(true); var line = U.row(); line.addView(nameView); line.addView(U.flexible()); line.addView(field.root); line.addView(U.hgap(6)); line.addView(button.root); return { root: line, field: field, refreshPath: refreshPath }; }; /* ------------------------------------------------------------ 底部渐隐 */ /** * 内容区底部渐隐(规格书 §6:y=577 h=80)。 * * 放在内容区与底栏之间,从透明渐变到**页面底色**: * · 内容超出屏幕时,卡片被底栏硬切一刀的观感消失,一眼看出还能滚; * · 内容没超出时它叠在空背景上(透明 → 背景色 = 背景色),看不出多余色块。 * 绝对定位、不吃点击(否则底栏上面那 80dp 就点不动了)。 */ W.bottomFade = function () { var view = new android.view.View(context); view.setBackground(U.fadeBg(Theme.hex.bg)); view.setClickable(false); view.setFocusable(false); var lp = new android.widget.FrameLayout.LayoutParams(-1, U.dp(Theme.m.fadeHeight)); lp.gravity = android.view.Gravity.BOTTOM; view.setLayoutParams(lp); return view; }; /** 空态(记录 D2):图标框 + 主文案 + 副文案,整块居中。 */ W.emptyState = function (iconChar, title, subtitle) { var box = U.column(); box.setGravity(android.view.Gravity.CENTER); var iconBox = U.text(iconChar, 28, Theme.hex.tabIdle, false); iconBox.setGravity(android.view.Gravity.CENTER); iconBox.setBackground(U.tintBg("#FFFFFF", 0.05, 20)); U.params(iconBox, 64, 64); box.addView(iconBox); box.addView(U.spacer(16)); var head = U.text(title, 14, Theme.hex.tabIdle, false); head.setGravity(android.view.Gravity.CENTER); box.addView(head); box.addView(U.spacer(6)); var sub = U.text(subtitle, Theme.m.hintSize, Theme.hex.textDim, false); sub.setGravity(android.view.Gravity.CENTER); box.addView(sub); /* weight=1 撑满剩余高度 → 整块垂直居中(不能靠 gravity,父容器是滚动的) */ var outer = U.column(); U.params(outer, -1, 0, 1); outer.setGravity(android.view.Gravity.CENTER); outer.addView(box); return outer; }; /* ------------------------------------------------------------ 返回头 */ /** * 二级页返回头(规格书 §6:高 44,返回热区 44×44,标题 21 SemiBold)。 * * 热区必须做满 44×44:箭头字形只有 20dp,只让它可点的话真机上很难戳中 * (触控标准里专门写了「返回热区 44×44」)。 * * @param onBack 返回回调 * @param onSettings 右侧设置入口(只有 ID提取 C1 有),不传则不渲染 */ W.backHeader = function (title, onBack, onSettings) { var arrow = U.text("\u2039", 22, Theme.hex.textPrimary, false); arrow.setGravity(android.view.Gravity.CENTER); var hot = U.row(false); hot.setGravity(android.view.Gravity.CENTER); U.params(hot, Theme.m.backHotSize, Theme.m.backHotSize); hot.addView(arrow); hot.setOnClickListener(function () { if (onBack) { onBack(); } }); var head = U.row(); head.addView(hot); head.addView(U.hgap(4)); var titleView = U.text(title, Theme.m.detailTitleSize, Theme.hex.textPrimary, true); titleView.setSingleLine(true); titleView.setEllipsize(android.text.TextUtils.TruncateAt.END); head.addView(titleView); head.addView(U.flexible()); if (onSettings) { var gear = U.text("\u2699", 18, Theme.hex.textSecondary, false); gear.setGravity(android.view.Gravity.CENTER); U.params(gear, 22, 22); gear.setOnClickListener(function () { onSettings(); }); head.addView(gear); } return head; }; /* ------------------------------------------------------------ 状态徽标 */ /** * 环形小徽标(任务卡的状态:「空闲 / 运行中 / 未配置」)。 * * 与 W.pill 分开是因为**内边距不同**:pill 是 6/6(给「已安装 v39.5.4」这类 * 有内容的标签),徽标是 8/3 —— 三四个字的小标签用 6dp 上下留白会胖得像个按钮。 */ W.badge = function (label, tone) { var t = W.TONES[tone || "mute"] || W.TONES["mute"]; var view = U.text(label, Theme.m.chipSize, t.text, true); view.setGravity(android.view.Gravity.CENTER); view.setSingleLine(true); view.setBackground(U.solidBg(U.alphaOf("#FFFFFF", t.fill), Theme.m.pillRadius)); view.setPadding(U.dp(Theme.m.badgePadH), U.dp(Theme.m.badgePadV), U.dp(Theme.m.badgePadH), U.dp(Theme.m.badgePadV)); return { root: view, setText: function (v) { view.setText(String(v)); }, setTone: function (next) { W.paintTone(view, next, Theme.m.pillRadius); } }; }; /* ------------------------------------------------------------ 任务卡 / 记录条目 */ /** * 同色图标框(任务卡 40 / 记录条目 36)。 * tint 传 hex,alpha 固定取 Theme.alpha.iconBoxFill(14%,设计稿取值)。 */ function iconBoxTint(hex, sizeDp, radiusDp, iconChar, iconSp) { var box = U.text(iconChar, iconSp, hex, false); box.setGravity(android.view.Gravity.CENTER); box.setBackground(U.tintBg(hex, Theme.alpha.iconBoxFill, radiusDp)); U.params(box, sizeDp, sizeDp); return box; } W.taskIconBox = function (hex, iconChar) { return iconBoxTint(hex, Theme.m.taskIconBox, Theme.m.taskIconRadius, iconChar, Theme.m.taskIconSize); }; W.recordIconBox = function (hex, iconChar) { return iconBoxTint(hex, Theme.m.recordIconBox, Theme.m.recordIconRadius, iconChar, Theme.m.recordIconSize); }; /** * 任务卡(B1/B2)。 * * 结构:图标框 · [任务名 + 状态徽标] / 当前脚本 / 上次运行 · 运行按钮。 * * 两个必须做对的地方: * ① 点卡片主体 = 进详情页;点「运行」按钮 = 直接跑 —— **两个命中区**。 * 合成一个的话,用户想进去改配置却把脚本跑起来了。 * ② 运行中**不禁用**其他任务的按钮(规格书 §2.3:「按钮仍然可点, * 点击后弹 F3 确认,不做禁用」)。禁用会让用户以为"卡死了"。 * * @param opts.onOpen 点卡片主体 * @param opts.onRun 点运行/停止按钮 */ W.taskCard = function (opts) { var badge = W.badge(opts.badge, opts.badgeTone); var nameLine = U.row(false); nameLine.addView(U.text(opts.name, Theme.m.taskNameSize, Theme.hex.textPrimary, true)); nameLine.addView(U.hgap(8)); nameLine.addView(badge.root); var textGroup = U.column(); textGroup.addView(nameLine); textGroup.addView(U.spacer(4)); textGroup.addView(U.text(opts.scriptLine, Theme.m.chipSize, Theme.hex.textFaint, false)); textGroup.addView(U.spacer(4)); textGroup.addView(U.text(opts.lastLine, Theme.m.chipSize, Theme.hex.textDim, false)); /* 运行按钮:默认渐变,运行中那一个换成次级样式(设计稿 B2 的「停止」)。 */ var btnLabel = U.text(opts.running ? "\u505C\u6B62" : "\u8FD0\u884C", Theme.m.sectionSize - 1, "#FFFFFF", true); btnLabel.setGravity(android.view.Gravity.CENTER); var button = new android.widget.FrameLayout(context); button.setBackground(opts.running ? U.solidBg(U.alphaOf("#FFFFFF", 0.08), Theme.m.pillRadius) : U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius)); U.params(button, Theme.m.taskRunBtnW, Theme.m.taskRunBtnH); button.addView(btnLabel); if (opts.onRun) { button.setOnClickListener(function () { opts.onRun(); }); } var card = U.column(); card.setBackground(U.strokeBg(Theme.alpha.cardFill, Theme.alpha.cardStroke, "#FFFFFF", Theme.m.cardRadius)); card.setPadding(U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding)); var line = U.row(); var iconBox = W.taskIconBox(opts.colorHex, opts.iconChar); line.addView(iconBox); line.addView(U.hgap(12)); /* 文本组用 weight=1 撑开:右侧是固定宽 68 的按钮,不会把标签挤成 0 宽 * (0 宽那类问题只在右侧控件是 MATCH_PARENT 时出现,见 W.settingRow 注释)。 */ U.params(textGroup, 0, -2, 1); line.addView(textGroup); line.addView(U.hgap(12)); line.addView(button); card.addView(line); /** * ★ 点卡片 = 进详情页。**每一块可点区域都要显式挂一次**,不能只挂根容器。 * * 真机踩过(2026-10-13,用户反馈「点任务卡上的脚本进不去参数设置」): * 原先这里写了一句 `line.setOnClickListener(null)`,本意是"行内不挂监听、让气泡到卡片"。 * 但 AutoX 的桥对 `null` 并不保证是"清掉监听"—— 它很可能装上一个 * **吃掉点击却什么都不做**的空监听。于是: * · 点「运行」按钮 → 有反应(按钮是它自己的子视图,装着真监听); * · 点卡片的其他任何地方 → 毫无反应(触摸被那一层吞了,到不了 card)。 * 两件事实拼起来才知道问题在哪 —— 单看"按钮能用"会以为点击链路是好的。 * * 所以现在 card / 图标框 / 文本组 各挂一次同一个回调: * 谁接到都做同一件事,既不依赖气泡顺序,也不怕中间某层被点成 clickable。 */ if (opts.onOpen) { var openDetail = function () { opts.onOpen(); }; card.setOnClickListener(openDetail); iconBox.setOnClickListener(openDetail); textGroup.setOnClickListener(openDetail); } return { root: card, badge: badge, button: button, iconBox: iconBox, textGroup: textGroup }; }; /** * 记录条目(D1)。 * * 结构:图标框 · [任务 · 脚本 + 结果标签] / 时间行 / 结果行 · 右箭头。 * 结果行按结果分类换色(报错那条是玫瑰红,一眼能在列表里挑出来)。 */ W.recordRow = function (opts) { var titleLine = U.row(false); titleLine.addView(U.text(opts.title, Theme.m.labelSize, Theme.hex.textPrimary, true)); titleLine.addView(U.hgap(8)); titleLine.addView(W.badge(opts.resultLabel, opts.resultTone).root); /* 可选动作槽(现在只有「继续」用它)。 * 按钮作为子 View 单独挂监听 —— 子 View 会自己消费点击, * 不会冒泡到整卡的 onOpen,所以"点按钮续跑、点别处进详情"两件事互不干扰。 * `W.checkRow` 里处理同一问题的手法可以对照着看。 */ if (opts.actionLabel) { var action = W.smallButton(opts.actionLabel, opts.actionTone || "accent", function () { if (opts.onAction) { opts.onAction(); } }); titleLine.addView(U.flexible()); titleLine.addView(action.root); } var resultColor = opts.resultTone === "danger" ? Theme.hex.danger : Theme.hex.textDim; var textGroup = U.column(); textGroup.addView(titleLine); textGroup.addView(U.spacer(4)); textGroup.addView(U.text(opts.timeLine, Theme.m.chipSize, Theme.hex.textFaint, false)); if (opts.resultLine) { textGroup.addView(U.spacer(4)); textGroup.addView(U.text(opts.resultLine, Theme.m.chipSize, resultColor, false)); } var line = U.row(); line.addView(W.recordIconBox(opts.colorHex, opts.iconChar)); line.addView(U.hgap(12)); U.params(textGroup, 0, -2, 1); line.addView(textGroup); line.addView(U.hgap(8)); line.addView(W.chevronRight(18, Theme.hex.textFaint)); var card = U.column(); card.setBackground(U.strokeBg(Theme.alpha.cardFill, Theme.alpha.cardStroke, "#FFFFFF", Theme.m.recordCardRadius)); card.setPadding(U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding), U.dp(Theme.m.cardPadding)); card.addView(line); if (opts.onOpen) { card.setOnClickListener(function () { opts.onOpen(); }); } return card; }; /* ------------------------------------------------------------ 小按钮 / 标签按钮 */ /** * 小按钮(「打开 / 分享 / 复制 / 选择」这类行内操作)。 * 尺寸 26 高 + 8 圆角,与胶囊(全圆角)区分开:胶囊是"状态/开关",方块是"动作"。 */ W.smallButton = function (label, tone, onClick, minWidthDp) { var chip = W.chip(label, tone || "default", minWidthDp, Theme.m.chipRadius); chip.root.setTextSize(Theme.m.chipSize); if (onClick) { chip.root.setOnClickListener(function () { onClick(); }); } return chip; }; /* ------------------------------------------------------------ 主按钮 */ /** * 底部主按钮:青蓝→紫渐变,48 高,全圆角。文案随运行态在「开始运行 / 停止运行」间切换。 */ W.runButton = function (onClick, text) { var label = U.text(text || "\u5F00\u59CB\u8FD0\u884C", Theme.m.sectionSize, "#FFFFFF", true); label.setGravity(android.view.Gravity.CENTER); var button = new android.widget.FrameLayout(context); button.setBackground(U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius)); U.params(button, -1, Theme.m.runBtnHeight); button.addView(label); if (onClick) { button.setOnClickListener(function () { onClick(); }); } return { root: button, label: label }; }; /** 次要说明文字(页面底部提示)。 */ W.hint = function (content, center) { var view = U.text(content, Theme.m.hintSize, Theme.hex.textFaint, false); if (center) { view.setGravity(android.view.Gravity.CENTER); } return view; }; /** * 提示条:标题 + 一行说明 + 右侧动作按钮。 * * 为什么单独做一个组件、而不是用 `W.hint` + `W.smallButton` 临时拼: * 同一种提示要出现在「ID提取页顶部」与「记录详情页」两处,拼两次必然长歪; * 而且 `W.hint` 没有描边,直接在玻璃背景上会"浮"在卡片外 —— * 这个界面的分层靠的就是 `U.strokeBg` 的描边(`W.card` 同理)。 * * @param opts { title, detail, tone:'warn'|'accent'|'mute'|'ok', actions:[{label,tone,onClick}] } * @returns { root, setTitle, setDetail } */ W.noticeBar = function (opts) { var o = opts || {}; var tone = o.tone || "warn"; var t = W.TONES[tone] || W.TONES["warn"]; var box = U.column(); box.setBackground(U.strokeBg(0.10, 0.24, t.text, Theme.m.cardRadius)); box.setPadding(U.dp(12), U.dp(12), U.dp(12), U.dp(12)); var title = U.text(o.title || "", Theme.m.labelSize, t.text, true); box.addView(title); var detail = null; if (o.detail) { box.addView(U.spacer(4)); detail = U.text(o.detail, Theme.m.chipSize, Theme.hex.textDim, false); box.addView(detail); } var actions = o.actions || []; if (actions.length > 0) { var row = U.row(); row.addView(U.flexible()); for (var i = 0; i < actions.length; i++) { if (i > 0) { row.addView(U.hgap(8)); } /* 用闭包把当前这一项绑住:直接在循环里引用 actions[i] 会因为 * 点击时才求值而全指向最后一项(这类"全点成同一个"极难发现)。 */ var btn = W.smallButton(actions[i].label, actions[i].tone || "accent", (function (one) { return function () { if (one && one.onClick) { one.onClick(); } }; }(actions[i]))); row.addView(btn.root); } box.addView(U.spacer(10)); box.addView(row); } return { root: box, setTitle: function (text) { title.setText(text); }, setDetail: function (text) { if (detail) { detail.setText(text); } } }; }; /* ------------------------------------------------------------ 底部页签 */ /* 页签顺序即设计稿从左到右的顺序。 * * ★ 2026-10-13 规格书 §1:底栏只有 **3 个 tab**(任务 / 记录 / 我的)。 * 原来的「ID提取 / UID私信 / 养号 / 视频功能」四个页签 * 降级为「任务」tab 里的四张任务卡(见 Pages.TASKS)。 * * 图标一律用 U+25xx 几何块字符,**不要用 U+2709 这类有 emoji 变体的码位**—— * Android 上即使追加 U+FE0E(文本变体选择符)也常被渲染成彩色 emoji, * 在深色界面里非常突兀(实测踩过)。换成 PNG 图标只需改这里的 icon 字段。 */ W.TABS = [ { key: "tasks", icon: "\u25A3", label: "\u4EFB\u52A1" }, { key: "records", icon: "\u25F4", label: "\u8BB0\u5F55" }, { key: "mine", icon: "\u25C9", label: "\u6211\u7684" } ]; W.tabBar = function (activeKey, onSelect) { var bar = U.row(); bar.setGravity(android.view.Gravity.CENTER); for (var i = 0; i < W.TABS.length; i++) { var tab = W.TABS[i]; var color = tab.key === activeKey ? Theme.hex.accent : Theme.hex.tabIdle; var icon = U.text(tab.icon, 16, color, false); icon.setGravity(android.view.Gravity.CENTER); var label = U.text(tab.label, Theme.m.tabSize, color, true); label.setGravity(android.view.Gravity.CENTER); var item = U.column(); item.setGravity(android.view.Gravity.CENTER); item.addView(icon); item.addView(U.spacer(4)); item.addView(label); U.params(item, 0, Theme.m.tabBarHeight, 1); (function (key) { item.setOnClickListener(function () { onSelect(key); }); })(tab.key); bar.addView(item); } return bar; }; /* ------------------------------------------------------------ 全局运行条 */ /** * 全局运行条(规格书 §2.4 / 设计稿 B2 的 30:304)。 * * 位置:内容区**上方**(状态栏之下),仅「运行中」时出现,空闲/完成整条隐藏。 * 内容:运行点 · {任务名} · 脚本X · 运行中 + 右侧等宽计时。 * 报错态:整条变玫瑰红、文案换成「执行出错已停止」、右侧「已终止」。 * * 分工是刻意的:**运行条只管状态与导航(可点击跳回运行中的任务), * 停止由页面底部的 CTA 负责** —— 条上不放停止按钮, * 否则"点一下条"到底会停止还是跳转,用户没法预期。 * * 计时用等宽字:数字每秒在变,比例字宽会让整条轻微抖动。 */ W.runBar = function (text, elapsed, tone) { /* tone: "ok"(运行中)/ "warn"(正在收尾)/ "danger"(报错中断) */ function toneColor(value) { if (value === "warn") { return Theme.hex.warn; } if (value === "danger") { return Theme.hex.danger; } return Theme.hex.ok; } var dot = U.text("\u25CF", 8, toneColor(tone), false); var label = U.text(text, 12, Theme.hex.textBody, true); label.setSingleLine(true); label.setEllipsize(android.text.TextUtils.TruncateAt.END); var time = U.monoText(elapsed, Theme.m.chipSize, toneColor(tone)); var bar = U.row(); bar.setGravity(android.view.Gravity.CENTER_VERTICAL); bar.setBackground(U.solidBg(U.alphaOf(tone === "danger" ? Theme.hex.danger : Theme.hex.accent, Theme.alpha.runBarFill), Theme.m.runBarRadius)); bar.setPadding(U.dp(Theme.m.runBarPadH), U.dp(Theme.m.runBarPadV), U.dp(Theme.m.runBarPadH), U.dp(Theme.m.runBarPadV)); bar.addView(dot); bar.addView(U.hgap(8)); /* 文案用 flexible 顶开:计时贴右端,长任务名先被省略号吃掉 */ U.params(label, 0, -2, 1); bar.addView(label); bar.addView(U.hgap(8)); bar.addView(time); return { root: bar, setText: function (v) { label.setText(String(v)); }, setElapsed: function (v) { time.setText(String(v)); }, /* 色调要能中途换:运行中是绿的,正在收尾是琥珀,报错中断是玫瑰红 —— * 三者都是"还在界面上",但用户需要一眼看出它们不是一回事。 */ setTone: function (v) { dot.setTextColor(U.argb(toneColor(v))); time.setTextColor(U.argb(toneColor(v))); bar.setBackground(U.solidBg( U.alphaOf(v === "danger" ? Theme.hex.danger : Theme.hex.accent, Theme.alpha.runBarFill), Theme.m.runBarRadius)); } }; }; /* ------------------------------------------------------------ 警示图标 */ /** * 弹窗顶部的警示图标(设计稿 16:952 / 16:1481)。 * * 设计稿是一个描边矢量图标。这里改用字符 "!" —— 换成 ⚠(U+26A0) 在 Android 上 * 会被渲染成彩色 emoji,加 U+FE0E 也压不住(页签图标踩过同一个坑)。 * 底衬用同色 14% 圆角方块,视觉层级与设计稿一致。 */ W.warnIcon = function (hex) { var view = U.text("!", 20, hex, true); view.setGravity(android.view.Gravity.CENTER); U.params(view, Theme.m.warnIconSize, Theme.m.warnIconSize); view.setBackground(U.solidBg(U.alphaOf(hex, Theme.alpha.warnFill), Theme.m.warnIconRadius)); return view; }; /* ------------------------------------------------------------ 弹窗按钮 */ /** * 主按钮(渐变色,弹窗内的「去授权 / 知道了 / 登录激活 / 确认」)。 * * 与 W.runButton 的区别只在尺寸:主按钮吃满卡片宽度但只有 46~48 高, * 而开始运行按钮固定在页面底部。两者视觉必须一致,所以共用同一套渐变。 */ W.primaryButton = function (label, heightDp, onClick) { var text = U.text(label, 15, "#FFFFFF", true); text.setGravity(android.view.Gravity.CENTER); var button = new android.widget.FrameLayout(context); button.setBackground(U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius)); U.params(button, -1, heightDp || 46); button.addView(text); if (onClick) { button.setOnClickListener(function () { onClick(); }); } return { root: button, label: text }; }; /** 实心危险按钮(「确认解绑」):不可逆操作用纯色而不是渐变,视觉上更"重"。 */ W.dangerButton = function (label, heightDp, onClick) { var text = U.text(label, 15, "#FFFFFF", true); text.setGravity(android.view.Gravity.CENTER); var button = new android.widget.FrameLayout(context); button.setBackground(U.solidBg(U.argb(Theme.hex.danger), Theme.m.pillRadius)); U.params(button, -1, heightDp || 44); button.addView(text); if (onClick) { button.setOnClickListener(function () { onClick(); }); } return { root: button, label: text }; }; /** 次级按钮(「取消」):白 6% 填充 + 10% 描边。 */ W.ghostButton = function (label, heightDp, onClick) { var text = U.text(label, 15, Theme.hex.textSecondary, true); text.setGravity(android.view.Gravity.CENTER); var button = new android.widget.FrameLayout(context); button.setBackground(U.strokeBg(0.06, 0.10, "#FFFFFF", Theme.m.pillRadius)); U.params(button, -1, heightDp || 44); button.addView(text); if (onClick) { button.setOnClickListener(function () { onClick(); }); } return { root: button, label: text }; }; /** 居中的次要文字链(「稍后再说」「购买卡密 · 联系客服」「退出应用」)。 */ W.link = function (label, hex, onClick) { var view = U.text(label, 13, hex, false); view.setGravity(android.view.Gravity.CENTER); /* 触摸目标补到 44dp:纯文字只有 ~16dp 高,直接点很难命中 */ view.setPadding(U.dp(4), U.dp(12), U.dp(4), U.dp(12)); if (onClick) { view.setOnClickListener(function () { onClick(); }); } return view; }; /* ------------------------------------------------------------ 遮罩 */ W.mask = function (alpha) { var view = new android.view.View(context); view.setBackground(U.solidBg(U.alphaOf("#000000", alpha === undefined ? Theme.alpha.mask : alpha))); return view; }; /* ------------------------------------------------------------ 容器工具 */ /** 重建页面用。 */ W.clear = function (container) { container.removeAllViews(); return container; }; /** 竖向滚动容器,页面内容区统一用它兜住超长内容。 */ W.scroller = function () { var scroll = new android.widget.ScrollView(context); scroll.setVerticalScrollBarEnabled(false); scroll.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER); return scroll; }; /* ============================== src/dialog.js ============================== */ /* ============================== src/dialog.js ============================== * 自绘弹窗体系:输入、选择、确认、提示、权限拦截。 * * ── 为什么不用 AutoX 的 dialogs 模块 ─────────────────────────────── * `"ui";` 模式下主线程就是 UI 线程。`dialogs.select(...)` / `dialogs.rawInput(...)` * 这类**不带 callback 的阻塞式调用**在这里拿不到返回值 —— 阻塞会死锁, * 所以运行时直接返回 undefined。表现是一连串很难查的现象: * · 输入框「只能输一个值」:第一次调用返回 undefined,被当成「取消」,流程直接中断; * · 下拉框选完写回 undefined(`Store.keyAt(group, undefined)`), * 配置被写坏,重启后又变回默认值; * · 弹出来的是系统浅色对话框,跟深色界面完全不是一套视觉。 * * 自己画弹窗同时解决这三点:取值走「确定」按钮的回调, * 不存在拿不到返回值的问题;样式也统一由 Theme 控制。 * * 弹窗分两类,命名上刻意区分开: * · 询问类(choose / confirm)—— 用户点了才知道结果; * · 拦截类(runBlocked / permissionBlocked)—— 告诉用户「为什么跑不起来」。 * 后者的标题统一是「无法开始运行」,与设计稿 08 / 12 一致。 * * ⚠️ 这里**没有**输入类弹窗(askText / askNumber 已删除)。 * 文本与数字一律用页面上的常驻输入框(`W.inlineField`)**直接改**—— * 少一次点击,也少一层「弹窗里的值有没有被写回去」的不确定性。 * 保留下来的弹窗都是**非输入**的:选一项、确认一次、被告知一次。 * * 所有弹窗都挂在 Dialog.host(由 main.js 注入)上,同时只允许存在一个。 */ var Dialog = {}; Dialog.WIDTH = 315; /* 宿主容器与当前打开的弹窗 */ Dialog.host = null; Dialog.active = null; /** 由 main.js 在搭好骨架后注入浮层容器。 */ Dialog.mount = function (host) { Dialog.host = host; }; /** * 把弹窗挂到宿主上。 * 挂之前先关掉已有弹窗——两层弹窗叠着关不掉,是这类自绘 UI 最容易出的岔子。 */ function present(overlay) { if (!Dialog.host) { console.log("[dialog] 宿主未注入,弹窗无法显示"); return null; } if (Dialog.active) { Dialog.active.close(); } var handle = { root: overlay, close: function () { var parent = overlay.getParent(); if (parent) { parent.removeView(overlay); } if (Dialog.active === handle) { Dialog.active = null; } } }; Dialog.active = handle; Dialog.host.addView(overlay); return handle; } /* ---------------------------------------------------------------- 骨架 */ /** * 弹窗底部按钮行。 * * 设计稿里的弹窗按钮是**等宽铺满**的两枚(16:960 取消 / 确认解绑), * 或整行一枚(16:1068 知道了)。所以这里不接受「几个胶囊靠右排」的写法, * 一律按等分宽度布局——按钮宽度不等会让两个弹窗看起来不像一套。 * * specs: [{ label, variant: "primary"|"danger"|"ghost", onClick }] */ function dialogButtons(specs) { var line = U.row(); for (var i = 0; i < specs.length; i++) { var spec = specs[i]; var height = spec.height || 46; var button = spec.variant === "danger" ? W.dangerButton(spec.label, height, spec.onClick) : spec.variant === "ghost" ? W.ghostButton(spec.label, height, spec.onClick) : W.primaryButton(spec.label, height, spec.onClick); /* 宽度 0 + weight 1 = 与同行的兄弟等分剩余宽度 */ U.params(button.root, 0, height, 1); if (i > 0) { line.addView(U.hgap(12)); } line.addView(button.root); } return line; } /** * 取消 + 确定 的成对按钮,绝大多数询问类弹窗都用它。 * * ⚠️ 「确定」**不预先关窗**,是否关窗交给 onConfirm 自己决定。 * 早先写成「先 close() 再 onConfirm()」,直接毁掉了输入类弹窗的关键行为: * 用户输了非法值,窗口已经关了,只能在原地提示的意图落空 —— * 他得重新点开、重新输入。校验逻辑写对了也没用,因为关窗发生得更早。 */ function cancelConfirmRow(close, onConfirm, confirmLabel, confirmVariant) { return dialogButtons([ { label: "\u53D6\u6D88", variant: "ghost", height: 44, onClick: close }, { label: confirmLabel || "\u786E\u5B9A", variant: confirmVariant || "primary", height: 44, onClick: onConfirm } ]); } /** 单枚「知道了」。 */ function acknowledgeRow(close, label) { return dialogButtons([ { label: label || "\u77E5\u9053\u4E86", variant: "primary", height: 46, onClick: function () { close(); } } ]); } /** * 弹窗骨架:遮罩 + 居中卡片。 * 返回 { overlay, card },调用方往 card 里塞内容。 */ function buildSheet(title) { var overlay = new android.widget.FrameLayout(context); overlay.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1)); overlay.addView(W.mask(), new android.widget.FrameLayout.LayoutParams(-1, -1)); var card = U.column(); card.setBackground(U.strokeBg(0.06, 0.12, "#FFFFFF", Theme.m.dialogRadius)); card.setPadding(U.dp(24), U.dp(24), U.dp(24), U.dp(24)); /* 卡片必须吞掉点击:否则点卡片会冒泡到遮罩,把弹窗直接关掉 */ card.setClickable(true); var lp = new android.widget.FrameLayout.LayoutParams(U.dp(Dialog.WIDTH), -2); lp.gravity = android.view.Gravity.CENTER; overlay.addView(card, lp); if (title) { card.addView(U.text(title, 18, Theme.hex.textPrimary, true)); card.addView(U.spacer(10)); } return { overlay: overlay, card: card }; } /** 弹窗副标题(说明文字)。字号与设计稿一致:12sp / 行距 17。 */ function sheetNote(text) { var view = U.text(text, 12, Theme.hex.textMuted, false); view.setLineSpacing(U.dp(3), 1.0); return view; } /** 多行说明文字:弹窗里长文案要能换行。 */ function wrappedText(message) { var view = U.text(message, 12, Theme.hex.textMuted, false); view.setLineSpacing(U.dp(4), 1.0); return view; } /* ---------------------------------------------------------------- 提示 */ /** 极简提示:标题 + 说明 + 「知道了」。 */ Dialog.alert = function (title, message) { var sheet = buildSheet(title); var handle = null; sheet.card.addView(wrappedText(message)); sheet.card.addView(U.spacer(20)); sheet.card.addView(acknowledgeRow(function () { if (handle) { handle.close(); } })); handle = present(sheet.overlay); return handle; }; /** * 「无法开始运行」拦截弹窗(单按钮形态,设计稿 16:1065)。 * 与 alert 分开是因为**语义不同**:alert 是告知,这个是拦下了一次操作。 * 分开之后调用点读起来就是「被拦了,因为……」,不用去猜 title 里该写什么。 */ Dialog.runBlocked = function (message, onAcknowledge) { var sheet = buildSheet("\u65E0\u6CD5\u5F00\u59CB\u8FD0\u884C"); var handle = null; sheet.card.addView(sheetNote(message)); sheet.card.addView(U.spacer(20)); sheet.card.addView(acknowledgeRow(function () { if (handle) { handle.close(); } if (onAcknowledge) { onAcknowledge(); } })); handle = present(sheet.overlay); return handle; }; /** * 「该脚本的执行引擎尚未接入」(拦截弹窗)。 * * 与 runBlocked 分开写,是因为两者要用户做的事完全不同: * runBlocked 是"你现在就能修好"(去勾一个脚本), * 这个是"暂时做不了"(引擎还没写)。混用一个弹窗会让用户白找一圈。 */ Dialog.engineMissing = function (scriptName) { return Dialog.runBlocked( "\u300C" + scriptName + "\u300D\u7684\u6267\u884C\u5F15\u64CE\u5C1A\u672A\u63A5\u5165\u3002\n\n" + "\u76EE\u524D\u5DF2\u63A5\u5165\u5F15\u64CE\u7684\u662F\u300C\u89C6\u9891\u529F\u80FD\u300D" + "\u9875\u7684\u300C\u5173\u952E\u8BCD\u89C6\u9891\u529F\u80FD\u300D\u3002"); }; /** 「引擎起不来」——缺产物、缺关键词、启动报错。message 里带可操作的指引。 */ Dialog.engineUnavailable = function (message) { return Dialog.runBlocked(message); }; /** * F3 切换任务确认(设计稿 30:1011)。 * * 全局单跑(规格书 §2.3):运行中点别的任务的「运行」**不禁用按钮**, * 而是弹这个确认。禁用按钮会让用户以为"卡死了", * 而"先停旧的再起新的"是一个明确的、可以自己决定的后果。 * * 标题里的运行中任务名是**真实读出来的**(Pages.taskName(Session.tab)), * 不是固定文案 —— 用户需要知道到底是"谁"要被停掉。 */ Dialog.switchTask = function (runningName, targetName, onConfirm) { var sheet = buildSheet(runningName + "\u6B63\u5728\u8FD0\u884C"); var handle = null; sheet.card.addView(sheetNote( "\u5F00\u59CB " + targetName + " \u5C06\u5148\u505C\u6B62\u5F53\u524D\u811A\u672C\uFF08\u5168\u5C40\u5355\u8DD1\uFF09")); sheet.card.addView(U.spacer(20)); sheet.card.addView(cancelConfirmRow( function () { if (handle) { handle.close(); } }, function () { if (handle) { handle.close(); } /* 先停旧的、再起新的:停止是**请求式**的(写 stop.flag), * 不等它回执就起新引擎 —— 两边 runId 不同, * 旧引擎的终态回报会被界面按 runId 过滤掉,不会污染新一轮。 */ App.stopRun(); if (onConfirm) { onConfirm(); } }, "\u505C\u6B62\u5E76\u5F00\u59CB" )); handle = present(sheet.overlay); return handle; }; /** * H5 停止二次确认(规格书 §10.6)。 * * 悬浮球上的「停止」一定要确认:它浮在别的应用上,误触的概率远高于 App 内的按钮, * 而停止的后果是**进度不保留**(下次从开头重跑)。 * 反过来「运行」不确认 —— 起跑是可逆的(再点一次停止即可), * 两个动作的风险不对称,确认门槛也就不该一样。 */ Dialog.stopScript = function (scriptName, elapsedText, onConfirm) { var sheet = buildSheet("\u505C\u6B62\u5F53\u524D\u811A\u672C\uFF1F"); var handle = null; sheet.card.addView(sheetNote(String(scriptName) + " \u5DF2\u8FD0\u884C " + String(elapsedText))); sheet.card.addView(U.spacer(6)); sheet.card.addView(sheetNote("\u505C\u6B62\u540E\u8FDB\u5EA6\u4E0D\u4FDD\u7559\uFF0C\u4E0B\u6B21\u5C06\u4ECE\u5F00\u5934\u91CD\u65B0\u5F00\u59CB")); sheet.card.addView(U.spacer(20)); sheet.card.addView(cancelConfirmRow( function () { if (handle) { handle.close(); } }, function () { if (handle) { handle.close(); } if (onConfirm) { onConfirm(); } }, "\u505C\u6B62\u8FD0\u884C", "danger" )); handle = present(sheet.overlay); return handle; }; /** H6 退出应用二次确认(规格书 §10.6 / §10.7:停脚本 + 关球 + 退到桌面)。 */ Dialog.exitApp = function (onConfirm) { var sheet = buildSheet("\u9000\u51FA\u98DE\u821F\u811A\u672C\uFF1F"); var handle = null; sheet.card.addView(sheetNote("\u5C06\u505C\u6B62\u6B63\u5728\u8FD0\u884C\u7684\u811A\u672C\uFF0C\u5E76\u5173\u95ED\u60AC\u6D6E\u7403")); sheet.card.addView(U.spacer(6)); sheet.card.addView(sheetNote("\u811A\u672C\u8FDB\u5EA6\u4E0D\u4FDD\u7559\uFF0C\u4E0B\u6B21\u8FD0\u884C\u5C06\u4ECE\u5F00\u5934\u91CD\u65B0\u5F00\u59CB")); sheet.card.addView(U.spacer(20)); sheet.card.addView(cancelConfirmRow( function () { if (handle) { handle.close(); } }, function () { if (handle) { handle.close(); } if (onConfirm) { onConfirm(); } }, "\u9000\u51FA\u5E94\u7528", "danger" )); handle = present(sheet.overlay); return handle; }; /** * F5 脚本报错(设计稿 30:1228)。 * * 与 runBlocked / alert 都不同:它有两个出口,而且**两个出口都要给**—— * 「查看日志」直接进 D3(用户想弄清楚为什么挂了), * 「知道了」只是收掉。合成一个按钮会逼用户在"看不到原因"和"被迫跳页"之间二选一。 */ Dialog.scriptError = function (message, onViewLogs, onDismiss) { var sheet = buildSheet(); var handle = null; /* 顶部警示图标:字符 "!" + 玫瑰色 14% 衬底(矢量图标在字符方案里见 W.warnIcon) */ sheet.card.addView(W.warnIcon(Theme.hex.danger)); sheet.card.addView(U.spacer(12)); sheet.card.addView(U.text("\u811A\u672C\u6267\u884C\u51FA\u9519", 18, Theme.hex.textPrimary, true)); sheet.card.addView(U.spacer(10)); sheet.card.addView(sheetNote(message || "\u811A\u672C\u5DF2\u505C\u6B62\u8FD0\u884C")); sheet.card.addView(U.spacer(20)); sheet.card.addView(dialogButtons([ { label: "\u67E5\u770B\u65E5\u5FD7", variant: "ghost", height: 44, onClick: function () { if (handle) { handle.close(); } if (onViewLogs) { onViewLogs(); } } }, { label: "\u77E5\u9053\u4E86", variant: "primary", height: 44, onClick: function () { if (handle) { handle.close(); } if (onDismiss) { onDismiss(); } } } ])); handle = present(sheet.overlay); return handle; }; /** 确认:取消 / 确定,结果走回调。 */ Dialog.confirm = function (title, message, onConfirm) { var sheet = buildSheet(title); var handle = null; sheet.card.addView(wrappedText(message)); sheet.card.addView(U.spacer(20)); sheet.card.addView(cancelConfirmRow( function () { if (handle) { handle.close(); } }, function () { if (handle) { handle.close(); } if (onConfirm) { onConfirm(); } } )); handle = present(sheet.overlay); return handle; }; /* ---------------------------------------------------------------- 单选 */ /** * 单选弹窗。选项竖排,当前项高亮。 * 比下拉列表更适合深色界面,也避免了下拉框「选完写回 undefined」那一类问题。 */ Dialog.choose = function (opts) { var sheet = buildSheet(opts.title); var handle = null; var options = opts.options || []; for (var i = 0; i < options.length; i++) { var selected = i === opts.currentIndex; var item = U.text(options[i], Theme.m.labelSize, selected ? Theme.hex.accentText : Theme.hex.textSecondary, selected); item.setGravity(android.view.Gravity.CENTER_VERTICAL); item.setBackground(selected ? U.solidBg(U.alphaOf("#FFFFFF", Theme.alpha.accentChipFill), Theme.m.chipRadius) : U.solidBg(U.alphaOf("#FFFFFF", 0), Theme.m.chipRadius)); item.setPadding(U.dp(12), U.dp(11), U.dp(12), U.dp(11)); (function (index, view) { view.setOnClickListener(function () { if (handle) { handle.close(); } if (opts.onConfirm) { opts.onConfirm(index); } }); })(i, item); if (i > 0) { sheet.card.addView(U.spacer(6)); } sheet.card.addView(item); } sheet.card.addView(U.spacer(20)); sheet.card.addView(dialogButtons([ { label: "\u53D6\u6D88", variant: "ghost", height: 44, onClick: function () { if (handle) { handle.close(); } } } ])); handle = present(sheet.overlay); return handle; }; /* ---------------------------------------------------------------- 权限 */ /** * 单行权限:名称 + 状态标签。整行可点,点了直接跳对应设置页。 * * 状态文案只有三种,集中在 W.pill 的 tone 里表达: * 已开启 → ok(青绿) * 未开启 → warn(琥珀)—— 这是阻断项 * 未开启(可选) → mute(灰)—— 不阻断,故意不抢注意力 * 把「可选」写进标签本身,而不是靠颜色让用户猜,是这里最要紧的一点。 */ function permissionRow(row) { var nameView = U.text(row.name, Theme.m.labelSize, Theme.hex.textPrimary, false); nameView.setSingleLine(true); var tone = row.granted ? "ok" : (row.optional ? "mute" : "warn"); var text = row.granted ? "\u5DF2\u5F00\u542F" : (row.optional ? "\u672A\u5F00\u542F\uFF08\u53EF\u9009\uFF09" : "\u672A\u5F00\u542F"); var status = W.pill(text, tone); var line = U.row(); line.addView(nameView); line.addView(U.flexible()); line.addView(U.hgap(Theme.m.gapInline)); line.addView(status.root); /* 整行都算命中区:可视高度只有 26dp 左右, * 不加高会低于 44dp 的最小触摸目标。 */ line.setPadding(0, U.dp(9), 0, U.dp(9)); line.setMinimumHeight(U.dp(Theme.m.touchTarget)); line.setOnClickListener(function () { Device.openPermissionSettings(row.key); }); return line; } /** 把三行权限按固定顺序铺进卡片。两个权限弹窗共用,避免顺序各写各的。 */ function stackPermissionRows(card) { var rows = Device.permissionRows(); for (var i = 0; i < rows.length; i++) { if (i > 0) { card.addView(U.spacer(8)); } card.addView(permissionRow(rows[i])); } return rows; } /** * 「去授权」该跳哪一项。 * * 规则:先找**阻断项**(未开启且非可选),都开了再退而处理可选的通知。 * 早先的实现直接取第一个未授权项,会在「无障碍已开、通知没开」时 * 把用户带到通知设置页 —— 而他真正缺的是悬浮窗。 */ function firstPendingPermission() { var rows = Device.permissionRows(); var i; for (i = 0; i < rows.length; i++) { if (!rows[i].granted && !rows[i].optional) { return rows[i]; } } for (i = 0; i < rows.length; i++) { if (!rows[i].granted) { return rows[i]; } } return null; } /** 权限行 + 去授权/稍后再说 的公共尾部。 */ function buildPermissionSheet(title, note) { var sheet = buildSheet(title); var handle = null; sheet.card.addView(sheetNote(note)); sheet.card.addView(U.spacer(18)); stackPermissionRows(sheet.card); sheet.card.addView(U.spacer(20)); var go = W.primaryButton("\u53BB\u6388\u6743", 46, function () { var pending = firstPendingPermission(); if (!pending) { toast("\u6743\u9650\u5DF2\u9F50\u5168"); if (handle) { handle.close(); } return; } Device.openPermissionSettings(pending.key); /* 跳出去后不自动关闭:用户开了权限再退回来, * 留在弹窗上能让他看到状态已经变绿。 */ toast("\u8BF7\u5F00\u542F\u300C" + pending.name + "\u300D\u540E\u8FD4\u56DE"); }); sheet.card.addView(go.root); /* 「稍后再说」按设计稿是纯文字,不占按钮位 */ sheet.card.addView(W.link("\u7A0D\u540E\u518D\u8BF4", Theme.hex.textFaint, function () { if (handle) { handle.close(); } })); handle = present(sheet.overlay); return handle; } /** * 权限检测弹窗(设计稿 7:260)。 * 只做展示与跳转,不自己改任何状态(检测与执行分离)。 */ Dialog.showPermissions = function () { return buildPermissionSheet("\u9700\u8981\u5F00\u542F\u6743\u9650", "\u4E3A\u6B63\u5E38\u6267\u884C\u811A\u672C\uFF0C\u8BF7\u5148\u6388\u4E88\u4EE5\u4E0B\u6743\u9650"); }; /** * 权限拦截弹窗(设计稿 16:436)。 * * 与 showPermissions 的区别不是文案,而是**时机与语气**: * 前者是进 App 时的软引导,后者是用户按了「开始运行」却被挡回来; * 所以标题是「无法开始运行」,说明也直接点名缺哪两个权限。 */ Dialog.permissionBlocked = function () { return buildPermissionSheet("\u65E0\u6CD5\u5F00\u59CB\u8FD0\u884C", "\u65E0\u969C\u788D\u4E0E\u60AC\u6D6E\u7A97\u6743\u9650\u672A\u5F00\u542F\uFF0C\u811A\u672C\u65E0\u6CD5\u6267\u884C"); }; /* ------------------------------------------------------------ 危险操作确认 */ /** * 解绑卡密确认(设计稿 16:951)。 * * 三件事必须同时说清,否则用户点完才后悔: * ① 后果是什么(要重新输入卡密);② 有没有更严重的连带影响(正在跑的会停)。 * 确认按钮用实心红而不是渐变青蓝 —— 渐变在这套界面里代表「推荐动作」。 */ Dialog.confirmUnbind = function (onConfirm) { var sheet = buildSheet(null); var handle = null; sheet.card.addView(W.warnIcon(Theme.hex.danger)); sheet.card.addView(U.spacer(16)); sheet.card.addView(U.text("\u786E\u8BA4\u89E3\u7ED1\u5361\u5BC6\uFF1F", 18, Theme.hex.textPrimary, true)); sheet.card.addView(U.spacer(10)); sheet.card.addView(sheetNote("\u89E3\u7ED1\u540E\u9700\u91CD\u65B0\u8F93\u5165\u5361\u5BC6\u624D\u80FD\u4F7F\u7528")); sheet.card.addView(U.spacer(4)); sheet.card.addView(sheetNote("\u6B63\u5728\u8FD0\u884C\u7684\u811A\u672C\u5C06\u7ACB\u5373\u505C\u6B62")); sheet.card.addView(U.spacer(20)); sheet.card.addView(cancelConfirmRow( function () { if (handle) { handle.close(); } }, function () { if (handle) { handle.close(); } if (onConfirm) { onConfirm(); } }, "\u786E\u8BA4\u89E3\u7ED1", "danger" )); handle = present(sheet.overlay); return handle; }; /** * 卡密到期弹窗(设计稿 16:1480)。 * 唯一的出口是「确认」,确认后回到登录页 —— 到期后留在主界面没有任何可做的事。 */ Dialog.licenseExpired = function (onConfirm) { var sheet = buildSheet(null); var handle = null; sheet.card.addView(W.warnIcon(Theme.hex.warn)); sheet.card.addView(U.spacer(16)); sheet.card.addView(U.text("\u6B63\u5728\u8FD0\u884C\u7684\u811A\u672C\u5DF2\u505C\u6B62", 18, Theme.hex.textPrimary, true)); sheet.card.addView(U.spacer(10)); sheet.card.addView(sheetNote("\u5361\u5BC6\u5DF2\u5230\u671F\uFF0C\u9700\u91CD\u65B0\u767B\u5F55\u540E\u7EE7\u7EED\u4F7F\u7528")); sheet.card.addView(U.spacer(20)); sheet.card.addView(dialogButtons([ { label: "\u786E\u8BA4", variant: "primary", height: 46, onClick: function () { if (handle) { handle.close(); } if (onConfirm) { onConfirm(); } } } ])); sheet.card.addView(U.spacer(10)); var tail = U.text("\u786E\u8BA4\u540E\u5C06\u8FD4\u56DE\u5361\u5BC6\u767B\u5F55\u754C\u9762", 11, Theme.hex.textFaint, false); tail.setGravity(android.view.Gravity.CENTER); sheet.card.addView(tail); handle = present(sheet.overlay); return handle; }; /* ============================== src/floatball.js ============================== */ /* ============================== src/floatball.js ============================== * 全局悬浮球(规格书 §10 / 设计稿 H1–H7 `34:*`)。 * * ## 它解决什么 * 引擎必须待在 TikTok 前台才认得出页面,所以 App 内的「全局运行条」在真正 * 干活的时候是看不见的。悬浮球浮在所有应用之上,是**跨 App 唯一的控制入口 * 与运行状态可视处**(规格书 §10 的定位)。 * * ## ★ 窗口层:两个 overlay 窗口,尺寸与位置恒定(2026-10-15 重做) * * **不要改回"一个窗口、展开时改尺寸"。** 那条路必然"跳",而且顺序怎么排都躲不掉: * 改窗口尺寸/位置由系统(WindowManager,另一个进程)**立刻生效**, * 而我们这边的视图重排要等 App 下一帧重绘;中间那几帧,合成器显示的是 * **上一帧的旧画面被摆到新的窗口矩形里**。用录屏逐帧验证过:展开那一刻球会 * 从下往上"爬"几级才停 —— 就是用户说的"跳动"。 * 两次失败的补救(都在真机上试过): * · 同一个 JS 调用里"先藏球、再改尺寸" —— 藏只是标记,改尺寸已经发出去了,照样跳; * · "藏起来 → 等这一帧画完(onPreDraw)→ 再改尺寸" —— 合成器有 2~3 帧缓冲队列, * 它照样能把"藏之前"那一帧摆到新位置上去。 * ⇒ 结论:只能**根本不改窗口几何**。两个窗口各自建好就永不动窝 * (只有用户拖动时球窗口才移,那是用户主动的动作): * · 球窗口 88×88dp:环 + 球 + 透明热区,永远可触摸; * · 扇形窗口 308×264dp:4 个按钮 + 4 个标签,**折叠时整窗 NOT_TOUCHABLE** * (触摸自动穿透到下面的 App,所以它的透明区不是死区),展开时才可触摸。 * 于是"展开/收起"= 改显隐 + 改一个 flag,没有任何几何变化,没有任何东西会跳。 * * ## ⚠️ 其他实测出来的约束(踩完才知道) * 1. 走**裸 WindowManager**(`addView` / `updateViewLayout` / `removeView`), * 而不是 AutoX 的 `floaty.window`: * · `floaty.rawWindow(代码建的 View)` 直接不可用(缺 `toXMLString()`); * · `floaty` 的窗口会在内容外再套一圈 **12dp 内边距**(去不掉),几何全靠反推; * · `floaty` 同时只能有一个窗口真正绘制(第二个建得出来但画不出来); * · `floaty` 默认还带一个调整手柄小点。 * 裸窗口没有这些问题 —— 两个窗口能同时绘制是实测过的(`_probe/fb_two_windows.js`)。 * 本模块用视图自己的 `setPadding` 复现了那圈 12dp 内边距, * 所以"球心在根视图里的坐标"这套算法一个字没改。 * 2. **窗口的透明区同样会吃掉触摸**(实测:点它透明区里的输入框键盘不弹, * 点窗口外就弹)。所以折叠时扇形窗口必须 NOT_TOUCHABLE,否则那是一片死区。 * 3. 覆盖窗口必须 `FLAG_NOT_FOCUSABLE`(§10.9④),否则会抢 TikTok 的键盘焦点。 * 4. `WindowManager.LayoutParams` 的 x/y/w/h 都是**像素**,dp 换算只在 `dp()` 里做。 * 5. **UI 线程禁止 `sleep()`** —— 本模块所有时序一律 `setTimeout` / `setInterval`。 * 6. 图片只能 `images.read(path).bitmap` + `setImageBitmap(bmp)`; * XML 的 `src="…"` 走的是 drawable 查找,**不吃文件路径**(会 InflateException)。 * * ## 与规格书的对应 * §10.1 Ø48 球 / 透明热区 64 / 扇形 R=96 / 子按钮 Ø48 / 夹角 60°20°−20°−60° * §10.3 扇形四个按钮:运行·停止 / 回到 App / 隐藏悬浮球 / 退出应用 * §10.4 三态靠**外环**表达(空闲无环 / 运行中青绿 22% / 报错玫瑰 20%), * 球内**恒定**是品牌图标(避免"球里是播放键 → 以为点了就运行"的歧义) * §10.5 单击 = 展开收起;拖动 > 8px = 移动;松手吸附最近边缘并记忆位置 * §10.11 球在右侧时扇形**镜像向左展开**、标签移到按钮左侧 */ var FB = {}; /* ------------------------------------------------------------ 常量(对齐 §10.1) */ FB.DIAM = 48; // 球体直径 FB.HOT = 64; // 透明热区(球的触控目标比视觉大) FB.R = 96; // 扇形半径 FB.SUB = 48; // 子按钮直径 FB.LABEL_H = 24; // 标签胶囊高 FB.ANGLES = [60, 20, -20, -60]; // 扇形夹角(以球心为原点,向右展开) FB.PAD = 12; // ⚠️ 悬浮窗根视图的固定内边距(实测,不能改) FB.SLOP = 8; // 拖动判定阈值:位移 > 8px 才算拖动,否则是单击 FB.EDGE = 12; // 贴边留白(§10.1 左边距 12) FB.BRAND = "/sdcard/tiktok_app/brand/FluxRpa_mark_only_412.png"; /** * 球的窗口矩形发布给引擎(规格书 §10.9③ 的另一半)。 * * 为什么需要它:球浮在所有应用之上,引擎的**坐标手势**点到球位就点到了球 * (实测:搜索建议列表的一条正好压住球位 → 点出了扇形面板)。 * 引擎侧 `ui.js` 的 clickNode 在读这个文件做避让,所以界面这边必须**如实发布** * 球的窗口矩形(比球大一圈,因为窗口的透明区同样会吃掉触摸)。 */ FB.RECT_PATH = "/sdcard/tiktok_app/ball-rect.json"; /* 动画节奏。规格书 §10.1 给的是 180ms 展开动画;这里用「分帧位移」实现, * 帧数 × 帧间隔 ≈ 180ms(不用 ValueAnimator:多引一层 Java 接口包装, * 换 AutoX 版本就可能失效,而这只是 6 帧的位置插值)。 */ FB.ANIM_FRAMES = 6; FB.ANIM_STEP_MS = 30; FB.BREATH_MS = 900; // 运行中外环呼吸周期 /* ------------------------------------------------------------ 运行期状态 */ /* ------------------- 窗口层:两个 overlay,尺寸恒定 ------------------- */ FB.ballWin = null; // 球窗口(88×88dp):常驻、永远可触摸、尺寸只在拖动时变 FB.fanWin = null; // 扇形窗口句柄占位(真正的视图是 FB.fanRoot) FB.window = null; // 兼容别名 = 球窗口的根视图(dumpLayout / 自检里用它) FB.root = null; // 球窗口里的根 FrameLayout FB.fanRoot = null; // 扇形窗口里的根 FrameLayout FB.ballLp = null; // 球窗口 LayoutParams(拖动改它) FB.fanLp = null; // 扇形窗口 LayoutParams(改位置与 NOT_TOUCHABLE) FB.open = false; // 扇形是否展开 FB.side = "left"; // 球贴哪一边(决定扇形方向) FB.anchorX = 0; // 球左上角 X(像素,绝对屏幕坐标) FB.anchorCy = 0; // 球心 Y(像素) FB.dragging = false; // 本次触摸是否已判定为拖动 FB.breathTimer = null; FB.breathOn = false; FB.views = {}; // id → 视图 FB.lastTone = ""; // 上一次的三态,用于避免无谓重建扇形 FB.lastRectKey = ""; // 上一次发布的球位(避免重复写盘) FB.firstBtn = null; // 扇形第 1 个按钮(运行/停止)的视图引用,用于就地更新 /* ------------------------------------------------------------ 调试开关 */ /** 调试模式:`/sdcard/tiktok_app/ball-debug` 存在即开启(每 2 秒最多查一次)。 */ FB.debugLastCheck = 0; FB.debugOn = false; FB.debugEnabled = function () { var now = Date.now(); if (now - FB.debugLastCheck > 2000) { FB.debugLastCheck = now; try { FB.debugOn = files.exists("/sdcard/tiktok_app/ball-debug"); } catch (e) { FB.debugOn = false; } } return FB.debugOn; }; function logTouch(action, x, y) { try { files.append('/sdcard/tiktok_app/ball-touch.log', Date.now() + ' action=' + action + ' (' + Math.round(x) + ',' + Math.round(y) + ')' + ' open=' + FB.open + ' anchor=' + FB.anchorX + ',' + FB.anchorCy + '\n'); } catch (e) { } } /** 一般轨迹(点按钮、走确认、拉前台…),同样只在 ball-debug 打开时落盘。 */ function trace(msg) { if (!FB.debugEnabled()) { return; } try { files.append('/sdcard/tiktok_app/ball-touch.log', Date.now() + ' ' + msg + '\n'); } catch (e) { } } /** * 异常轨迹。 * * ★ 悬浮球这一层的异常**不会自己冒出来**:UI 回调里抛错既不结束脚本、也不弹提示, * 用户看到的就是"点了没反应"。所以凡是"点了应该发生什么"的路径,都要有人接住 * 并把异常写下来 —— 否则下一次还是只能靠猜。 */ function traceError(where, error) { var text = where + ' 抛错: ' + error + '\n' + (error && error.stack ? error.stack : ''); try { console.log('[fb] ' + text); } catch (e0) { } if (!FB.debugEnabled()) { return; } try { files.append('/sdcard/tiktok_app/ball-touch.log', Date.now() + ' ' + text + '\n'); } catch (e1) { } try { files.write('/sdcard/tiktok_app/ball-error.txt', text); } catch (e2) { } } /* ------------------------------------------------------------ 布局轨迹(调试用) */ /** * 记录「**这一帧球实际画在哪**」。 * * 为什么需要它:展开前后**球心是设计成不动的**(窗口变大、球在窗口内的偏移同步变大, * 两者相抵)。所以任何肉眼可见的位移,都只可能来自"两半改动没落在同一帧"—— * 即窗口已经换了尺寸、而视图还按旧布局摆(或反过来)。那种中间态只存在一两帧, * 连拍截图(~250ms/帧)根本抓不到。 * * 而 `getLocationOnScreen()` 读的是**布局结果**,不是我们设进去的值,所以它能把这个 * 中间态如实反映出来 —— 这正是"能不能看见"的判据。 */ function absPosOf(view) { if (!view) { return "?"; } try { /* ⚠️ 必须是**真的 Java int[]**:Rhino 把 JS 数组按值转成 Java 数组, * 方法写进去的值读不回来 —— 传 [0,0] 永远得到 0,0(白测一轮才发现)。 */ var loc = java.lang.reflect.Array.newInstance(java.lang.Integer.TYPE, 2); view.getLocationOnScreen(loc); return java.lang.reflect.Array.getInt(loc, 0) + "," + java.lang.reflect.Array.getInt(loc, 1); } catch (e) { try { return view.getLeft() + "," + view.getTop(); } catch (e2) { return "?"; } } } /** 打一张当前快照:窗口矩形 + 球/热区的屏幕绝对位置 + 根视图透明度。 */ function traceFrame(tag) { if (!FB.debugEnabled() || !FB.window) { return; } var parts = [tag]; try { parts.push("win=" + FB.window.getWidth() + "x" + FB.window.getHeight() + "@" + FB.window.getX() + "," + FB.window.getY()); } catch (e) { parts.push("win=?"); } if (FB.views.ball) { parts.push("ballAbs=" + absPosOf(FB.views.ball) + " " + FB.views.ball.getWidth() + "x" + FB.views.ball.getHeight()); } if (FB.views.hot) { parts.push("hotAbs=" + absPosOf(FB.views.hot) + " " + FB.views.hot.getWidth() + "x" + FB.views.hot.getHeight()); } try { parts.push("rootAlpha=" + FB.root.getAlpha()); } catch (e2) { } trace("frame " + parts.join(" | ")); } /** 挂一次布局监听:这个视图**每次真正被布局**(含首次)都打一张快照。 */ function watchLayout(tag, view) { if (!FB.debugEnabled() || !view) { return; } try { view.addOnLayoutChangeListener(new android.view.View.OnLayoutChangeListener({ onLayoutChange: function () { traceFrame(tag); } })); } catch (e) { trace("watchLayout(" + tag + ") 失败: " + e); } } /* ------------------------------------------------------------ 小工具 */ function dp(v) { return Math.round(v * (context.getResources().getDisplayMetrics().density || 1)); } function screenW() { return device.width; } function screenH() { return device.height; } /** * 折叠态窗口边长(dp):热区 + 两侧内边距。 * * ★ 命名带 Dp 后缀是刻意的:这套坐标算错过一次 —— `fanSize()` 原先返回**像素** * 而调用方把它当 dp 再用(`place()` 会再乘一次密度),于是展开时球心被算到 * 根视图外面:球整体下移、四个按钮挤在一起。dp / px 混用不会有任何报错, * 只会"看起来不太对",所以这里用名字把单位写死。 */ function collapsedSizeDp() { return FB.HOT + FB.PAD * 2; } /** 扇形展开后窗口需要多大(dp) */ function fanSizeDp() { /* 水平:内边距 + 热区 + 扇形半径 + 子按钮半径 + 4dp 间隙 + 最长标签 + 内边距 */ var labelMax = 96; var w = FB.PAD + FB.HOT + FB.R + FB.SUB / 2 + 4 + labelMax + FB.PAD; /* 垂直:球心上下各「半径 + 子按钮半径 + 余量」 */ var h = 2 * (FB.R + FB.SUB / 2 + 12); return { w: w, h: h }; } /** * 球心在窗口**根视图**里的坐标(dp)—— 展开/折叠共用这一个公式。 * * 折叠态球心在热区正中;展开态球心垂直居中(扇形上下都要地方)、 * 水平仍贴原来那一侧(右侧时取镜像)。 */ function ballCenterInRoot(winWDp, winHDp) { var half = FB.HOT / 2; var rootW = winWDp - FB.PAD * 2; /* 根视图被内边距挤掉一圈 */ var rootH = winHDp - FB.PAD * 2; var x = (FB.side === "right") ? (rootW - half) : half; /* ⚠️ 是**根视图**的中心,不是窗口的中心:漏掉这个 PAD 会让球整体下移 12dp */ return { x: x, y: rootH / 2 }; } /** * 把当前悬浮窗的**真实布局**写盘(排障用)。 * * 这条是踩坑之后加的:球的坐标算错过两次(dp/px 混用、根视图中心漏算内边距), * 两次都表现为"看起来只是有点歪",靠肉眼量截图量不准。 * 有了它,下一次任何"位置不对"都能一眼看出是窗口、根还是某个子视图算错了。 */ FB.dumpLayout = function (tag) { if (!FB.root || !FB.ballLp) { return; } var out = ['# ' + tag]; function winLine(name, lp, view) { if (!lp) { return name + ' (无)'; } var touchable = !(lp.flags & FLAG_NOT_TOUCHABLE); return name + ' ' + lp.width + 'x' + lp.height + ' @ ' + lp.x + ',' + lp.y + ' touchable=' + touchable + ' root=' + (view ? view.getWidth() + 'x' + view.getHeight() : '?'); } try { out.push(winLine('ballWin', FB.ballLp, FB.root)); out.push(winLine('fanWin ', FB.fanLp, FB.fanRoot)); out.push('anchor ' + FB.anchorX + ',' + FB.anchorCy + ' side=' + FB.side + ' open=' + FB.open); function dumpChildren(prefix, view) { if (!view) { return; } for (var i = 0; i < view.getChildCount(); i++) { var v = view.getChildAt(i); var text = ''; var vis = v.getVisibility(); try { text = (typeof v.getText === 'function') ? String(v.getText()) : ''; } catch (e) { text = '?'; } var shown = (vis === android.view.View.GONE) ? ' GONE' : (vis === android.view.View.INVISIBLE ? ' INVIS' : ''); out.push(' ' + prefix + '#' + i + ' ' + v.getLeft() + ',' + v.getTop() + ' ' + v.getWidth() + 'x' + v.getHeight() + shown + ' "' + text + '"'); } } dumpChildren('ball ', FB.root); dumpChildren('fan ', FB.fanRoot); } catch (error) { out.push('dump 失败: ' + error); } try { files.write('/sdcard/tiktok_app/ball-layout.txt', out.join('\n')); } catch (e2) { } }; /* ------------------------------------------------------------ 落盘(记忆位置) */ /** 当前垂直位置的比例(0~1000),换屏幕比例后仍然合理。 */ FB.yRatio = function () { var h = screenH(); if (!h) { return 500; } return Math.max(0, Math.min(1000, Math.round(FB.anchorCy / h * 1000))); }; FB.savePosition = function () { Store.set("ball.side", FB.side); Store.set("ball.yRatio", FB.yRatio()); }; /** 从配置恢复锚点位置(球心 X / Y 像素)。 */ FB.restoreAnchor = function () { FB.side = Store.getEnum("ball.side", ["left", "right"]); var ratio = Store.getNumber("ball.yRatio"); if (ratio < 100 || ratio > 900) { ratio = 500; } /* 越界值当默认,别把球顶到屏幕外 */ FB.anchorCy = Math.round(screenH() * ratio / 1000); FB.anchorX = (FB.side === "right") ? screenW() - dp(FB.EDGE) - dp(FB.DIAM) : dp(FB.EDGE); FB.clampAnchor(); }; /** 锚点必须留在屏幕内:用户手滑拖出去,球就再也点不到了。 */ /** * 把锚点夹进「两个窗口都能完整显示」的范围。 * * ★ 夹的必须是**锚点**而不是窗口:两个窗口的位置都由锚点推导,分别夹会让它们错位。 * 代价是球不能被拖到离上下边缘 120dp 以内(扇形半高)—— 这是物理上必须的, * 否则扇形窗口会出屏、下面两个按钮点不到。横向不用管:球本来就吸附在左右边缘上。 */ FB.clampAnchor = function () { var maxX = Math.max(0, screenW() - dp(FB.DIAM)); FB.anchorX = Math.max(0, Math.min(maxX, FB.anchorX)); var m = dp(fanSizeDp().h / 2) - dp(FB.PAD); /* 球心到扇形窗口上/下边的最小距离 */ var minY = Math.max(dp(FB.DIAM) / 2, m); var maxY = Math.max(minY, screenH() - m); FB.anchorCy = Math.max(minY, Math.min(maxY, FB.anchorCy)); }; /* ------------------------------------------------------------ 视图构建 */ /** 圆形图标按钮(扇形里的四个,或球体本身)。 */ function circleButton(hexFill, gradient, iconChar, iconHex, iconSize) { var view = U.text(iconChar, iconSize || 15, iconHex || "#FFFFFF", false); view.setGravity(android.view.Gravity.CENTER); view.setBackground(gradient ? U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius) : U.solidBg(U.alphaOf(hexFill, hexFill.indexOf("#FFFFFF") === 0 ? 0.08 : 0.14), Theme.m.pillRadius)); U.params(view, FB.SUB, FB.SUB); return view; } /** 标签胶囊(§10.1:高 24、圆角 99、距按钮 4px、11px 字)。 */ function labelPill(text, textHex, bgHex, bgAlpha) { var view = U.text(text, Theme.m.chipSize, textHex, true); view.setGravity(android.view.Gravity.CENTER); view.setBackground(U.solidBg(U.alphaOf(bgHex, bgAlpha), Theme.m.pillRadius)); view.setPadding(U.dp(10), 0, U.dp(10), 0); var lp = new android.widget.FrameLayout.LayoutParams(-2, dp(FB.LABEL_H)); view.setLayoutParams(lp); return view; } /** 绝对定位(根是 FrameLayout,用 margin 当坐标)。 */ function place(view, leftDp, topDp, wDp, hDp) { var lp = new android.widget.FrameLayout.LayoutParams( wDp > 0 ? dp(wDp) : wDp, hDp > 0 ? dp(hDp) : hDp); lp.leftMargin = dp(leftDp); lp.topMargin = dp(topDp); view.setLayoutParams(lp); return view; } /** 球能不能建:卡密有效 + 悬浮窗权限 + 用户没主动隐藏。 */ FB.canShow = function () { if (Store.getBool("ball.hidden")) { return false; } if (Service.licenseStatus().status !== "active") { return false; } return Device.overlayEnabled(); }; /* ------------------------------------------------------------ 窗口层(两个 overlay,尺寸恒定) */ var TYPE_APPLICATION_OVERLAY = 2038; var FLAG_NOT_FOCUSABLE = 0x00000008; var FLAG_NOT_TOUCHABLE = 0x00000010; var FLAG_NOT_TOUCH_MODAL = 0x00000020; /* ★ 必须带上它:否则窗口会被状态栏内缩,整套坐标整体下移一个状态栏高度 * (实测球比锚点低了 48px,正好是状态栏高。AutoX 的 floaty 窗口带这个 flag,所以以前没暴露) */ var FLAG_LAYOUT_IN_SCREEN = 0x00000100; var FLAG_LAYOUT_NO_LIMITS = 0x00000200; var FLAG_HARDWARE_ACCELERATED = 0x01000000; var wmService = null; function wm() { if (!wmService) { wmService = context.getSystemService("window"); } return wmService; } function overlayFlags(touchable) { var f = FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL | FLAG_LAYOUT_NO_LIMITS | FLAG_LAYOUT_IN_SCREEN | FLAG_HARDWARE_ACCELERATED; /* §10.9④:不抢输入焦点,否则会影响 TikTok 的键盘 */ if (!touchable) { f = f | FLAG_NOT_TOUCHABLE; } return f; } /** 建一个覆盖窗口(尺寸取 px)。返回它的 LayoutParams,后面拖动/改触摸要用。 */ function addOverlay(view, x, y, w, h, touchable) { var lp = new android.view.WindowManager.LayoutParams(); lp.type = TYPE_APPLICATION_OVERLAY; lp.format = -3; /* PixelFormat.TRANSLUCENT */ lp.flags = overlayFlags(touchable); lp.gravity = 51; /* TOP | LEFT,于是 x/y 就是屏幕绝对坐标 */ lp.x = x; lp.y = y; lp.width = w; lp.height = h; wm().addView(view, lp); return lp; } /** * ★★ 为什么是**两个**窗口(2026-10-15 重做;这一版之前的做法都错了) * * 之前是"一个窗口,展开时改尺寸"。它必然"跳",且**顺序怎么排都躲不掉**: * 改窗口尺寸/位置由系统(WindowManager)**立刻生效**, * 而我们这边的视图重排要等 App 下一帧重绘; * 中间那几帧,合成器显示的是**上一帧的旧画面被摆到新的窗口矩形里**。 * 用录屏逐帧看得很清楚:展开那一刻球会从下往上"爬"几级才停 —— 就是用户说的"跳动"。 * 试过的两条补救都不行: * · 先在同一个 JS 调用里把球藏起来再改尺寸 —— 藏只是标记,改尺寸已经发出去了,照样跳; * · 藏起来之后**等这一帧画完**(`onPreDraw`)再改尺寸 —— 合成器有 2~3 帧的缓冲队列, * 它照样能把"藏之前"的那一帧摆到新位置上去(实测仍然跳)。 * * ⇒ 唯一干净的办法是**根本不改窗口几何**: * · **球窗口** 88×88dp:常驻、永远可触摸、尺寸与位置只在拖动时变; * · **扇形窗口** 308×264dp:位置随球算好,**折叠时整窗设成 NOT_TOUCHABLE** * (触摸自动穿透到下面的 App,所以它的透明区不会是死区),展开时才可触摸。 * 于是"展开/收起"只改**显隐**与一个 flag —— 没有任何几何变化,也就没有任何东西会跳。 * 附带好处:折叠态的"死区"从 176×176px 缩到球窗口那么大,引擎要绕开的范围也小了(见 publishRect)。 * * 两个窗口能同时绘制是**实测过的**(`_probe/fb_two_windows.js`:红块与绿块都出现)。 * AutoX 的 `floaty.window` 做不到这一点(第二个建得出来但画不出来),所以这里走裸 WindowManager。 */ FB.ensure = function () { if (FB.ballWin) { FB.sync(); return; } if (!FB.canShow()) { return; } try { FB.restoreAnchor(); /* ① 先建两棵视图树。内边距用视图自己的 padding 复现那圈 12dp, * 于是"球心在根视图里的坐标"这套算法一个字都不用改 */ FB.root = new android.widget.FrameLayout(context); FB.root.setPadding(dp(FB.PAD), dp(FB.PAD), dp(FB.PAD), dp(FB.PAD)); buildBallViews(); FB.fanRoot = new android.widget.FrameLayout(context); FB.fanRoot.setPadding(dp(FB.PAD), dp(FB.PAD), dp(FB.PAD), dp(FB.PAD)); buildFanViews(); /* ② 再加窗口:**扇形先加、球后加**。同类型窗口按加入顺序叠放,后加的在上; * 球必须在最上层 —— 扇形展开时球的矩形整个落在扇形窗口里, * 球若在下层,点它就会被扇形窗口吃掉,**再点一次收不起来**(真机踩过)。 * 两层的子视图不重叠(扇形按钮离球心 96dp,球窗口只有 88dp),所以不会有遮挡问题。 */ var f = fanSizeDp(); FB.fanLp = addOverlay(FB.fanRoot, 0, 0, dp(f.w), dp(f.h), false); var c = collapsedSizeDp(); FB.ballLp = addOverlay(FB.root, 0, 0, dp(c), dp(c), true); FB.ballWin = FB.root; /* 兼容别名:dumpLayout / 自检里用它 */ FB.window = FB.root; positionBallWindow(); positionFanWindow(); FB.open = false; FB.publishRect(); /* 启动就把球位告诉引擎,别让它去点球 */ FB.sync(); attach(); } catch (e) { console.log("[fb] 创建悬浮窗失败: " + e); traceError('ensure', e); FB.destroy(); } }; /** 窗口挂上去之后的收尾(定位、调试监听、留一份布局快照)。 */ function attach() { setTimeout(function () { if (!FB.ballWin) { return; } if (FB.debugEnabled() && FB.root) { FB.root.setOnTouchListener(new android.view.View.OnTouchListener({ onTouch: function (v, event) { trace('root-touch action=' + event.getAction() + ' @' + Math.round(event.getRawX()) + ',' + Math.round(event.getRawY())); return false; } })); } FB.dumpLayout('collapsed'); console.log("[fb] 悬浮球已就绪 side=" + FB.side + " pos=" + FB.anchorX + "," + FB.anchorCy); }, 60); } FB.destroy = function () { FB.stopBreath(); /* 先告诉引擎"球没了"再关窗:反过来的话,关窗与写文件之间那一瞬 * 引擎可能刚好读到旧矩形,于是白白绕开一个已经不存在的球。 */ try { files.write(FB.RECT_PATH, JSON.stringify({ hidden: true, updatedAt: Date.now() })); FB.lastRectKey = ""; } catch (e0) { /* 写不进就算了,引擎读不到会当没有球 */ } try { if (FB.root) { wm().removeView(FB.root); } } catch (e) { console.log("[fb] 移除球窗口失败: " + e); } try { if (FB.fanRoot) { wm().removeView(FB.fanRoot); } } catch (e2) { console.log("[fb] 移除扇形窗口失败: " + e2); } FB.ballWin = null; FB.fanWin = null; FB.window = null; FB.ballLp = null; FB.fanLp = null; FB.root = null; FB.fanRoot = null; FB.open = false; FB.views = {}; FB.firstBtn = null; }; /* ------------------------------------------------------------ 扇形几何 */ /** * 第 index 个按钮相对「球心」的偏移(dp)。 * 球在右侧时整体镜像(x 取负)—— 规格书 §10.11:不镜像整组按钮会出屏。 */ function fanOffset(index) { var rad = FB.ANGLES[index] * Math.PI / 180; var dx = FB.R * Math.cos(rad); var dy = -FB.R * Math.sin(rad); /* 屏幕 y 向下,故取负 sin */ return { x: FB.side === "right" ? -dx : dx, y: dy }; } /* ------------------------------------------------------------ 视图构建(只建一次) */ /** * 品牌图标位图**只解码一次**。 * `images.read()` 要把 412×412 的 PNG 解码成位图,是这一层最贵的一步。 */ FB.brandBitmap = null; function brandBitmap() { if (FB.brandBitmap) { return FB.brandBitmap; } try { var img = images.read(FB.BRAND); if (img) { FB.brandBitmap = img.bitmap; } else { console.log("[fb] 品牌图不存在: " + FB.BRAND); } } catch (e) { console.log("[fb] 读品牌图失败: " + e); } return FB.brandBitmap; } /** * 球窗口里就三件套:呼吸环(Ø60)/ 球体(Ø48)/ 透明热区(Ø64)。 * * 位置**建一次就够**:折叠态与展开态球在根视图里的坐标是同一个 * (球心永远在球窗口的正中),所以没有"换状态要重摆"这回事。 * 顺序:环 → 球 → 热区,热区最后加所以盖在最上层收手势。 */ function buildBallViews() { var root = FB.root; root.removeAllViews(); var ring = U.text("", 1, "#FFFFFF", false); ring.setBackground(U.solidBg(U.alphaOf(Theme.hex.ok, 0.22), Theme.m.pillRadius)); /* 建的时候先藏起来:环的颜色/有无完全由 sync() 按运行态决定。 * 默认可见会让空闲态也挂着一个绿环。 */ ring.setVisibility(android.view.View.GONE); place(ring, (FB.HOT - 60) / 2, (FB.HOT - 60) / 2, 60, 60); root.addView(ring); var ball = new android.widget.ImageView(context); var bmp = brandBitmap(); if (bmp) { ball.setImageBitmap(bmp); } ball.setScaleType(android.widget.ImageView.ScaleType.FIT_XY); ball.setBackground(U.solidBg(U.argb(Theme.hex.bg), Theme.m.pillRadius)); ball.setClipToOutline(true); place(ball, (FB.HOT - FB.DIAM) / 2, (FB.HOT - FB.DIAM) / 2, FB.DIAM, FB.DIAM); root.addView(ball); /* 透明热区:球 Ø48、热区 Ø64 —— §10.1 的"视觉小、触控大" */ var hot = new android.view.View(context); hot.setBackground(U.solidBg(0x00000000, 0)); place(hot, 0, 0, FB.HOT, FB.HOT); hot.setOnTouchListener(new android.view.View.OnTouchListener({ onTouch: function (v, event) { return onTouch(event); } })); root.addView(hot); FB.views.ring = ring; FB.views.ball = ball; FB.views.hot = hot; watchLayout("ball", ball); watchLayout("hot", hot); } /** * 扇形窗口里是 4 个按钮 + 4 个标签,**建一次、位置也只算一次** * (它们只取决于扇形几何与球贴哪一边;换边时由 layoutFanViews() 重摆)。 */ function buildFanViews() { var root = FB.fanRoot; root.removeAllViews(); FB.views.fan = []; FB.firstBtn = null; var specs = fanSpecs(); var i; for (i = 0; i < specs.length; i++) { var spec = specs[i]; var shape = (spec.kind === "primary") ? primaryState() : spec; var danger = !!shape.danger; var btn = U.text(shape.icon, 15, shape.iconHex, false); btn.setGravity(android.view.Gravity.CENTER); btn.setBackground(shape.gradient ? U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius) : (danger ? U.solidBg(U.alphaOf(Theme.hex.danger, shape.solid ? 1 : 0.14), Theme.m.pillRadius) : U.solidBg(U.alphaOf("#FFFFFF", 0.08), Theme.m.pillRadius))); btn.setVisibility(android.view.View.GONE); /* 轨迹里用「标签」而不是 kind:只有第 1 个按钮有 kind, * 其余三个会打成 kind=undefined —— 排障时最需要看清"点中的是哪一个"。 */ var fanTag = (spec.kind === "primary") ? "primary" : String(spec.label); (function (action, tag, idx) { /* 点按钮 → 执行动作。 * ⚠️ try/catch 是必须的:UI 回调里抛错在 AutoX 上**不报错、不弹窗、 * 不结束脚本**,症状就是"点了没反应"。这里把它转成一条可读的轨迹。 */ btn.setOnClickListener(function () { trace('click fan#' + idx + ' ' + tag + ' open=' + FB.open); try { action(); } catch (e) { traceError('扇形按钮#' + idx + '(' + tag + ')', e); } /* 选完就收起:扇形是个**选择器**,点完还摊着,正好挡住用户此刻要看的东西。 * 「停止 / 退出」在 confirmThenAct 里已经收过一次,这里是幂等的。 */ try { FB.collapse(); } catch (e2) { traceError('收起扇形', e2); } }); })(spec.action, fanTag, i); root.addView(btn); var pill = labelPill(shape.label, danger ? Theme.hex.danger : "#FFFFFF", danger ? Theme.hex.danger : "#FFFFFF", danger ? 0.14 : 0.10); pill.setVisibility(android.view.View.GONE); root.addView(pill); FB.views.fan.push({ btn: btn, pill: pill }); if (spec.kind === "primary") { FB.firstBtn = { view: btn, pill: pill }; } } layoutFanViews(); } /** * 摆扇形里那 8 个视图。只在**建视图**与**换了贴边方向**时调用。 * * ★ 必须走 `place()`:它内部统一做 dp→px。这里原先调的是另一个自己写的 pillParams(), * 那里面漏了 dp() —— 于是标签的坐标被当成像素用、全部挤到左上角(按钮是对的)。 */ function layoutFanViews() { var size = fanSizeDp(); var center = ballCenterInRoot(size.w, size.h); var i; for (i = 0; i < FB.views.fan.length; i++) { var off = fanOffset(i); var cx = center.x + off.x; var cy = center.y + off.y; var item = FB.views.fan[i]; place(item.btn, cx - FB.SUB / 2, cy - FB.SUB / 2, FB.SUB, FB.SUB); /* 标签:向右展开时贴按钮右侧,镜像时贴左侧(§10.11) */ var label = ""; try { label = String(item.pill.getText()); } catch (e) { label = ""; } var pw = measurePillWidth(item.pill, label); var px = (FB.side === "right") ? (cx - FB.SUB / 2 - 4 - pw) : (cx + FB.SUB / 2 + 4); place(item.pill, px, cy - FB.LABEL_H / 2, -2, FB.LABEL_H); } } /** * 扇形 8 个视图的显隐。 * 用 **GONE 而不是 INVISIBLE**:INVISIBLE 仍然占着位置、也仍然会吃掉触摸。 */ function setFanVisible(on) { var vis = on ? android.view.View.VISIBLE : android.view.View.GONE; for (var i = 0; i < FB.views.fan.length; i++) { FB.views.fan[i].btn.setVisibility(vis); FB.views.fan[i].pill.setVisibility(vis); } } /** * 扇形**窗口**能不能吃触摸。 * * 折叠时必须设成不可触摸:扇形窗口有一大片透明区,而**窗口的透明区同样会吃掉触摸** * (实测:点它透明区里的输入框,键盘不弹;点窗口外,键盘就弹)。 * 设成 NOT_TOUCHABLE 之后触摸会穿透到下面的 App —— 于是折叠态没有任何死区。 */ function setFanTouchable(on) { if (!FB.fanLp) { return; } FB.fanLp.flags = overlayFlags(on); try { wm().updateViewLayout(FB.fanRoot, FB.fanLp); } catch (e) { traceError('setFanTouchable', e); } } /** 标签宽度:项目里没有测量工具,按字数估算(11px 字 + 两侧 10dp 内边距)。 */ function measurePillWidth(pill, text) { var len = String(text).length; var ascii = 0, wide = 0, j; for (j = 0; j < len; j++) { if (String(text).charCodeAt(j) < 128) { ascii += 1; } else { wide += 1; } } return dp(20) + ascii * dp(7) + wide * dp(11); } /** * 四个按钮的定义(§10.3)。 * 第 1 个随运行态切换:空闲/完成后是「运行」,运行中是「停止」。 */ function fanSpecs() { return [ /* ★ 第 1 个按钮的动作**在点击那一刻现取**当前状态(见 primaryState)。 * * ⚠️ 这里踩过一个"界面全对、日志正常、就是点了没反应"的大坑,务必别再改回去: * 早期用一个可变 holder(`FB.firstAction.fn`)存这个动作,由 `applyFirstButton()` * 在**三态发生变化时**写入。而"展开扇形"这一步通常是 idle → idle —— * 状态根本没变,`sync()` 判定 `changed === false`,于是对**新按钮实例** * 从不调用 `applyFirstButton()`,holder 里始终是最初那个空函数。 * 表现:「运行 / 停止」点了毫无反应,而其余三个按钮(动作内联在下面几行)都正常; * 触摸事件收到了、界面也完全正确 —— 只是回调是个空壳,最难查的一类。 * ⇒ 动作一律**现算**,不存可变引用,"忘记接线"这种可能就根本不存在了。 */ { kind: "primary", action: function () { primaryState().fn(); } }, { icon: "\u25A6", label: "\u56DE\u5230 App", iconHex: "#FFFFFF", action: function () { App.bringToFront(); } }, { icon: "\u2298", label: "\u9690\u85CF\u60AC\u6D6E\u7403", iconHex: "#FFFFFF", action: function () { FB.hide(); } }, { icon: "\u2715", label: "\u9000\u51FA\u5E94\u7528", iconHex: Theme.hex.danger, danger: true, action: function () { askExit(); } } ]; } /** 第 1 个按钮当前的形态(也定义了它现在该做什么)。 */ function primaryState() { var running = !!Session.running; return running ? { icon: "\u25A0", label: "\u505C\u6B62", iconHex: "#FFFFFF", danger: true, solid: true, fn: function () { askStop(); } } : { icon: "\u25B6", label: "\u8FD0\u884C", iconHex: "#FFFFFF", gradient: true, fn: function () { askRun(); } }; } /* --------------------------------------------- 展开 / 收起(只改显隐与一个 flag) */ /** * 展开 / 收起**只做两件事**:扇形视图的显隐 + 扇形窗口的 NOT_TOUCHABLE 开关。 * * 没有尺寸变化、没有位置变化、没有等待、没有动画 —— 所以不存在"跳"的可能。 * 这一点是本模块的核心结论:只要**窗口几何不变**,合成器拿到的每一帧缓冲都画在同一个 * 矩形里,就不可能出现"旧画面被摆到新位置"。详见窗口层那段注释(含两次失败的尝试)。 * * ★ **不做任何过渡动画**(用户 2026-10-14 明确要求去掉)。 * 规格书 §10.1 写了"展开动画 180ms",这里是有意识的偏离:那 180ms 淡入会让 * "展开"在点击后近 0.3 秒才看清,而这个球的用法是"点一下就到位"。 * 将来若要恢复动画,在 `setFanVisible(true)` 之后做透明度渐变 —— ⚠️ 但那时 * **透明度的写者必须只有这一处**,否则会出现"两个动画互相顶掉"的鬼影(不报错、只有肉眼看得见)。 */ FB.expand = function () { if (FB.open || !FB.ballWin) { return; } try { FB.open = true; positionFanWindow(); /* 位置随球算(拖动过就更新一下) */ if (FB.firstBtn) { applyFirstButton(); } /* 第 1 个按钮跟上当前运行态 */ setFanVisible(true); setFanTouchable(true); FB.sync(); /* 三态外环 */ FB.publishRect(); FB.dumpLayout('fan'); /* 切换之后的真实布局,留一份给排障 */ trace("expand 完成"); } catch (error) { /* 展开路径上任何一步抛错都必须留下痕迹:真机上它表现为"点了没反应", * 而 AutoX 的 UI 回调异常不会让脚本退出、也不会弹任何提示。 */ traceError('expand', error); } }; FB.collapse = function () { if (!FB.ballWin || !FB.open) { return; } try { FB.open = false; setFanVisible(false); setFanTouchable(false); /* 整窗不可触摸 → 触摸穿透,不留死区 */ FB.sync(); FB.publishRect(); FB.dumpLayout('collapsed'); trace("collapse 完成"); } catch (error) { traceError('collapse', error); } }; FB.toggleOpen = function () { if (FB.open) { FB.collapse(); } else { FB.expand(); } }; /* ------------------------------------------------------------ 位置计算 */ /** * 由「球锚点」反推某个窗口的左上角(像素)。**两个窗口共用这一处换算。** * * 球心的屏幕绝对坐标必须始终等于锚点: * 球心 = 窗口位置 + 内边距(PAD) + 球心在根视图里的偏移 * 所以 窗口位置 = 锚点 − PAD − 根内偏移。折叠态与展开态的"根内偏移"不同 * (折叠在正中、展开要垂直居中给扇形腾地方),于是两个窗口算出来的位置也不同 —— * 这没关系:**窗口各自建好之后就再也不动了**。 */ function originFor(winWDp, winHDp) { var c = ballCenterInRoot(winWDp, winHDp); return { x: Math.round(FB.anchorX + dp(FB.DIAM) / 2 - dp(FB.PAD) - dp(c.x)), y: Math.round(FB.anchorCy - dp(FB.PAD) - dp(c.y)), w: dp(winWDp), h: dp(winHDp) }; } function moveWindow(lp, view, o) { if (!lp || !view) { return; } lp.x = o.x; lp.y = o.y; lp.width = o.w; lp.height = o.h; try { wm().updateViewLayout(view, lp); } catch (e) { traceError('updateViewLayout', e); } } /** 把球窗口摆到锚点(拖动中与吸附后调用)。尺寸不变,只改位置。 */ function positionBallWindow() { var c = collapsedSizeDp(); moveWindow(FB.ballLp, FB.root, originFor(c, c)); } /** 把扇形窗口摆到锚点(展开前调用;拖动过之后位置要跟上)。 */ function positionFanWindow() { var f = fanSizeDp(); moveWindow(FB.fanLp, FB.fanRoot, originFor(f.w, f.h)); } /** * 把「引擎必须绕开的矩形」写盘(`ball-rect.json`)。 * * 折叠态**只发球窗口**:扇形窗口那时是 NOT_TOUCHABLE,触摸会穿透到下面的 App, * 引擎不必绕开它 —— 这比之前"永远绕开一大块"更准,引擎的点击成功率也更高。 * 展开态发两个窗口的并集。 */ FB.publishRect = function () { var ball = originFor(collapsedSizeDp(), collapsedSizeDp()); var rect; if (FB.open) { var fan = originFor(fanSizeDp().w, fanSizeDp().h); var x0 = Math.min(ball.x, fan.x), y0 = Math.min(ball.y, fan.y); var x1 = Math.max(ball.x + ball.w, fan.x + fan.w); var y1 = Math.max(ball.y + ball.h, fan.y + fan.h); rect = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; } else { rect = { x: ball.x, y: ball.y, w: ball.w, h: ball.h }; } rect.open = !!FB.open; rect.updatedAt = Date.now(); var key = [rect.x, rect.y, rect.w, rect.h, rect.open ? 1 : 0].join(","); if (key === FB.lastRectKey) { return; } /* 只在真的变了才写盘 */ FB.lastRectKey = key; try { files.write(FB.RECT_PATH, JSON.stringify(rect)); } catch (e) { console.log("[fb] 发布球位失败: " + e); } }; /* ------------------------------------------------------------ 手势:单击 / 拖动 */ var touchState = { x: 0, y: 0, ax: 0, ay: 0, moved: false, downAt: 0 }; function onTouch(event) { var action = event.getAction(); var rawX = event.getRawX(), rawY = event.getRawY(); /* 触摸事件日志:**默认关闭**。 * * 球的手势属于"点不出来就完全没线索"的那类问题,所以留了这条日志 —— * 但每次触摸都往盘上追加是白白 IO,于是用「调试开关文件」控制: * `adb shell touch /sdcard/tiktok_app/ball-debug` 打开,删掉即关闭。 * 排查这类问题时它是救命的(CANCEL 与 UP 混在一起那次就是靠它定位的)。 */ if (FB.debugEnabled()) { logTouch(action, rawX, rawY); } if (action === android.view.MotionEvent.ACTION_DOWN) { touchState.x = rawX; touchState.y = rawY; touchState.ax = FB.anchorX; touchState.ay = FB.anchorCy; touchState.moved = false; touchState.downAt = Date.now(); return true; } if (action === android.view.MotionEvent.ACTION_MOVE) { var dx = rawX - touchState.x; var dy = rawY - touchState.y; if (!touchState.moved && (Math.abs(dx) > dp(FB.SLOP) || Math.abs(dy) > dp(FB.SLOP))) { touchState.moved = true; FB.dragging = true; /* 拖动时先收起:展开态的窗口很大,拖着走会挡住半个屏幕 */ if (FB.open) { FB.collapse(true); } } if (touchState.moved) { FB.anchorX = touchState.ax + dx; FB.anchorCy = touchState.ay + dy; FB.clampAnchor(); positionBallWindow(); } return true; } if (action === android.view.MotionEvent.ACTION_CANCEL) { /* ★ CANCEL **不是** UP,不能当点击处理 —— 这个坑很隐蔽: * `setSize()` 改窗口尺寸会让 WindowManager 把正在进行的这条触摸流 * 判为取消,于是每次展开都会紧跟一个 CANCEL。 * 早期版本把 CANCEL 与 UP 一视同仁,结果就是「展开 → 立刻又收起」, * 用户看到的是"点了完全没反应",而日志里一切正常(事件都收到了)。 * 取消时只复位状态,不做任何动作。 */ FB.dragging = false; touchState.moved = false; return true; } if (action === android.view.MotionEvent.ACTION_UP) { FB.dragging = false; var moved = touchState.moved; touchState.moved = false; if (moved) { FB.snapToEdge(); } else { FB.toggleOpen(); } return true; } return false; } /** 松手吸附最近边缘并记忆(§10.5)。 */ FB.snapToEdge = function () { var half = screenW() / 2; FB.side = (FB.anchorX + dp(FB.DIAM) / 2 < half) ? "left" : "right"; FB.anchorX = (FB.side === "right") ? screenW() - dp(FB.EDGE) - dp(FB.DIAM) : dp(FB.EDGE); FB.clampAnchor(); FB.savePosition(); positionBallWindow(); positionFanWindow(); /* 位置随球算,早点跟上 */ layoutFanViews(); /* 贴边方向变了 → 扇形要重新镜像,标签也得换到另一侧 */ FB.sync(); FB.publishRect(); }; /* ------------------------------------------------------------ 状态同步(三态外环) */ /** * 把球刷成当前运行状态(§10.4): * 空闲 → 无外环;运行中 → 青绿 22% + 呼吸;报错 → 玫瑰 20% 静态。 * 球内**永远**是品牌图标(不随状态换),状态只靠环表达。 */ FB.sync = function () { if (!FB.window) { return; } /* 状态切换期间不改环:此刻它还在展开态的位置上,而 sync() 会把环设成 VISIBLE * (运行中),于是切换的空档里会冒出一个"位置不对的绿环"。等落地后再刷。 */ var ring = FB.views.ring; if (!ring) { return; } var tone = "idle"; if (App.failBar) { tone = "error"; } else if (Session.running) { tone = "running"; } if (tone === "running") { ring.setBackground(U.solidBg(U.alphaOf(Theme.hex.ok, 0.22), Theme.m.pillRadius)); ring.setVisibility(android.view.View.VISIBLE); FB.startBreath(); } else if (tone === "error") { FB.stopBreath(); ring.setBackground(U.solidBg(U.alphaOf(Theme.hex.danger, 0.20), Theme.m.pillRadius)); ring.setVisibility(android.view.View.VISIBLE); } else { FB.stopBreath(); ring.setVisibility(android.view.View.GONE); } /* 扇形开着时,第 1 个按钮要跟着状态变(运行 ↔ 停止)。 * * ★ 这里是**就地改那一个按钮**,不重建扇形 —— 真机上踩过两次: * ① 无条件重建:本函数每秒被 tickTimer 调一次,用户点下去那一刻正好被重建掉, * 点击就丢了("偶尔点不动",极难查); * ② 只在状态变化时重建:仍然会与旧视图叠在一起(建到一半就与残留的叠着), * 真机上表现为「运行」和「停止」两个标签同时出现、位置还错位。 */ var changed = (tone !== FB.lastTone); FB.lastTone = tone; if (FB.open && changed && FB.firstBtn) { applyFirstButton(); } }; /** * 就地更新扇形第 1 个按钮(图标 / 文字 / 底色)。 * * ★ 它**只改外观,不改行为** —— 行为由 `primaryState()` 在点击时现算 * (曾经在这里写 `FB.firstAction.fn = shape.fn`,造成"状态没变就永远没接线"的空回调, * 详见 `fanSpecs()` 上的注释)。 */ function applyFirstButton() { var shape = primaryState(); var view = FB.firstBtn.view; view.setText(shape.icon); view.setBackground(shape.gradient ? U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius) : U.solidBg(U.alphaOf(Theme.hex.danger, shape.solid ? 1 : 0.14), Theme.m.pillRadius)); var pill = FB.firstBtn.pill; pill.setText(shape.label); var danger = !!shape.danger; pill.setTextColor(U.argb(danger ? Theme.hex.danger : "#FFFFFF")); pill.setBackground(U.solidBg(U.alphaOf(danger ? Theme.hex.danger : "#FFFFFF", danger ? 0.14 : 0.10), Theme.m.pillRadius)); /* 就地改完再落一份布局快照 —— 否则 ball-layout.txt 永远停在"展开那一刻", * 事后回看会以为按钮一直是「运行」(实际它已经变成「停止」,只是没重新 dump)。 */ } /** * 呼吸动效:外环在 22% ↔ 6% 之间循环。 * 用 setInterval 而不是 ValueAnimator —— 少引一层 Java 接口包装, * 而且这里只需要"看起来在呼吸",不需要精确的插值曲线。 */ FB.startBreath = function () { if (FB.breathTimer) { return; } FB.breathTimer = setInterval(function () { if (!FB.window || !FB.views.ring) { FB.stopBreath(); return; } FB.breathOn = !FB.breathOn; FB.views.ring.setBackground(U.solidBg( U.alphaOf(Theme.hex.ok, FB.breathOn ? 0.22 : 0.06), Theme.m.pillRadius)); }, FB.BREATH_MS); }; FB.stopBreath = function () { if (FB.breathTimer) { clearInterval(FB.breathTimer); FB.breathTimer = null; } }; /** * 呼吸环是否正在跑。 * * 存在的理由:它占一个 `setInterval`,而自检里「运行计时器该起 / 该停」是按 * interval 总数断言的 —— 球一被建出来(就是它在说"正在运行"),总数就会多一个。 * 有了这个查询,断言可以写成「总数 − 呼吸环 = 运行计时器」, * 而不是把期望值改成 2 这种"把噪声当基准"的写法。 */ FB.breathing = function () { return !!FB.breathTimer; }; /* ------------------------------------------------------------ 四个按钮的动作 */ /** ① 运行:走与 App 内完全相同的校验链(悬浮球不给自己开后门)。 */ function askRun() { var taskKey = App.detail || Session.tab; if (!taskKey) { /* 没有"当前任务"时(停在任务列表):回 App 让用户自己挑一个, * 比在这里猜一个任务跑起来安全得多(规格书 §2.5 的"已选脚本"校验同理)。 */ toast("\u8BF7\u5148\u5728\u4EFB\u52A1\u5217\u8868\u9009\u4E00\u4E2A\u811A\u672C"); App.bringToFront(); return; } App.requestRun(taskKey); } /* ------------------------------------------------------------ 二次确认(H5 / H6) */ /** * ⚠️ 这里做了一处**有意识的偏离**,原因写清楚免得后来者白折腾: * * 规格书 §10.6 把 H5/H6 画成悬浮球自己的弹窗,我也照做了 —— 用第二个 floaty 窗 * 承载遮罩 + 卡片。真机上它**建得出来但画不出来**:同屏已经有一个浮窗(球)时, * 再开一个全屏浮窗只剩 AutoX 的调整手柄小点,遮罩与卡片都不显示(试过补 * `setSize`/`setAdjustEnabled(false)`/延后 150ms 创建,都没解决)。 * * 「停止」是球上最要紧的一个键,**绝不能出现点了没反应**。所以改成确定可行的路径: * 点停止/退出 → 把 App 切到前台 → 用 App 里那套**已经在真机验证过的**弹窗确认。 * 代价是多一次"App 跳到前台",换来的是"这个键一定能用"。 * * 后续若要回到规格书的浮窗形态:要么查清双浮窗的绘制问题(大概率与 AutoX 的 * 窗口尺寸协商有关),要么把确认做成**在扇形内联**(按钮变成"确认停止?"), * 后者完全不需要第二个窗口,其实更稳。 */ function confirmThenAct(opts) { trace('confirmThenAct 进入'); FB.collapse(true); /* 先收起扇形:确认期间不该留着一排按钮 */ setTimeout(function () { /* 用户必须看得见确认弹窗,而弹窗画在 App 窗口里。 * `bringToFront` 失败时 App 不在前台 → 弹窗建得出来但没人看得见 * (外部表现为"点了停止,什么都没发生")。所以把成败记进轨迹。 */ var ok = false; try { ok = App.bringToFront(); } catch (e) { traceError('bringToFront', e); } trace('confirmThenAct bringToFront=' + ok); try { opts.dialog(); trace('confirmThenAct 弹窗已弹出'); } catch (e2) { traceError('confirmThenAct 弹窗', e2); } }, 150); } /** ② 停止:H5 二次确认(规格书 §10.6,停止必须确认、运行不用)。 */ function askStop() { var name = Session.scriptName || Pages.taskName(Session.tab); var elapsed = Records.formatDuration(Session.elapsedMs()); confirmThenAct({ dialog: function () { Dialog.stopScript(name, elapsed, function () { App.stopRun(); App.renderScreen(); }); } }); } /** ③ 退出应用:H6 二次确认(§10.7:停止脚本 + 关闭悬浮球 + 退出到桌面)。 */ function askExit() { confirmThenAct({ dialog: function () { Dialog.exitApp(function () { if (Session.running) { App.stopRun(); } FB.destroy(); App.exit(); }); } }); } /** * 隐藏悬浮球(§10.3 第 3 项)。 * * 规格书没写"隐藏之后怎么再显示"—— 没有恢复入口的话用户就再也找不回来了。 * 所以这里落盘 `ball.hidden=true`,并在「我的」页给一个开关把它打开(本项目补充)。 */ FB.hide = function () { Store.set("ball.hidden", true); FB.destroy(); toast("\u5DF2\u9690\u85CF\u60AC\u6D6E\u7403\uFF08\u53EF\u5728\u300C\u6211\u7684\u300D\u91CC\u91CD\u65B0\u6253\u5F00\uFF09"); }; /* ------------------------------------------------- hidden 开关的双向同步 */ /** 「我的」页的开关调它:hidden=true 时立刻关掉球。 */ FB.setHidden = function (hidden) { Store.set("ball.hidden", !!hidden); if (hidden) { FB.destroy(); } else { FB.ensure(); } }; /* ============================== src/page-id.js ============================== */ /* ============================== src/page-id.js ============================== * ID提取页 —— **三段式**(v8 / 2026-10-17)。 * * ① 取材 从哪儿找人 —— 可累加的取材清单(每条自带起点与深度) * ② 只要这样的账号 —— 过滤条件(所有取材共用) * ③ 输出 上限 / 去重 / 文件 * * ## 为什么不再是"四张脚本卡选一个" * v7 及以前是四张卡单选。那四张卡其实是「起点(人从哪来)」×「深度(要不要再展开 * 一层)」的 4 个**不完整组合**:各缺一块、各有别人没有的一块,合起来才完整。 * 于是「同一时间仅可运行一个脚本」这条约束本身就在跟用户较劲 —— * 想同时抓"关键词视频的作者"和"文本里这批 uid 的粉丝"时,它直接不允许。 * * 现在的模型: * · **五个起点**(指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕) * · **一个深度选项**(不展开 / 再取粉丝 / 再取关注),最多 2 层 * · 取材可加多条,**按顺序串行执行**(真机只有一个前台,没有并发可言), * 共用同一份去重集合与同一个输出文件 * * ## 每个控件都有唯一含义 * 选了什么起点才出现什么输入 —— 不会再有"勾上却无所指"的开关。 * 这是 v7 那次"清语义"留下来的规矩,v8 继续守。 */ var Pages = Pages || {}; /** * 各页脚本清单的集中注册表(main.js 用 key → 中文名翻译)。 * ID提取已经没有"脚本"这个概念,但仍然注册一个占位条目: * 导出配置、运行日志、拦截提示里都会把 key 翻成中文名, * 让那套通用机制不必为 id 开特例。 */ Pages.SCRIPTS_OF = {}; Pages.registerScripts = function (tab, list) { Pages.SCRIPTS_OF[tab] = list; }; /* ★ 引擎声明:`engine` 一填,运行前置校验的第 4 步才会放行。 * 没有它时点「开始运行」会被 Dialog.engineMissing 拦下 —— 这是有意的设计: * **绝不静默跑一个不相关的东西**。ID 提取的引擎侧(task = 'id')已接入。 */ Pages.ID_SCRIPTS = [{ key: "id", name: "ID提取", engine: "tiktok-auto" }]; Pages.registerScripts("id", Pages.ID_SCRIPTS); /* ============================================================ ① 取材 */ /** 候选文案数组(下拉要的是纯文案,顺序与 Sources 里的表一致)。 */ function labelsOf(table) { var out = []; for (var i = 0; i < table.length; i++) { out.push(table[i].label); } return out; } /** 取某个表里 key 的下标(选了之后要把文案换掉,回调给的是下标)。 */ function indexOfKey(table, key) { for (var i = 0; i < table.length; i++) { if (table[i].key === key) { return i; } } return 0; } /** * 切换"取哪些列表"里的某一项。 * * **最后一个不许取消**:三个都取消,这条取材就没有任何来源可抓 —— * 它会变成一条永远跑不出结果的取材,而用户看不出为什么。 */ function toggleListOf(itemId, key, next) { var cur = Sources.find(itemId); if (!cur) { return; } var list = cur.lists.slice(); var at = list.indexOf(key); if (next && at < 0) { list.push(key); } else if (!next && at >= 0) { if (list.length === 1) { Dialog.alert("至少要留一个列表", "这条取材要取哪个列表?\n\n三个都取消的话,它就没有任何来源可抓了。"); Pages.requestRerender(); return; } list.splice(at, 1); } else { return; /* 状态没变,不必写盘也不必重建 */ } Sources.update(itemId, { lists: list }); Pages.requestRerender(); } /** 「取哪些列表」多选行。 */ function buildListPicker(item) { var row = U.row(); for (var i = 0; i < Sources.LISTS.length; i++) { (function (entry, at) { var on = item.lists.indexOf(entry.key) >= 0; var box = W.checkRow(entry.label, on, function (next) { toggleListOf(item.id, entry.key, next); }); if (at > 0) { row.addView(U.hgap(10)); } row.addView(box.root); })(Sources.LISTS[i], i); } return row; } /** 起点下拉(选完要重排输入行,所以必须重建整页)。 */ function buildKindChooser(item) { return W.chooser({ label: Sources.kindLabel(item.kind), options: labelsOf(Sources.KINDS), index: function () { var cur = Sources.find(item.id); return indexOfKey(Sources.KINDS, cur ? cur.kind : "anchor"); }, onPick: function (i) { /* 换起点 → normalize 会把旧起点特有的参数丢掉(见 Sources.normalize 的第 ① 条) */ Sources.update(item.id, { kind: Sources.KINDS[i].key }); Pages.requestRerender(); }, minWidthDp: 110 }); } /** 深度下拉。`screen` 起点已经是最底层,不给这个选项。 */ function buildExpandChooser(item) { if (!Sources.canExpand(item.kind)) { return null; } return W.chooser({ label: Sources.expandLabel(item.expand), options: labelsOf(Sources.EXPANDS), index: function () { var cur = Sources.find(item.id); return indexOfKey(Sources.EXPANDS, cur ? cur.expand : ""); }, onPick: function (i) { Sources.update(item.id, { expand: Sources.EXPANDS[i].key }); Pages.requestRerender(); }, minWidthDp: 104 }); } /** 一个绑到取材字段的输入框(不是 Store 字段,所以用 read/write 而不是 path)。 */ function sourceField(item, key, placeholder, widthDp) { return W.inlineField({ read: function () { var cur = Sources.find(item.id); return cur ? cur[key] : ""; }, write: function (value) { var patch = {}; patch[key] = value; Sources.update(item.id, patch); }, placeholder: placeholder, widthDp: widthDp }); } /** 一条取材的输入区 —— **按起点分派**,选了什么才出现什么。 */ function buildSourceInputs(item) { var rows = []; /* —— 指定账号 —— */ if (item.kind === "anchor") { rows.push(W.settingRow("账号", sourceField(item, "anchors", "账号1#账号2#账号3", 148).root, 8)); rows.push(W.settingRow("取哪些列表", null, 0)); rows.push(buildListPicker(item)); rows.push(W.hint("账号留空 = 从当前屏幕上的列表直接抓", false)); } /* —— 搜索结果 —— */ if (item.kind === "search") { rows.push(W.settingRow("搜什么", W.chooser({ label: item.searchType === "videos" ? "视频作者" : "用户", options: labelsOf(Sources.SEARCH_TYPES), index: function () { var cur = Sources.find(item.id); return indexOfKey(Sources.SEARCH_TYPES, cur ? cur.searchType : "users"); }, onPick: function (i) { Sources.update(item.id, { searchType: Sources.SEARCH_TYPES[i].key }); Pages.requestRerender(); }, minWidthDp: 96 }).root, 8)); rows.push(W.settingRow("关键词", sourceField(item, "keyword", "关键词", 148).root, 8)); } /* —— 某个视频的评论区 —— */ if (item.kind === "video") { rows.push(W.settingRow("视频从哪来", W.chooser({ label: item.videoSource === "keyword" ? "关键词搜索" : "当前屏幕", options: labelsOf(Sources.VIDEO_SOURCES), index: function () { var cur = Sources.find(item.id); return indexOfKey(Sources.VIDEO_SOURCES, cur ? cur.videoSource : "screen"); }, onPick: function (i) { Sources.update(item.id, { videoSource: Sources.VIDEO_SOURCES[i].key }); Pages.requestRerender(); }, minWidthDp: 104 }).root, 8)); if (item.videoSource === "keyword") { rows.push(W.settingRow("搜索关键词", sourceField(item, "videoKeyword", "关键词", 148).root, 8)); } else { rows.push(W.hint("当前屏幕 = 用你此刻停在的那条视频的评论区", false)); } } /* —— 文本文件 —— */ if (item.kind === "text") { var field = sourceField(item, "path", "文本路径", 108); var pathRow = U.row(); pathRow.addView(U.text("文件", Theme.m.labelSize, Theme.hex.textSecondary, false)); pathRow.addView(U.flexible()); pathRow.addView(field.root); pathRow.addView(U.hgap(6)); /* storePath 传 null:只让选择器回调路径,不写 Store(取材字段不在字段表里) */ pathRow.addView(W.filePickerButton(null, function (picked) { Sources.update(item.id, { path: picked }); field.paint(); }).root); rows.push(pathRow); rows.push(W.hint("一行一个 UID;空行与 # 开头的行会被跳过", false)); } /* —— 当前屏幕 —— */ if (item.kind === "screen") { rows.push(W.hint("用你此刻停在的那个列表(粉丝页 / 关注页 / 评论区都行)", false)); } /* —— 深度(所有起点共用,screen 除外) —— */ var expand = buildExpandChooser(item); if (expand) { rows.push(W.settingRow("深度", expand.root, 8)); } return rows; } /** 一条取材卡片。 */ function buildSourceCard(item, index) { var card = W.card(false); var head = U.row(); head.addView(U.text("第 " + (index + 1) + " 条", Theme.m.labelSize, Theme.hex.textPrimary, true)); head.addView(U.flexible()); var remove = W.smallButton("删除", "danger", function () { Sources.removeAt(index); Pages.requestRerender(); }); head.addView(remove.root); var rows = [head]; rows.push(U.spacer(4)); rows.push(W.settingRow("起点", buildKindChooser(item).root, 8)); var inputs = buildSourceInputs(item); for (var i = 0; i < inputs.length; i++) { rows.push(inputs[i]); } W.stack(card, rows, 10); return card; } /** 一条取材都没有时的占位 —— 不用 `W.emptyState`(它靠 weight 撑满整屏,在滚动页里不成立)。 */ function buildSourcesEmpty() { var box = U.column(); box.setBackground(U.strokeBg(0.03, 0.10, "#FFFFFF", Theme.m.cardRadius)); box.setPadding(U.dp(16), U.dp(22), U.dp(16), U.dp(22)); var head = U.text("还没有取材", 13, Theme.hex.textSecondary, true); head.setGravity(android.view.Gravity.CENTER); box.addView(head); box.addView(U.spacer(6)); var sub = U.text("点下面的「添加一条取材」,选从哪儿找人", Theme.m.hintSize, Theme.hex.textFaint, false); sub.setGravity(android.view.Gravity.CENTER); box.addView(sub); return box; } /* ============================================== ② 只要这样的账号 */ /** 卡内分组标题(与 v7 一致:比 sectionTitle 轻一档)。 */ function featureGroup(title) { var view = U.text(title, 11, Theme.hex.textMuted, true); view.setPadding(0, U.dp(8), 0, 0); return view; } /** * ② 过滤:所有取材共用。 * * 这里**一项都不删**(v7 的成果):私密 / 无头像 / 语言 / 取样范围 / 四组数量门槛。 * 只有「提取区域」「AI 画像识别」两项当前没有承载者 → 灰行 + 暂不支持 * (不给一个能开却什么也不会发生的开关)。 */ function buildAccountFilters() { var card = W.card(false); var base = "tabs.id.features."; var blocks = []; blocks.push(featureGroup("账号特征")); var privateRow = W.checkRow("过滤私密账户", Store.getBool(base + "filterPrivate"), function (on) { Store.set(base + "filterPrivate", on); }); var noAvatarRow = W.checkRow("过滤无头像账户", Store.getBool(base + "filterNoAvatar"), function (on) { Store.set(base + "filterNoAvatar", on); }); var filterLine = U.row(); U.params(privateRow.root, 0, -2, 1); U.params(noAvatarRow.root, 0, -2, 1); filterLine.addView(privateRow.root); filterLine.addView(noAvatarRow.root); blocks.push(filterLine); /* 语言区域 + 取样范围:后者是前者的**取样字段**,不是独立的过滤条件 */ var langRow = U.row(); langRow.addView(U.text("语言区域", Theme.m.labelSize, Theme.hex.textSecondary, false)); langRow.addView(U.hgap(8)); langRow.addView(W.dropdown("langRegion", base + "langRegion").root); langRow.addView(U.flexible()); langRow.addView(U.text("取样范围", Theme.m.labelSize, Theme.hex.textSecondary, false)); langRow.addView(U.hgap(8)); langRow.addView(W.dropdown("nicknameCheck", base + "nicknameCheck").root); blocks.push(langRow); blocks.push(W.mutedRow("提取区域", Store.getText(base + "regionCodes"), function () { Dialog.alert("提取区域 · 暂不支持", "TikTok 的用户页**不显示账号所属国家**,没有可靠的地方能读到它," + "所以这一项目前不会生效。\n\n" + "你填的国家简码(" + Store.getText(base + "regionCodes") + ")已经保留," + "将来接入地区数据源后会直接生效,不需要重新填。\n\n" + "当前版本的过滤只有:私密、无头像、语言,以及下面的四组数量门槛。"); }).root); blocks.push(W.mutedRow("AI 画像识别", "头像 · 性别 · 年龄", function () { Dialog.alert("AI 画像识别 · 暂不支持", "AI 头像识别 / AI 性别 / AI 年龄需要人脸与属性模型(本地模型或云端接口)," + "当前版本没有承载者。\n\n" + "这三项原来的设置值都已保留,将来接入后直接生效。\n\n" + "把关掉的道理和「提取区域」一样:与其给一个开了也不发生任何事的开关," + "不如明确告诉你它现在不参与。"); }).root); blocks.push(featureGroup("数值门槛")); var metrics = [ { key: "follow", label: "关注量" }, { key: "fans", label: "粉丝量" }, { key: "likes", label: "获赞量" }, { key: "videos", label: "视频量" } ]; for (var i = 0; i < metrics.length; i++) { var m = metrics[i]; blocks.push(W.rangeRow(base + m.key + "Min", base + m.key + "Max", m.label, "~").root); } W.stack(card, blocks, 12); return card; } /* ============================================== ③ 输出 */ function buildOutput() { var card = W.card(false); var base = "tabs.id.features."; var blocks = []; var localBox = W.checkRow("本地去重", Store.getBool(base + "dedupeLocal"), function (on) { Store.set(base + "dedupeLocal", on); }); var historyBox = W.checkRow("跨轮去重", Store.getBool(base + "dedupeHistory"), function (on) { Store.set(base + "dedupeHistory", on); Pages.requestRerender(); /* 打开后会多一行说明 */ }); var dedupeRow = U.row(); U.params(localBox.root, 0, -2, 1); U.params(historyBox.root, 0, -2, 1); dedupeRow.addView(localBox.root); dedupeRow.addView(historyBox.root); blocks.push(dedupeRow); if (Store.getBool(base + "dedupeHistory")) { blocks.push(W.hint("跨轮 = 与历史已提取的 ID 比对,重复的跳过", false)); } /* 云端去重:勾选后才出现表名与连通测试 */ var cloudBox = W.checkRow("云端去重", Store.getBool(base + "cloudDedupe"), function (on) { Store.set(base + "cloudDedupe", on); Pages.requestRerender(); }); if (Store.getBool(base + "cloudDedupe")) { var cloudRow = U.row(); cloudRow.addView(cloudBox.root); cloudRow.addView(U.flexible()); cloudRow.addView(W.textField(base + "cloudTable", "表名:字符", 100).root); cloudRow.addView(U.hgap(6)); var testBtn = W.chip("连通测试", "accent"); testBtn.root.setOnClickListener(function () { var table = Store.getText(base + "cloudTable"); Dialog.alert("连通测试", table === "" ? "请先填写表名。" : "表名:" + table + "\n\n当前未配置云端去重服务地址,无法真实测试。"); }); cloudRow.addView(testBtn.root); blocks.push(cloudRow); } else { blocks.push(cloudBox.root); } /* 提取上限:0 的语义写在文案里 —— 只说"0",用户会以为是"提取 0 个" */ var stopRow = U.row(); stopRow.addView(U.text("提取满", Theme.m.labelSize, Theme.hex.textSecondary, false)); stopRow.addView(U.flexible()); stopRow.addView(W.numberField(base + "extractLimit", "提取符合").root); stopRow.addView(U.hgap(6)); stopRow.addView(U.text("个 ID 后停止(0 = 不限)", Theme.m.labelSize, Theme.hex.textMuted, false)); blocks.push(stopRow); /* 运行时长:它现在是**下限**而不是上限 —— 引擎会按提取上限自动放宽 * (每个收录约 20 秒,见 tiktok-auto/src/idflow.js 的 budgetFor)。 * 设 100 个却只给 30 分钟,会永远抓不满;所以文案必须说清"会自动放宽"。 */ var runtimeRow = U.row(); runtimeRow.addView(U.text("至少运行", Theme.m.labelSize, Theme.hex.textSecondary, false)); runtimeRow.addView(U.flexible()); runtimeRow.addView(W.numberField(base + "maxRuntimeMin", "30").root); runtimeRow.addView(U.hgap(6)); runtimeRow.addView(U.text("分钟(按提取上限自动放宽,最多 4 小时)", Theme.m.labelSize, Theme.hex.textMuted, false)); blocks.push(runtimeRow); /* 数字 uid:默认开。它要额外下一遍用户主页(约 2.5 秒/个), * 只要 handle 的场景可以关掉省时间 —— 以前没有开关,想关只能改配置。 */ var resolveBox = W.checkRow("同时解析数字 uid", Store.getBool(base + "resolveUid"), function (on) { Store.set(base + "resolveUid", on); }); blocks.push(resolveBox.root); if (Store.getBool(base + "resolveUid")) { blocks.push(W.hint("抓完再逐个换成 TikTok 内部的数字 uid,另存一份 uid_map 文件;每个约多花 2~3 秒", false)); } blocks.push(W.hint("结果文件自动命名 uid_日期_时分.txt,存放在 /sdcard/Download/tiktok_app", false)); W.stack(card, blocks, 12); return card; } /* ============================================== ⓪ 断点提示条 */ /** * 「上次没跑完」的提示条。三种状态各自一套文案与动作: * * | 状态 | 标题 | 动作 | * |---|---|---| * | 可续跑 | 上次运行没跑完(已收 N 个) | 继续 / 清空 | * | 配置变了 | 上次运行无法继续 | 清空断点 | * | 结果文件没了 | 上次运行无法继续 | 清空断点 | * * 后两种只给「清空断点」——没有别的能做的,说清原因比给一个点了没用的按钮强。 */ function buildResumeNotice(snap) { if (snap.valid) { return W.noticeBar({ title: "上次运行没跑完(已收 " + snap.extracted + " 个)", detail: Resume.detail(snap), tone: "accent", actions: [ { label: "继续", tone: "accent", onClick: function () { App.resumeRun(); } }, { label: "清空", tone: "mute", onClick: function () { App.clearCheckpoint(); } } ] }).root; } return W.noticeBar({ title: "上次运行无法继续", detail: snap.message || "断点已失效", tone: "warn", actions: [ { label: "清空断点", tone: "mute", onClick: function () { App.clearCheckpoint(); } } ] }).root; } /* ============================================== 页面 */ Pages.id = function (rerender) { var inner = U.column(); var blocks = []; /* ——— ⓪ 断点提示(有未跑完的才出现)——— * 放在最上面:用户进这个页最想知道的就是"我上次那轮还没跑完"。 * 只在没有运行中时出现 —— 跑着的时候点"继续"只会撞上"已有任务在跑"。 * snapshot 一次读盘,不在下面各处重复读。 */ var snap = Resume.snapshot(); if (!Session.running && snap.exists && !snap.finished && snap.reason !== 'cleared') { blocks.push(buildResumeNotice(snap)); blocks.push(U.spacer(Theme.m.gapCard)); } /* ——— ① 取材 ——— */ blocks.push(W.sectionTitle("取材")); blocks.push(W.hint("从哪儿找人。可添加多条,按顺序执行;共用同一份去重与输出", false)); var list = Sources.list(); if (list.length === 0) { blocks.push(buildSourcesEmpty()); } else { for (var i = 0; i < list.length; i++) { blocks.push(buildSourceCard(list[i], i)); } } if (list.length < Sources.MAX) { var add = W.ghostButton("+ 添加一条取材", 44, function () { if (Sources.count() >= Sources.MAX) { return; } Sources.add("anchor"); Pages.requestRerender(); }); blocks.push(add.root); } else { blocks.push(W.hint("已达上限 " + Sources.MAX + " 条 —— 想加新的先删一条", false)); } /* ——— ② 只要这样的账号 ——— */ blocks.push(U.spacer(4)); blocks.push(W.sectionTitle("只要这样的账号")); blocks.push(buildAccountFilters()); /* ——— ③ 输出 ——— */ blocks.push(U.spacer(4)); blocks.push(W.sectionTitle("输出")); blocks.push(buildOutput()); /* 运行中的约束提示留在页尾:它约束的是整页,而它只在运行中出现 */ if (Session.running) { blocks.push(U.spacer(4)); blocks.push(W.hint("运行中不可修改取材 · 请保持无障碍权限开启", true)); } W.stack(inner, blocks, Theme.m.gapPage); var scroller = W.scroller(); scroller.addView(inner); return scroller; }; /* ============================== src/page-dm.js ============================== */ /* ============================== src/page-dm.js ============================== * UID私信页(设计稿 3:534 与展开态 7:11)。 * * 结构:脚本选择(2 张卡)→ 功能设置 → 私信 / 评论设置。 * * ⚠️ 设计稿两版的功能设置卡内容不一致: * 3:579(折叠态页面):AI 性别 / 关注上限 / 私信上限 / 休息间隔 * 7:79 (展开态页面):进行关注私信 / 关注上限 / 私信上限 / 去重方式 * 逐版核对过:**「关注上限」与「私信上限」两版都是分开的两行**, * 差异只在「有没有那个进行关注/私信的合并开关」。这里两张卡的设置都保留。 */ var Pages = Pages || {}; Pages.DM_SCRIPTS = [ { key: "s1", name: "脚本一 \u00B7 已提 UID 私信", engine: "tiktok-auto" }, { key: "s2", name: "脚本二 \u00B7 自己粉丝私信", engine: "tiktok-auto" } ]; Pages.registerScripts("dm", Pages.DM_SCRIPTS); /** * 脚本二展开后的说明。 * * 它**没有自己的配置项**:取材就发生在「自己的粉丝列表」里, * 要多少人由共用参数决定(关注数量 + 私信数量)。所以这里只解释规则, * 不放任何控件 —— 放一个"取材上限"之类的假选项,反而会让人以为能调。 */ function buildDmScript2Detail() { var detail = U.column(); var rows = [ W.hint("\u4ECE\u4F60\u81EA\u5DF1\u7684\u7C89\u4E1D\u5217\u8868\u53D6\u4EBA\uFF1A" + "\u5148\u53D6\u300C\u5173\u6CE8\u6570\u91CF\u300D\u4E2A\u56DE\u5173\uFF0C" + "\u518D\u53D6\u300C\u79C1\u4FE1\u6570\u91CF\u300D\u4E2A\u53D1\u79C1\u4FE1\u3002", false), W.hint("\u4E24\u6279\u4E0D\u91CD\u53E0\uFF0C\u6240\u4EE5\u7C89\u4E1D\u6570\u8981\u4E0D\u5C11\u4E8E\u4E24\u8005\u4E4B\u548C\uFF1B" + "\u4E0D\u591F\u65F6\u6309\u5B9E\u9645\u6570\u91CF\u6267\u884C\uFF0C\u5E76\u5728\u7ED3\u679C\u91CC\u8BF4\u660E\u3002", false) ]; W.stack(detail, rows, 8); return detail; } /** 脚本一展开后的四行:按什么条件筛、筛完做什么、从哪儿读 UID。 */ function buildDmScript1Detail(rerender) { var base = "tabs.dm.scripts.s1."; var detail = U.column(); var rows = []; /* UID 私信:勾选后对筛出的 UID 逐个发私信 */ var doDmBox = W.checkbox(Store.get(base + "doDm", true), 16); doDmBox.root.setOnClickListener(function () { var next = !doDmBox.isChecked(); doDmBox.set(next); Store.set(base + "doDm", next); }); var doDmLabel = U.text("UID \u79C1\u4FE1", Theme.m.labelSize, Theme.hex.textSecondary, false); var doDmRow = U.row(); doDmRow.addView(doDmBox.root); doDmRow.addView(U.hgap(8)); doDmRow.addView(doDmLabel); doDmRow.addView(U.flexible()); rows.push(doDmRow); rows.push(W.settingRow("\u7B5B\u9996\u89C6\u9891\u65E5\u671F", W.dropdown("filterDate", base + "filterDate").root, 8)); rows.push(W.settingRow("\u9996\u89C6\u9891\u70B9\u8D5E / \u9996\u89C6\u9891\u8BC4\u8BBA", W.dropdown("firstAction", base + "firstAction").root, 6)); rows.push(W.filePicker("\u5DF2\u7B5B\u7B26\u5408\u7684 UID \u6587\u672C", base + "uidTextPath", "\u9009\u62E9").root); W.stack(detail, rows, 12); return detail; } /** * 脚本勾选:**单选**(同一时间只能跑一个)。 * * `W.scriptHeader` 只负责画和报"点完该是什么状态",互斥必须由页面做 * (组件注释里写明了这一点)。漏了这一步的后果很具体: * 两个脚本都勾着时,`buildDmConfig` 只能挑一个跑 —— * 用户以为跑的是「自己粉丝私信」,实际跑的是「已提 UID 私信」, * 拿一份可能过期的 UID 文件发出去,而且界面上看不出任何异常。 */ function toggleDmScript(scriptKey, next) { for (var i = 0; i < Pages.DM_SCRIPTS.length; i += 1) { var key = Pages.DM_SCRIPTS[i].key; var path = "tabs.dm.scripts." + key + ".checked"; if (key === scriptKey) { Store.set(path, next); } else if (next) { /* 只在新勾上时才清别人:取消自己不该顺手把别人也取消掉 */ Store.set(path, false); } } } /* 暴露给自检:互斥是"数据层的不变量",不该只能靠点 UI 才能验 */ Pages.toggleDmScript = toggleDmScript; function buildDmScriptCard(script, rerender) { var base = "tabs.dm.scripts." + script.key + "."; var checked = Store.getBool(base + "checked"); var expanded = Store.getBool(base + "expanded"); var card = W.card(checked); card.addView(W.scriptHeader({ checked: checked, expanded: expanded, name: script.name, summary: null, disabled: Session.running, onToggleCheck: function (next) { toggleDmScript(script.key, next); rerender(); }, onToggleExpand: function () { Store.set(base + "expanded", !expanded); rerender(); } }).root); if (expanded && script.key === "s1") { card.addView(U.spacer(12)); card.addView(buildDmScript1Detail(rerender)); } else if (expanded && script.key === "s2") { card.addView(U.spacer(12)); card.addView(buildDmScript2Detail()); } return card; } /** * 功能设置卡(设计稿 3:579 折叠态 / 7:79 展开态)。 * * 三处刻意保留的写法: * · **进行关注 / 进行私信是两个独立开关**(稿子 7:79 把它们合成一个)。 * 合成一个的话,想单独关掉私信就得连关注一起关。 * · **数量用区间**(关注数量 / 私信数量 / 休息时间):一个量的下界与上界, * 一行两项、两个数各自可改。 * · **去重方式只出现在展开态卡片上**,这里一并保留(两版稿的并集)。 */ function buildDmSettings() { var card = W.card(false); var rows = [ W.settingRow("AI 性别", W.dropdown("aiGender", "tabs.dm.aiGender").root, 6), /* ——— 关注 ——— */ W.settingRow("进行关注", W.toggle(Store.getBool("tabs.dm.doFollow"), function (on) { Store.set("tabs.dm.doFollow", on); }).root, 6), W.rangeRow("tabs.dm.followMin", "tabs.dm.followMax", "关注数量", "~").root, /* ——— 私信 ——— */ W.settingRow("进行私信", W.toggle(Store.getBool("tabs.dm.doDm"), function (on) { Store.set("tabs.dm.doDm", on); }).root, 6), W.rangeRow("tabs.dm.dmMin", "tabs.dm.dmMax", "私信数量", "~").root, /* ——— 休息时间:一个区间的两个端点 ——— */ W.rangeRow("tabs.dm.restMin", "tabs.dm.restMax", "休息时间(秒)", "~").root, W.settingRow("去重方式", W.dropdown("dedupeMode", "tabs.dm.dedupeMode").root, 6) ]; W.stack(card, rows, 12); return card; } /** 私信 / 评论文本设置卡。 */ function buildDmTexts() { var card = W.card(false); var rows = [ W.filePicker("\u968F\u673A\u79C1\u4FE1\u6587\u672C", "tabs.dm.dmTextPath").root, W.filePicker("\u968F\u673A\u8BC4\u8BBA\u6587\u672C", "tabs.dm.commentTextPath").root ]; W.stack(card, rows, 12); return card; } Pages.dm = function (rerender) { var inner = U.column(); var blocks = []; blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9")); for (var i = 0; i < Pages.DM_SCRIPTS.length; i++) { blocks.push(buildDmScriptCard(Pages.DM_SCRIPTS[i], rerender)); } blocks.push(U.spacer(4)); blocks.push(W.sectionTitle("\u529F\u80FD\u8BBE\u7F6E")); blocks.push(buildDmSettings()); blocks.push(U.spacer(4)); blocks.push(W.sectionTitle("\u79C1\u4FE1 / \u8BC4\u8BBA\u8BBE\u7F6E")); blocks.push(buildDmTexts()); /* 本页内容会超出屏幕,末尾留一点余量,避免最后一张卡贴着滚动边界 */ blocks.push(U.spacer(8)); W.stack(inner, blocks, Theme.m.gapCard); var scroller = W.scroller(); scroller.addView(inner); return scroller; }; /* ============================== src/page-nurture.js ====================== * 养号页(设计稿 3:469)。 * * 结构:脚本选择(1 张卡)→ 养号参数卡 → 底部提示(左对齐)。 * * 概率仍是固定档位(下拉),浏览时长与单次操作则是区间: * 养号动作的强度直接决定账号安全,概率给几个安全档位比让用户随手填 90% 更负责; * 而时长与次数天然有「最少/最多」两个端点,用区间表达最贴切。 */ Pages.NURTURE_SCRIPTS = [ { key: "s1", name: "\u968F\u673A\u64CD\u4F5C\u517B\u53F7", engine: "tiktok-auto" } ]; Pages.registerScripts("nurture", Pages.NURTURE_SCRIPTS); /** 养号参数卡(设计稿 16:2)。 */ function buildNurtureParams() { var card = W.card(false); var base = "tabs.nurture."; var rows = [ W.rangeRow(base + "browseMin", base + "browseMax", "\u6D4F\u89C8\u65F6\u957F\uFF08\u5206\u949F\uFF09", "~").root, W.settingRow("\u70B9\u8D5E\u6982\u7387", W.dropdown("likeRate", base + "likeRate").root, 8), /* ★ 评论概率:当前做不到,所以**不做成下拉**。 * * 首页信息流的评论面板里,那个"发送"按钮不在无障碍树里 * (把输入框整行右侧节点全 dump 过,只有动作栏那六个和评论自己的赞/表情)。 * 而引擎是子脚本,gesture 无效、shell 没有注入权限 —— 点不到就发不出去。 * * 一个点不动的下拉,用户会以为"选了 10% 就会评论",比直接说"不支持"更糟。 */ W.settingRow("\u8BC4\u8BBA\u6982\u7387", U.text("\u5F53\u524D\u7248\u672C\u4E0D\u652F\u6301", Theme.m.hintSize, Theme.hex.textFaint, false), 8), W.settingRow("\u5173\u6CE8\u6982\u7387", W.dropdown("followRate", base + "followRate").root, 8), W.rangeRow(base + "actionMin", base + "actionMax", "\u5355\u6B21\u64CD\u4F5C", "~").root ]; W.stack(card, rows, 12); return card; } Pages.nurture = function (rerender) { var inner = U.column(); var blocks = []; blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9")); for (var i = 0; i < Pages.NURTURE_SCRIPTS.length; i++) { var script = Pages.NURTURE_SCRIPTS[i]; var base = "tabs.nurture.scripts." + script.key + "."; var checked = Store.getBool(base + "checked"); var expanded = Store.getBool(base + "expanded"); var card = W.card(checked); card.addView(W.scriptHeader({ checked: checked, expanded: expanded, name: script.name, summary: null, disabled: Session.running, onToggleCheck: function (next) { Store.set(base + "checked", next); rerender(); }, onToggleExpand: function () { Store.set(base + "expanded", !expanded); rerender(); } }).root); blocks.push(card); } blocks.push(U.spacer(4)); blocks.push(W.sectionTitle("\u517B\u53F7\u53C2\u6570")); blocks.push(buildNurtureParams()); blocks.push(U.spacer(8)); /* 设计稿 3:506:这行是左对齐的,不像其它页居中。 * * 文案要交代两件规格书里没写清的事(不写用户就会猜错): * ① 「单次操作」是**轮数**,不是间隔秒数 —— 与「浏览时长」各自独立,谁先到谁停 * ② 不含评论(那个当前做不到,见上面评论概率那行的注释) */ blocks.push(W.hint("\u968F\u673A\u6D4F\u89C8\u3001\u70B9\u8D5E\u3001\u5173\u6CE8\u6A21\u62DF\u771F\u5B9E\u6D3B\u8DC3\u5EA6\uFF1B" + "\u300C\u5355\u6B21\u64CD\u4F5C\u300D\u662F\u8F6E\u6570\uFF0C\u4E0E\u6D4F\u89C8\u65F6\u957F\u8C01\u5148\u5230\u8C01\u505C", false)); W.stack(inner, blocks, Theme.m.gapPage); var scroller = W.scroller(); scroller.addView(inner); return scroller; }; /* ============================== src/page-video.js ============================== */ /* ============================== src/page-video.js ============================== * 视频功能页(设计稿 3:629)。 * * 结构:脚本选择(1 张卡)→ 功能设置 → 私信 / 评论设置。 * * 与 UID私信页的差别在「执行操作」这一行:它是一行多选(关注/私信/点赞/评论), * 所以用的是 W.toggleRow 而不是 W.toggle。 */ var Pages = Pages || {}; /** * 视频功能页的脚本。 * * `engine` 指名由哪个执行引擎驱动 —— 目前只有这一页接了 tiktok-auto。 * 其它页的脚本没有这个字段,点「开始运行」会被拦下并说明原因 * (见 App.toggleRun 与 Dialog.engineMissing),而不是静默跑一个不相关的东西。 */ Pages.VIDEO_SCRIPTS = [ { key: "s1", name: "\u5173\u952E\u8BCD\u89C6\u9891\u529F\u80FD", engine: "tiktok-auto" } ]; Pages.registerScripts("video", Pages.VIDEO_SCRIPTS); /** 「执行操作」四项的定义。顺序即设计稿从左到右的顺序。 */ Pages.VIDEO_ACTIONS = [ { key: "follow", label: "\u5173\u6CE8" }, { key: "dm", label: "\u79C1\u4FE1" }, { key: "like", label: "\u70B9\u8D5E" }, { key: "comment", label: "\u8BC4\u8BBA" } ]; function buildVideoScriptCard(rerender) { var base = "tabs.video.scripts.s1."; var checked = Store.getBool(base + "checked"); var expanded = Store.getBool(base + "expanded"); var card = W.card(checked); card.addView(W.scriptHeader({ checked: checked, expanded: expanded, name: Pages.VIDEO_SCRIPTS[0].name, summary: null, disabled: Session.running, onToggleCheck: function (next) { Store.set(base + "checked", next); rerender(); }, onToggleExpand: function () { Store.set(base + "expanded", !expanded); rerender(); } }).root); return card; } /** 功能设置卡(设计稿 3:667):搜索条件 + 节奏 + 动作开关。 */ function buildVideoSettings() { var card = W.card(false); var rows = []; rows.push(W.settingRow("\u5173\u952E\u8BCD", W.textField("tabs.video.keyword", "\u641C\u7D22", 100).root, 6)); rows.push(W.settingRow("\u4E3B\u64AD\u533A\u57DF", W.textField("tabs.video.region", "\u56FD\u5BB6\u7B80\u7801", 100).root, 6)); /* 停止个数与间隔时间都是区间:一行两项,两个数各自可改 */ rows.push(W.rangeRow("tabs.video.stopMin", "tabs.video.stopMax", "\u505C\u6B62\u4E2A\u6570", "~").root); rows.push(W.rangeRow("tabs.video.intervalMin", "tabs.video.intervalMax", "\u95F4\u9694\u65F6\u95F4\uFF08\u79D2\uFF09", "~").root); /* 动作开关:四个独立,改了立即落盘 */ var actionItems = []; for (var i = 0; i < Pages.VIDEO_ACTIONS.length; i++) { var action = Pages.VIDEO_ACTIONS[i]; actionItems.push({ key: action.key, label: action.label, on: Store.getBool("tabs.video.actions." + action.key) }); } rows.push(W.settingRow("\u6267\u884C\u64CD\u4F5C", W.toggleRow(actionItems, function (key, on) { Store.set("tabs.video.actions." + key, on); }).root, 6)); W.stack(card, rows, 12); return card; } /** 文本设置卡:私信与评论内容文件。 */ function buildVideoTexts() { var card = W.card(false); var rows = [ W.filePicker("随机私信内容 1-3", "tabs.video.dmTextPath").root, W.filePicker("随机评论内容 1-3", "tabs.video.commentTextPath").root ]; W.stack(card, rows, 12); return card; } Pages.video = function (rerender) { var inner = U.column(); var blocks = []; blocks.push(W.sectionTitle("\u811A\u672C\u9009\u62E9")); blocks.push(buildVideoScriptCard(rerender)); blocks.push(U.spacer(4)); blocks.push(W.sectionTitle("\u529F\u80FD\u8BBE\u7F6E")); blocks.push(buildVideoSettings()); blocks.push(U.spacer(4)); blocks.push(W.sectionTitle("\u79C1\u4FE1 / \u8BC4\u8BBA\u8BBE\u7F6E")); blocks.push(buildVideoTexts()); /* 内容超出屏幕,末尾留余量避免最后一张卡贴着滚动边界 */ blocks.push(U.spacer(8)); W.stack(inner, blocks, Theme.m.gapCard); var scroller = W.scroller(); scroller.addView(inner); return scroller; }; /* ============================== src/page-tasks.js ============================== */ /* ============================== src/page-tasks.js ============================== * 任务列表(规格书 B1 / B2,设计稿 30:102 / 30:203)。 * * ## 这一页是整个改版的核心 * 底栏从「5 个页签」变成「3 个 tab」之后,原来的 ID提取 / UID私信 / 养号 / 视频功能 * **降级为这里的四张任务卡**,各自的页面成了二级页(见 Pages.DETAILS)。 * 所以这一页不只是"多了一个列表":它是新的入口层级。 * * ## 任务清单与「脚本清单」是两张表 * · `Pages.TASKS` —— 四张卡(名称 / 图标 / 颜色),**顺序即设计稿顺序**; * · `Pages.SCRIPTS_OF`—— 每个任务下的脚本(各页自己注册,见 page-id/page-dm/page-video)。 * 任务卡要显示「当前:xxx」,就得把两张表拼起来 —— 拼接逻辑集中在 * `Pages.checkedScript()`,`App.currentTask()` 也复用它,避免两处各判一遍单选。 */ var Pages = Pages || {}; /** * 四个任务。key 与旧的页签 key、以及配置路径 `tabs..*` **完全一致** —— * 这不是巧合:老用户的配置一条都不用搬家(见 Store.MIGRATIONS[5])。 * * 颜色取设计稿任务卡图标框的衬底色(14% 透明底 + 同色字形): * ID提取 #2EA6FF / UID私信 #9482FF / 养号 #12DEC4 / 视频功能 #F5C24A * 图标一律用 U+25xx 几何块字符(彩色 emoji 的坑见 W.TABS 注释)。 */ Pages.TASKS = [ { key: "id", name: "ID\u63D0\u53D6", icon: "\u25A4", colorHex: Theme.hex.accent }, { key: "dm", name: "UID\u79C1\u4FE1", icon: "\u25A5", colorHex: Theme.hex.violet }, { key: "nurture", name: "\u517B\u53F7", icon: "\u25D4", colorHex: Theme.hex.ok }, { key: "video", name: "\u89C6\u9891\u529F\u80FD", icon: "\u25B6", colorHex: Theme.hex.warn } ]; /** 二级页的标题(返回头上显示的就是它)。 */ Pages.DETAIL_TITLES = { id: "ID\u63D0\u53D6", dm: "UID\u79C1\u4FE1", nurture: "\u517B\u53F7", video: "\u89C6\u9891\u529F\u80FD" }; Pages.taskOf = function (key) { for (var i = 0; i < Pages.TASKS.length; i++) { if (Pages.TASKS[i].key === key) { return Pages.TASKS[i]; } } return null; }; Pages.taskName = function (key) { var task = Pages.taskOf(key); return task ? task.name : String(key || ""); }; /** * 某个任务**当前选中的脚本**(没选返回 null)。 * * 单选规则由各页自己保证(ID提取页是 `Pages.toggleIdScript`), * 这里只做"第一个勾上的就是它"的读取 —— 与 `App.currentTask()` 的旧逻辑一致, * 但接受任意任务 key,而不是只认当前 tab。 */ Pages.checkedScript = function (taskKey) { /* ★ v8:ID提取没有"脚本"这个概念了 —— 它的配置是**取材清单**。 * 所以"已配置" = 清单非空,而不是"某张卡的 checked 为真"(那些字段已删除)。 * 两个名字分开给: * · `name` 给拦截弹窗用(「ID提取的执行引擎尚未接入」比塞一整段摘要更像人话); * · `detail` 给任务卡的「当前:…」用(那里正需要"从哪儿取材"这个信息)。 */ if (taskKey === "id") { if (Sources.isEmpty()) { return null; } /* 引擎声明仍然从 SCRIPTS_OF 读:将来 ID提取 接入执行引擎时, * 在 `Pages.ID_SCRIPTS` 上加一个 `engine` 字段就够了,这里不用改。 */ var idEntry = (Pages.SCRIPTS_OF["id"] || [])[0] || {}; return { key: "id", name: Pages.taskName("id"), detail: Sources.summary(), engine: idEntry.engine || "", task: "id", taskName: Pages.taskName("id") }; } var list = Pages.SCRIPTS_OF[taskKey] || []; for (var i = 0; i < list.length; i++) { var entry = Store.getBool("tabs." + taskKey + ".scripts." + list[i].key + ".checked"); if (entry) { return { key: list[i].key, name: list[i].name, detail: list[i].name, engine: list[i].engine || "", task: taskKey, taskName: Pages.taskName(taskKey) }; } } return null; }; /** 该任务是否正在被驱动。运行态是全局单跑,所以只要 Session.tab 对上就是它。 */ Pages.taskIsRunning = function (taskKey) { return !!Session.running && Session.tab === taskKey; }; /** * 任务卡的状态徽标(设计稿:空闲 灰 / 运行中 绿 / 未配置 琥珀)。 * * 「未配置」= 这个任务当前没有任何脚本被选中。ID提取页默认一个都不选 * (设计稿 16:23 专门画了"未选择脚本"那一屏),所以它一进来就是琥珀色 —— * 这正是要给用户的信号:想跑先选一个。 */ Pages.taskState = function (taskKey) { if (Pages.taskIsRunning(taskKey)) { return { badge: "\u8FD0\u884C\u4E2D", tone: "ok" }; } if (!Pages.checkedScript(taskKey)) { return { badge: "\u672A\u914D\u7F6E", tone: "warn" }; } return { badge: "\u7A7A\u95F2", tone: "mute" }; }; /** 任务卡的「当前:xxx」一行。 */ Pages.taskScriptLine = function (taskKey) { var script = Pages.checkedScript(taskKey); if (!script) { /* v8:ID提取没有"脚本"了,它的空态是"还没添加取材" */ return "\u5F53\u524D\uFF1A" + (taskKey === "id" ? "\u672A\u6DFB\u52A0\u53D6\u6750" : "\u672A\u9009\u62E9\u811A\u672C"); } /* detail 才是"从哪儿取材"(ID提取用清单摘要,其它任务与 name 相同) */ return "\u5F53\u524D\uFF1A" + (script.detail || script.name); }; /* ------------------------------------------------------------ 任务卡 */ function buildTaskCard(task) { var state = Pages.taskState(task.key); var running = Pages.taskIsRunning(task.key); return W.taskCard({ name: task.name, iconChar: task.icon, colorHex: task.colorHex, badge: state.badge, badgeTone: state.tone, scriptLine: Pages.taskScriptLine(task.key), lastLine: Records.lastRunLine(task.key), running: running, /* 点卡片主体 → 进该任务的详情页 */ onOpen: function () { App.openDetail(task.key); }, /* 点「运行」→ 直接跑这个任务当前选中的脚本(不必先切页签再点底部按钮)。 * 运行中的那一个按钮是「停止」:它停的是**全局运行**, * 点别的任务的「运行」也不会被禁用 —— 由 App.requestRun 弹 F3 确认(规格书 §2.3)。 */ onRun: function () { if (running) { App.stopRun(); App.renderScreen(); return; } App.requestRun(task.key); } }).root; } /* ------------------------------------------------------------ 页面 */ /** B1/B2:任务列表(运行态只是同一个页面的另一种状态)。 * * ⚠️ 这里**不画**页面标题区:标题区由路由层统一加(App.renderContent 的 * 「一级 tab」分支),页面自己再加一次就会渲染出两遍。 * 页面只负责"这一 tab 的内容"。 */ Pages.tasks = function () { var inner = U.column(); var blocks = []; var section = U.column(); section.addView(W.sectionTitle("\u4EFB\u52A1")); section.addView(U.spacer(4)); /* 区说明是刻意保留的:全局单跑是这一页最需要被知道的一条规则 —— * 四张卡上都有「运行」按钮,不写清楚没人知道它们互斥。 */ section.addView(U.text("\u5168\u5C40\u5355\u8DD1\uFF1A\u540C\u4E00\u65F6\u95F4\u4EC5\u53EF\u8FD0\u884C\u4E00\u4E2A\u811A\u672C", Theme.m.hintSize, Theme.hex.textDim, false)); blocks.push(section); blocks.push(U.spacer(Theme.m.gapCard)); for (var i = 0; i < Pages.TASKS.length; i++) { blocks.push(buildTaskCard(Pages.TASKS[i])); if (i < Pages.TASKS.length - 1) { blocks.push(U.spacer(Theme.m.gapCard)); } } /* 末尾余量:最后一张卡贴着底栏时会被渐隐压住 */ blocks.push(U.spacer(8)); W.stack(inner, blocks, 0); var scroller = W.scroller(); scroller.addView(inner); return scroller; }; /* ============================== src/page-records.js ============================== */ /* ============================== src/page-records.js ============================== * 运行记录(规格书 D1/D2/D3,设计稿 30:871 / 30:958 / 30:1241)。 * * D1 是「记录」tab 的一级页,D3 是它的二级页(点任一条进)。 * D2 是 D1 的空态 —— **空态是设计稿专门画的一屏**,不是"没数据就不显示": * 一个空页面看起来像坏了,空态告诉用户"去哪能产生记录"。 * * 记录本身由 `Records` 模块负责读写(见 src/records.js), * 这一层只负责画:不猜字段、不编文案 —— 没有的东西就不渲染那一行。 */ var Pages = Pages || {}; /** 「脚本一 · 粉丝页提 ID」→「脚本一」。卡上只放得下短名,全名在详情页。 */ Pages.shortScriptName = function (name) { var text = String(name || ""); var cut = text.indexOf(" \u00B7 "); return cut > 0 ? text.substring(0, cut) : text; }; /** 记录条目 / 详情页统一用「任务 · 脚本」当标题。 */ Pages.recordTitle = function (record) { var task = record.taskName || Pages.taskName(record.taskKey); var script = Pages.shortScriptName(record.scriptName); return script === "" ? task : (task + " \u00B7 " + script); }; /** 记录用的任务色/图标(与任务卡一致,列表里一眼能对上号)。 */ function recordVisual(record) { var task = Pages.taskOf(record.taskKey); return { colorHex: task ? task.colorHex : Theme.hex.accent, iconChar: task ? task.icon : "\u25A3" }; } /* ------------------------------------------------------------ 日志行 */ /** * 日志行的配色(规格书:INFO 灰 / WARN 琥珀 / ERROR 玫瑰红)。 * STEP 归到 INFO:它是流程节点,不是异常。 */ function logColor(level) { if (level === "WARN") { return Theme.hex.warn; } if (level === "ERROR") { return Theme.hex.danger; } return Theme.hex.textFaint; } function pad2(n) { return n < 10 ? "0" + n : String(n); } /** 时间戳 → HH:MM:SS(日志行前缀,等宽显示才对齐)。 */ function logClock(ms) { var d = new Date(ms || 0); return pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds()); } /** * 一条日志行。 * * 用**等宽字体**并且手动补空格对齐 level:设计稿里 `10:24:01 INFO 启动脚本` * 的 level 是定宽的,用比例字体会让每一行的正文起点参差不齐。 */ function buildLogLine(entry) { var raw = String(entry.level || "INFO").toUpperCase(); var level = raw; while (level.length < 5) { level += " "; } var text = logClock(entry.t) + " " + level + " " + String(entry.text || ""); var view = U.monoText(text, Theme.m.logSize, logColor(raw)); U.params(view, -1, -2, 0); return view; } /* ------------------------------------------------------------ D1 记录列表 */ function buildRecordRow(record, snap) { var visual = recordVisual(record); var result = Records.RESULT[record.result] || Records.RESULT.failed; var resumable = Resume.isRecordResumable(record, snap); return W.recordRow({ title: Pages.recordTitle(record), iconChar: visual.iconChar, colorHex: visual.colorHex, resultLabel: result.label, resultTone: result.tone, timeLine: Records.timeLine(record), resultLine: Records.resultLine(record), /* 只有"这条记录就是那个没跑完的断点"时才给「继续」。 * 判据在 Resume.isRecordResumable 里(含 runId 精确匹配)—— * 否则一串历史「手动停止」记录上都会冒出「继续」,而只有最后一条能真续。 */ actionLabel: resumable ? "\u7EE7\u7EED" : "", actionTone: "accent", onAction: function () { App.resumeRun(record); }, onOpen: function () { App.openRecord(record.id); } }); } /** D1:记录列表(有记录时)。 */ Pages.records = function () { var inner = U.column(); var blocks = []; var list = Records.list(); /* 断点只读一次:50 条记录各读一次盘是没必要的浪费 */ var snap = Resume.snapshot(); /* 标题区由路由层统一加(见 App.renderContent)——页面再加一次就会渲染两遍 */ var section = U.column(); section.addView(W.sectionTitle("\u8FD0\u884C\u8BB0\u5F55")); section.addView(U.spacer(4)); /* 「共 N 条 · 点任一条查看执行日志」——条数是真实的,空列表走 D2 那一屏 */ section.addView(U.text("\u5171 " + list.length + " \u6761 \u00B7 \u70B9\u4EFB\u4E00\u6761\u67E5\u770B\u6267\u884C\u65E5\u5FD7", Theme.m.hintSize, Theme.hex.textDim, false)); blocks.push(section); blocks.push(U.spacer(Theme.m.gapCard)); for (var i = 0; i < list.length; i++) { blocks.push(buildRecordRow(list[i], snap)); if (i < list.length - 1) { blocks.push(U.spacer(Theme.m.gapCard)); } } blocks.push(U.spacer(8)); W.stack(inner, blocks, 0); var scroller = W.scroller(); scroller.addView(inner); return scroller; }; /** D2:记录空态。 */ Pages.recordsEmpty = function () { var inner = U.column(); /* 标题区同样由路由层提供;空态只剩「区标题 + 空态块」 */ inner.addView(W.sectionTitle("\u8FD0\u884C\u8BB0\u5F55")); inner.addView(W.emptyState("\u25F4", "\u8FD8\u6CA1\u6709\u8FD0\u884C\u8BB0\u5F55", "\u53BB\u300C\u4EFB\u52A1\u300D\u6807\u7B7E\u9009\u4E00\u4E2A\u811A\u672C\u8DD1\u8D77\u6765")); return inner; }; /* ------------------------------------------------------------ D3 运行详情 */ /** * 结果摘要卡。 * 三行都是**有条件渲染**的:结果文案/结果文件只有引擎回传了才有, * 没有就不画那一行 —— 空着的一行会让用户以为"数据丢了"。 */ function buildSummaryCard(record) { var card = W.card(false); var rows = []; var result = Records.RESULT[record.result] || Records.RESULT.failed; var titleRow = U.row(); var title = U.text(Pages.recordTitle(record), Theme.m.taskNameSize, Theme.hex.textPrimary, true); title.setSingleLine(true); title.setEllipsize(android.text.TextUtils.TruncateAt.END); U.params(title, 0, -2, 1); titleRow.addView(title); titleRow.addView(U.hgap(8)); titleRow.addView(W.badge(result.label, result.tone).root); rows.push(titleRow); rows.push(U.text(Records.timeLine(record), Theme.m.chipSize, Theme.hex.textFaint, false)); if (record.resultText) { rows.push(U.text(record.resultText, Theme.m.chipSize, Theme.hex.textDim, false)); } if (record.resultFile) { pushFileRows(rows, "\u7ED3\u679C\u6587\u4EF6", record.resultFile); } if (record.mapFile) { pushFileRows(rows, "uid \u6620\u5C04\uFF08username\u2192\u6570\u5B57 uid\uFF09", record.mapFile); } W.stack(card, rows, 10); return card; } /** * 一份产物文件:说明行 + 路径行(点路径打开)+ 打开/分享两个按钮。 * * 抽成函数是因为现在有两份产物(结果文件 + uid 映射文件), * 各自都要能打开和分享 —— 光在界面上写一句「已生成 uid 映射」而取不到文件, * 等于让用户自己去翻 /sdcard。 */ function pushFileRows(rows, caption, path) { rows.push(U.text(caption, Theme.m.chipSize, Theme.hex.textFaint, false)); var fileRow = U.row(); var pathText = U.text(path, Theme.m.chipSize, Theme.hex.accentText, false); pathText.setSingleLine(true); pathText.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE); U.params(pathText, 0, -2, 1); fileRow.addView(pathText); fileRow.addView(U.hgap(8)); fileRow.addView(W.smallButton("\u6253\u5F00", "accent", function () { App.openResultFile(path); }).root); fileRow.addView(U.hgap(6)); fileRow.addView(W.smallButton("\u5206\u4EAB", "default", function () { App.shareResultFile(path); }).root); rows.push(fileRow); } /** 日志卡:标题行(执行日志 + 复制)+ 日志行。 */ function buildLogCard(record) { var card = W.card(false); var logs = record.logs || []; var head = U.row(); head.addView(U.text("\u6267\u884C\u65E5\u5FD7", Theme.m.labelSize, Theme.hex.textSecondary, true)); head.addView(U.flexible()); head.addView(W.smallButton("\u590D\u5236", "default", function () { App.copyLogs(record); }).root); card.addView(head); card.addView(U.spacer(10)); if (logs.length === 0) { /* 如实说明,而不是画一张空卡片。日志来自引擎的 --log 通道(见 Engine.readLog), * 没接引擎的任务本来就不会有日志。 */ card.addView(U.text("\u672C\u6B21\u8FD0\u884C\u6CA1\u6709\u56DE\u4F20\u65E5\u5FD7", Theme.m.chipSize, Theme.hex.textDim, false)); return card; } for (var i = 0; i < logs.length; i++) { if (i > 0) { card.addView(U.spacer(6)); } card.addView(buildLogLine(logs[i])); } return card; } /** * D3:运行详情。 * 记录被清理掉(超过 Records.MAX 或被手工清空)时,**不能白屏** —— * 给一句"这条记录已不在本地"和一条回到列表的路。 */ Pages.recordDetail = function (record) { var inner = U.column(); inner.addView(W.backHeader("\u8FD0\u884C\u8BE6\u60C5", function () { App.closeRecord(); })); if (!record) { inner.addView(U.spacer(Theme.m.gapPage)); inner.addView(W.emptyState("\u25F4", "\u8FD9\u6761\u8BB0\u5F55\u5DF2\u4E0D\u5728\u672C\u5730", "\u8BB0\u5F55\u4EC5\u4FDD\u7559\u6700\u8FD1 " + Records.MAX + " \u6761")); var scroller0 = W.scroller(); scroller0.addView(inner); return scroller0; } inner.addView(U.spacer(Theme.m.gapCard)); /* 这条记录还能接着跑 → 在看日志的地方也放一个入口(列表行那个是"一眼可见", * 这个是"看完日志顺手继续")。判据与列表行同一份。 */ var snap = Resume.snapshot(); if (Resume.isRecordResumable(record, snap)) { inner.addView(W.noticeBar({ title: "\u8FD9\u6B21\u8FD0\u884C\u6CA1\u8DD1\u5B8C\uFF08\u5DF2\u6536 " + snap.extracted + " \u4E2A\uFF09", detail: Resume.detail(snap), tone: "accent", actions: [{ label: "\u7EE7\u7EED", tone: "accent", onClick: function () { App.resumeRun(record); } }] }).root); inner.addView(U.spacer(Theme.m.gapCard)); } inner.addView(buildSummaryCard(record)); inner.addView(U.spacer(Theme.m.gapCard)); inner.addView(buildLogCard(record)); inner.addView(U.spacer(8)); var scroller = W.scroller(); scroller.addView(inner); return scroller; }; /* ============================== src/page-login.js ============================== */ /* ============================== src/page-login.js ============================== * 卡密登录页(设计稿 16:257 登录 / 16:288 校验失败 / 16:1491 校验中)。 * * 它不是某个页签,而是**未激活时的整个界面**: * 没有页签栏、没有开始运行按钮,唯一的出口是登录成功或退出应用。 * 所以由 main.js 的 screen 路由决定显示它还是显示主界面。 * * 三态共用一个构建函数,靠 Pages.LICENSE_VIEW.state 分叉: * idle 输入框灰底、按钮「登录激活」、提示「卡密为一次性激活凭证…」 * verifying 按钮半透明 + 「登录中…」、提示换成「正在校验卡密,请稍候…」 * failed 输入框红边、下方红字「卡密无效或已过期,请检查后重试」 * 三态的差异全部落在「样式」上,结构完全一致 —— 拆成三个函数会立刻产生三份漂移。 * * 输入框与页面上的其它输入框是**同一个组件**(`W.inlineField`), * 并且**同样绑配置路径**(`license.draftCode`): * 手打 16 位卡密时被系统回收进程,重开还能接着填。 * 提交成功后草稿会被清掉,`license.code` 成为唯一副本。 */ var Pages = Pages || {}; /** 登录页的界面状态。三态与失败文案只活在内存里:重启后回到 idle 是对的。 */ Pages.LICENSE_VIEW = { state: "idle", /* idle | verifying | failed */ message: "" }; Pages.LICENSE_RESET = function () { Pages.LICENSE_VIEW.state = "idle"; Pages.LICENSE_VIEW.message = ""; }; /* ------------------------------------------------------------ 子块 */ /** 品牌区:渐变 Logo + 应用名 + 副标题。 */ function buildBrandBlock() { var logoText = U.text("\u25B6", 26, "#FFFFFF", true); logoText.setGravity(android.view.Gravity.CENTER); var logo = new android.widget.FrameLayout(context); logo.setBackground(U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.loginLogoRadius)); U.params(logo, Theme.m.loginLogoSize, Theme.m.loginLogoSize); logo.addView(logoText); var brand = U.column(false); brand.addView(logo); brand.addView(U.spacer(12)); brand.addView(U.text("\u98DE\u821F\u811A\u672C", 28, Theme.hex.textPrimary, true)); brand.addView(U.spacer(6)); brand.addView(U.text("TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u8BF7\u767B\u5F55\u540E\u4F7F\u7528", 12, Theme.hex.textMuted, false)); return brand; } /** * 卡密输入框。失败态换成红边 + 红底,其余与常态一致。 * * ★ 绑定 `license.draftCode`:**没提交的半截输入也要落盘**。 * 之前的做法是「只在点登录时读一次、不进配置」,代价是手打 16 位卡密时 * 被系统回收进程就得从头再打一遍。 */ function buildLicenseInput(state) { var input = W.inlineField({ path: "license.draftCode", placeholder: "\u8BF7\u8F93\u5165\u5361\u5BC6", fill: true }); /* 卡密是分组的大写字母数字串,等宽字更容易逐位核对 */ input.root.setTypeface(android.graphics.Typeface.MONOSPACE); input.root.setTextSize(14); if (state === "failed") { input.root.setBackground(U.strokeBg(0.06, 0.45, Theme.hex.danger, 10)); } else { input.root.setBackground(U.strokeBg(0.05, 0.12, "#FFFFFF", 10)); } return input; } /** * 登录 / 校验中 按钮。 * 校验中时把渐变整体压到 45% 透明度,并在文字前加一个「◌」—— * 不加这个视觉差,用户会以为可以连点。 */ function buildSubmitButton(state) { var verifying = state === "verifying"; var label = verifying ? "\u25CC \u767B\u5F55\u4E2D\u2026" : "\u767B\u5F55\u6FC0\u6D3B"; var text = U.text(label, 16, "#FFFFFF", true); text.setGravity(android.view.Gravity.CENTER); var button = new android.widget.FrameLayout(context); /* 不用 W.primaryButton:它没有「禁用态」这个概念,而这里必须有 */ var bg = U.gradientBg(Theme.hex.accent, Theme.hex.accentDeep, Theme.m.pillRadius); if (verifying) { bg.setAlpha(Math.round(255 * 0.45)); } button.setBackground(bg); U.params(button, -1, Theme.m.loginBtnHeight); button.addView(text); if (!verifying) { button.setOnClickListener(function () { App.submitLicense(inputCode()); }); } return { root: button, label: text }; } /* 取输入框里的原始串。登录页只有一个输入框,用页面级引用比层层回传更省事。 */ var licenseInputRef = null; function inputCode() { return licenseInputRef ? licenseInputRef.value() : ""; } /* ------------------------------------------------------------ 页面 */ /** * 登录页主体。 * 不接收 rerender —— 三态切换由 App 统一走 renderScreen(), * 页面自己重绘会绕开「切屏」这条唯一的路径。 */ Pages.login = function () { var view = Pages.LICENSE_VIEW; var state = view.state; var inner = U.column(); inner.setPadding(U.dp(20), U.dp(28), U.dp(20), U.dp(20)); inner.addView(buildBrandBlock()); inner.addView(U.spacer(28)); /* —— 卡密输入卡 —— */ var card = U.column(); card.setBackground(U.strokeBg(0.04, 0.08, "#FFFFFF", Theme.m.cardRadius)); card.setPadding(U.dp(16), U.dp(16), U.dp(16), U.dp(16)); var input = buildLicenseInput(state); licenseInputRef = input; card.addView(input.root); card.addView(U.spacer(14)); card.addView(buildSubmitButton(state).root); card.addView(U.spacer(14)); /* 提示位在三态里说三件不同的事,共用同一行位置 */ if (state === "failed") { var err = U.text(view.message || "\u5361\u5BC6\u65E0\u6548\u6216\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u68C0\u67E5\u540E\u91CD\u8BD5", 11, Theme.hex.danger, false); err.setGravity(android.view.Gravity.CENTER); card.addView(err); } else if (state === "verifying") { var waiting = U.text("\u6B63\u5728\u6821\u9A8C\u5361\u5BC6\uFF0C\u8BF7\u7A0D\u5019\u2026", 11, Theme.hex.accentText, false); waiting.setGravity(android.view.Gravity.CENTER); card.addView(waiting); } else { var safe = U.text("\u5361\u5BC6\u4E3A\u4E00\u6B21\u6027\u6FC0\u6D3B\u51ED\u8BC1\uFF0C\u8BF7\u52FF\u6CC4\u9732\u7ED9\u4ED6\u4EBA", 11, Theme.hex.textFaint, false); safe.setGravity(android.view.Gravity.CENTER); card.addView(safe); } card.addView(U.spacer(12)); card.addView(W.link("\u8D2D\u4E70\u5361\u5BC6 \u00B7 \u8054\u7CFB\u5BA2\u670D", Theme.hex.accentText, function () { App.showPurchaseInfo(); })); card.addView(U.spacer(4)); card.addView(W.link("\u9000\u51FA\u5E94\u7528", Theme.hex.textMuted, function () { App.exit(); })); inner.addView(card); var scroller = W.scroller(); scroller.addView(inner); return scroller; }; /* ============================== src/page-mine.js ============================== */ /* ============================== src/page-mine.js ============================== * 我的页(设计稿 7:289)。 * * 结构:卡密 → 应用 → 关于 三段。 * 与其它页签的差别:本页没有「开始运行」按钮,底部只有页签栏。 * * 数据来源都是真实的: * · 卡密状态/到期时间 → Service.licenseStatus() * · TikTok 安装与版本 → Device.tiktok() * · 脚本版本 → Service.SCRIPT_VERSION(构建时注入) * · 检查更新 → Service.checkUpdate() */ var Pages = Pages || {}; /** 卡密登录卡:已登录给脱敏卡密 + 解绑;未登录/已过期给「去登录」。 */ function buildLicenseCard(rerender) { var card = W.card(false); var rows = []; var display = Service.licenseDisplay(); rows.push(W.settingRow("\u5361\u5BC6\u72B6\u6001", W.pill(display.text, display.tone).root, 6)); rows.push(W.settingRow("\u5361\u5BC6\u5230\u671F\u65F6\u95F4", monoChip(display.expireAt, display.expireAt === "\u2014").root, 6)); if (display.status === "active") { /* 已登录:脱敏展示,只露前 4 后 4 —— 卡片码不该完整出现在截图里 */ var masked = Service.formatCode(Service.normalizeCode(Service.licenseStatus().code)); if (masked.length > 9) { masked = masked.substring(0, 4) + "-****-****-" + masked.substring(masked.length - 4); } var codeView = U.text(masked, Theme.m.chipSize, Theme.hex.textSecondary, false); codeView.setSingleLine(true); codeView.setBackground(U.strokeBg(Theme.alpha.chipFill, Theme.alpha.chipStroke, "#FFFFFF", Theme.m.chipRadius)); codeView.setPadding(U.dp(6), U.dp(6), U.dp(6), U.dp(6)); /* 「解绑」而不是「退出登录」:这个动作的后果比退出更重 * (正在跑的脚本会被停掉),文案要与弹窗里的说法一致。 */ var unbind = W.chip("\u89E3\u7ED1", "danger"); unbind.root.setOnClickListener(function () { Dialog.confirmUnbind(function () { /* 顺序有意义:先停运行,再清卡密,最后切回登录页。 * 反过来的话,停运行那一刻界面已经不在主界面上了。 */ App.stopRun(); Service.logout(); toast("\u5DF2\u89E3\u7ED1\u5361\u5BC6"); App.showLogin(); }); }); var row = U.row(); row.addView(codeView); row.addView(U.flexible()); row.addView(U.hgap(6)); row.addView(unbind.root); rows.push(row); } else { /* 未登录 / 已过期:主界面里没有可做的事,引导回登录页 */ var go = W.chip("\u53BB\u767B\u5F55", "accent"); go.root.setOnClickListener(function () { App.showLogin(); }); rows.push(W.settingRow("\u5361\u5BC6", go.root, 6)); } W.stack(card, rows, 12); return card; } /** 等宽小胶囊,用于日期 / 版本号这类需要对齐阅读的值。 */ function monoChip(value, muted) { var view = U.monoText(value, Theme.m.chipSize, muted ? Theme.hex.textFaint : Theme.hex.textSecondary); view.setSingleLine(true); view.setBackground(U.strokeBg(Theme.alpha.chipFill, Theme.alpha.chipStroke, "#FFFFFF", Theme.m.chipRadius)); view.setPadding(U.dp(6), U.dp(6), U.dp(6), U.dp(6)); return { root: view, setText: function (v) { view.setText(String(v)); } }; } /** * 悬浮球开关卡(规格书 §10 的补充项)。 * * 规格书 §10.3 里有「隐藏悬浮球」,但**没写隐藏之后怎么再显示** —— * 没有恢复入口的话,用户点一次隐藏就再也找不回这个跨 App 唯一的控制入口了。 * 所以这里补一个开关,并且它是**双向**的: * · 关掉 → 立刻关球 + 落盘 `ball.hidden`(下次启动也不会再冒出来); * · 打开 → 立刻 `FB.ensure()`(当场就能看到球出现在屏幕上)。 * * 权限没给时会如实说明球建不出来 —— 规格书 §10.9① 写着悬浮窗权限 * "唯一实际用途"就是这个球,所以这里必须讲清楚,而不是让用户对着开关纳闷。 */ function buildFloatBallCard() { var card = W.card(false); var rows = []; var allowed = Device.overlayEnabled(); var shown = !Store.getBool("ball.hidden") && allowed; var toggle = W.toggle(shown, function (on) { if (on && !Device.overlayEnabled()) { /* 没权限就别落一个"开了但看不见"的状态:直接引导去授权 */ toast("\u8BF7\u5148\u5F00\u542F\u60AC\u6D6E\u7A97\u6743\u9650"); Device.openPermissionSettings("overlay"); return; } FB.setHidden(!on); toast(on ? "\u5DF2\u663E\u793A\u60AC\u6D6E\u7403" : "\u5DF2\u9690\u85CF\u60AC\u6D6E\u7403"); }); rows.push(W.settingRow("\u60AC\u6D6E\u7403", toggle.root, 6)); /* 权限缺失时补一行说明(否则用户会以为开关坏了) */ if (!allowed) { rows.push(W.hint("\u9700\u5F00\u542F\u300C\u60AC\u6D6E\u7A97\u6743\u9650\u300D\u624D\u80FD\u663E\u793A\u60AC\u6D6E\u7403", false)); } else { /* 球的位置是用户拖出来的,写清"可拖动"能省掉一轮困惑 */ rows.push(W.hint("\u53EF\u62D6\u52A8\u5230\u4EFB\u610F\u8FB9\u7F18\uFF0C\u677E\u624B\u81EA\u52A8\u5438\u9644\u5E76\u8BB0\u4F4F\u4F4D\u7F6E", false)); } W.stack(card, rows, 10); return card; } /** 应用下载卡:按 TikTok 的真实安装状态显示「未安装/下载」或「已安装版本/更新」。 */ function buildAppCard() { var card = W.card(false); var info = Device.tiktok(); var label = U.text("TikTok \u5BA2\u6237\u7AEF", Theme.m.labelSize, Theme.hex.textSecondary, false); var statusView = info.installed ? W.pill("\u5DF2\u5B89\u88C5 " + Device.formatVersion(info.versionName), "ok") : W.pill("\u672A\u5B89\u88C5", "mute"); var action = W.chip(info.installed ? "\u66F4\u65B0" : "\u4E0B\u8F7D", "accent"); action.root.setOnClickListener(function () { installFromLocalApk(info); }); /* 左侧标签用内容宽度,中间用 flexible() 顶开,右侧控件自然靠边 */ var row = U.row(); row.addView(label); row.addView(U.flexible()); row.addView(statusView.root); row.addView(U.hgap(6)); row.addView(action.root); card.addView(row); return card; } /** * 从本机 APK 安装/更新 TikTok。 * * 为什么不做成「点一下自动下载」:没有可依赖的官方下载源, * 硬塞一个第三方地址既不安全也会失效。改成扫描本机已有的 APK, * 让用户自己放包——这是能真正跑通的路径。 */ function installFromLocalApk(info) { var dirs = ["/sdcard/Download", "/sdcard/Download/apk", "/sdcard/apk"]; var found = []; for (var i = 0; i < dirs.length; i++) { var names = []; try { names = files.listDir(dirs[i]); } catch (e) { names = []; } for (var j = 0; j < names.length; j++) { if (/\.apk$/i.test(names[j])) { found.push(dirs[i] + "/" + names[j]); } } } if (found.length === 0) { Dialog.alert("未找到安装包", "把 TikTok 的 APK 放到 /sdcard/Download 下即可在这里安装。\n\n" + (info.installed ? "当前已安装:" + Device.formatVersion(info.versionName) : "当前未安装。")); return; } Dialog.choose({ title: info.installed ? "选择更新包" : "选择安装包", options: found, currentIndex: 0, onConfirm: function (index) { launchInstaller(found[index]); } }); } /** 把选中的 APK 交给系统安装器。 */ function launchInstaller(apkPath) { var ok = false; try { /* app.viewFile 交给系统安装器处理;不用 FileUriExposed 的裸 file:// Intent, * 那在 Android 7+ 会直接抛异常。 */ app.viewFile(apkPath); ok = true; } catch (e) { console.log("[mine] 调起安装器失败: " + e); } toast(ok ? "已交给系统安装器" : "无法调起安装器,请手动点击该 APK"); } /** 版本信息卡:脚本版本 / 更新日志 / 检查更新。 */ function buildVersionCard() { var card = W.card(false); var rows = []; rows.push(W.settingRow("\u811A\u672C\u7248\u672C", monoChip(Service.SCRIPT_VERSION, false).root, 6)); var changelog = W.chip("\u67E5\u770B", "accent"); changelog.root.setOnClickListener(function () { Dialog.alert("更新日志", Service.CHANGELOG.join("\n")); }); rows.push(W.settingRow("\u66F4\u65B0\u65E5\u5FD7", changelog.root, 6)); var statusPill = W.pill("\u68C0\u67E5\u4E2D\u2026", "mute"); rows.push(W.settingRow("\u68C0\u67E5\u66F4\u65B0", statusPill.root, 6)); function refresh() { statusPill.setText("\u68C0\u67E5\u4E2D\u2026"); checkUpdateAsync(function (result) { statusPill.setText(updateText(result)); }); } statusPill.root.setOnClickListener(refresh); /* 进页面自动查一次:设计稿这里直接显示结论(「已是最新」), * 停在「检查中…」会让用户以为卡住了。走独立线程,不阻塞界面。 */ refresh(); W.stack(card, rows, 12); return card; } /** 检查更新的结论文案。集中一处,避免「自动查」和「手动查」两条路径说法不一致。 */ function updateText(result) { if (result.hasUpdate) { return "\u53D1\u73B0 " + result.latest; } if (result.source === "unknown") { return "\u68C0\u67E5\u5931\u8D25"; } return "\u5DF2\u662F\u6700\u65B0"; } /** * 检查更新。 * * ⚠️ 默认路径**必须同步返回**,不能无脑起线程: * 真机实测,`threads.start` + `ui.run` 这条链路里改 View 会直接崩, * 报 `Cannot find function setAndNotify in object P7.b@...`(TextView 的内部通知)。 * 而 updateEndpoint 为空时 checkUpdate() 是纯本地计算、不发网络请求, * 本来就没什么可异步的——起线程只是白白引入跨线程改 UI 的风险。 * 只有真配了远端端点、会发 HTTP 时,才走线程。 */ function checkUpdateAsync(callback) { if (!Service.CONFIG.updateEndpoint) { callback(Service.checkUpdate()); return; } threads.start(function () { var result = Service.checkUpdate(); ui.run(function () { callback(result); }); }); } /** * 我的页。 * 与其它四页不同,本页**不需要 rerender** —— 页内唯一的动态刷新是 * 「检查更新」的状态标签,它是就地改文字(改完不重建,滚动位置才不会丢)。 */ Pages.mine = function () { var inner = U.column(); var blocks = []; blocks.push(W.sectionTitle("\u5361\u5BC6")); blocks.push(buildLicenseCard()); blocks.push(U.spacer(4)); blocks.push(W.sectionTitle("\u5E94\u7528")); blocks.push(buildAppCard()); /* 悬浮球单独一张卡:它是"跨 App 控制入口"的开关, * 混进 TikTok 客户端那张卡里会被当成"TikTok 的设置"(规格书 §10 补的恢复入口) */ blocks.push(U.spacer(4)); blocks.push(W.sectionTitle("\u60AC\u6D6E\u7403")); blocks.push(buildFloatBallCard()); blocks.push(U.spacer(4)); blocks.push(W.sectionTitle("\u5173\u4E8E")); blocks.push(buildVersionCard()); /* 设备信息放在最后一行,排障时一眼能看到当前跑在什么机器上 */ blocks.push(W.hint(Device.deviceLabel() + " \u00B7 " + Device.appVersionName(), false)); /* —— 退出应用 —— * ★ 为什么要有这个入口:以前主界面里**没有**退出,用户想关掉只能按返回或 HOME。 * 而按 HOME 是"退到后台",不是关闭 —— 悬浮球会继续浮在桌面和别的 App 上面 * (它本来就是为了在 TikTok 里操作才存在的)。用户看到球还在,就会认为"关不掉"。 * 给一个明确的出口:停脚本 → 关球 → 退到桌面,三件事一次做完。 * 靠 `events.on("exit")` 和 Activity 轮询也能清,但那些是兜底,不该是用户唯一的办法。 */ blocks.push(U.spacer(8)); var exitLink = W.link("\u9000\u51FA\u5E94\u7528\uFF08\u505C\u811A\u672C \u00B7 \u5173\u60AC\u6D6E\u7403\uFF09", Theme.hex.textMuted, function () { Dialog.exitApp(function () { FB.destroy(); App.exit(); }); }); blocks.push(exitLink); W.stack(inner, blocks, Theme.m.gapCard); var scroller = W.scroller(); scroller.addView(inner); return scroller; }; /* ============================== src/main.js ============================== */ /* ============================== src/main.js ============================== * 入口:搭骨架、路由(tab + 二级页)、运行态、启动流程。 * * 骨架只搭一次(根 FrameLayout + 光晕 + 浮层宿主),之后所有刷新都只重建 body: * · 主界面:全局运行条 / 页面内容 / 开始运行按钮 / 页签栏 四个宿主容器; * · 登录页:整块 body 就是登录内容,没有页签也没有按钮。 * 不整体重建根布局——那会让光晕图层反复重建,肉眼可见地闪。 * * ## 路由只有两层(2026-10-13 改版) * 一级:三个 tab —— 任务 / 记录 / 我的(设计稿底栏只有三个图标) * 二级:任务详情(id/dm/nurture/video 四个任务,各自一页)/ 运行详情(某条记录) * 二级页保留底栏与「开始运行」CTA;运行详情没有 CTA(它只是看,不驱动)。 * * ⚠️ 本脚本必须以 "ui"; 指令开头(build.js 负责写在第一行)。 */ var App = {}; App.ROOT_TAG = "feizhou-root"; App.RUN_CONFIG_PATH = "/sdcard/tiktok_app/run-config.json"; /** 当前显示的是哪一屏:主界面还是卡密登录页。 */ App.screen = "app"; /** * 一级页:tab → 页面构建函数。顺序与 W.TABS 一致。 * * 记录页在这里做「有记录 / 空态」的分流:两者是同一页的两种状态 * (设计稿 D1/D2),分流点放在路由层而不是页面内部 —— * 页面函数只管画自己那一种,不必先判断"我有数据吗"。 */ App.TAB_PAGES = { tasks: function () { return Pages.tasks(); }, records: function () { return Records.list().length > 0 ? Pages.records() : Pages.recordsEmpty(); }, /* 我的页不需要 rerender:页内唯一会变的是「检查更新」的状态标签, * 它是就地改文字,不重建 —— 重建会把滚动位置清零。 */ mine: function () { return Pages.mine(); } }; /** 一级页的标题区文案(设计稿:副标题各不相同)。 */ App.TAB_HEADERS = { tasks: ["\u98DE\u821F\u811A\u672C", "TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u9009\u62E9\u4EFB\u52A1"], records: ["\u98DE\u821F\u811A\u672C", "TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u8FD0\u884C\u8BB0\u5F55"], mine: ["\u98DE\u821F\u811A\u672C", "TikTok \u81EA\u52A8\u5316\u811A\u672C \u00B7 \u6211\u7684"] }; /** 二级页:任务详情。每个任务一页,都是可滚动的长页。 */ App.DETAIL_PAGES = { id: function (rerender) { return Pages.id(rerender); }, dm: function (rerender) { return Pages.dm(rerender); }, nurture: function (rerender) { return Pages.nurture(rerender); }, video: function (rerender) { return Pages.video(rerender); } }; App.body = null; App.overlayHost = null; App.runBar = null; /** * 上一次运行的终态(内存,不落盘)。 * 布局与状态条同位:未运行时它就是那一条"上次运行:…"。 * 不落盘的理由与运行态相同 —— 冷启动后该是干净的。 */ App.lastRunResult = null; /** * 报错态运行条(设计稿 F5 的 30:1119:整条变玫瑰红 + 文案「执行出错已停止」)。 * * 只在「失败结束、且用户还没关掉 F5」这一刻存在。 * 关掉之后就整条隐藏 —— 与规格书 §2.4 的「空闲/完成时整条隐藏」一致, * 但报错那一刻必须留着:它是"这次跑挂了"最直观的证据。 */ App.failBar = null; /** * 二级页的运行详情(记录 id)。**只存内存、不落盘**: * 冷启动后回到"某一条历史记录"没有意义,而记录本身可能已被清理。 */ App.recordId = ""; /** * 引擎"没写终态就没了"的判定宽限期。 * 引擎刚起来时还没出现在引擎列表里,头几秒不判定,免得刚启动就被判成已退出。 */ App.CHILD_GRACE_MS = 4000; /** * 发出停止请求后等引擎确认的上限。 * 超时就当它已经停了:用户按了停止,界面不能一直卡在"正在收尾"上。 */ App.STOP_TIMEOUT_MS = 8000; /** * 本次运行的 runId,在唤起引擎之前就定下来。 * * 单独存一份是因为它要在两处用到 —— 写进 run-config(追溯)与传给引擎(比对), * 而 `Session.start` 只在引擎**已经起来之后**才会被调用,顺序上不能倒过来。 */ App.pendingRunId = ""; /** 本次运行是否已提示过「引擎需要 TikTok 保持前台」。来回切窗口时只提醒一次。 */ App.backgroundWarned = false; /* ------------------------------------------------------------ 光晕背景 */ /** * 复刻设计稿背景的两团径向光晕。 * 用绝对定位的圆形 View 叠在根 FrameLayout 上,位置取自设计稿坐标。 */ function buildGlowLayer() { var layer = new android.widget.FrameLayout(context); layer.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1)); var specs = [ { hex: Theme.hex.accent, alpha: Theme.alpha.glowA, size: 440, x: -40, y: -70 }, { hex: Theme.hex.accentDeep, alpha: Theme.alpha.glowB, size: 420, x: 40, y: 120 } ]; for (var i = 0; i < specs.length; i++) { var spec = specs[i]; var glow = new android.view.View(context); glow.setBackground(U.glowBg(spec.hex, spec.alpha)); var lp = new android.widget.FrameLayout.LayoutParams(U.dp(spec.size), U.dp(spec.size)); lp.leftMargin = U.dp(spec.x); lp.topMargin = U.dp(spec.y); layer.addView(glow, lp); } /* 光晕只是氛围,不能吃掉点击事件 */ layer.setClickable(false); layer.setFocusable(false); return layer; } /* ------------------------------------------------------------ 页面骨架 */ /** * 定高占位块。注意入参是**像素**而不是 dp —— * 状态栏与导航栏高度从系统取回来就已经是 px,再 dp() 一次会放大两倍多。 */ function pxPlaceholder(heightPx) { var view = new android.view.View(context); view.setLayoutParams(new android.widget.LinearLayout.LayoutParams(-1, Math.round(heightPx))); return view; } /** 竖向铺开并撑满剩余高度,供内容区使用。 */ function fillColumn() { var column = U.column(); var lp = new android.widget.LinearLayout.LayoutParams(-1, 0); lp.weight = 1; column.setLayoutParams(lp); return column; } App.build = function () { var root = new android.widget.FrameLayout(context); root.setBackground(U.screenBackground()); root.addView(buildGlowLayer()); App.body = U.column(); App.body.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1)); root.addView(App.body); App.overlayHost = new android.widget.FrameLayout(context); root.addView(App.overlayHost); return root; }; /* ------------------------------------------------------------ 主界面 */ /** * 上一次运行的结论行。点一下消失 —— 确认过了就不必一直占着位置。 */ function buildLastResultRow() { var row = U.row(); row.setPadding(U.dp(Theme.m.contentPadding), U.dp(10), U.dp(Theme.m.contentPadding), 0); var text = U.text("\u4E0A\u6B21\u8FD0\u884C\uFF1A" + (App.lastRunResult.message || ""), 11, Theme.hex.textFaint, false); text.setSingleLine(true); text.setEllipsize(android.text.TextUtils.TruncateAt.END); row.addView(text); row.addView(U.flexible()); row.setOnClickListener(function () { App.lastRunResult = null; App.renderScreen(); }); return row; } /** * 把正在被驱动的目标应用切回前台。 * * 引擎靠"看到 TikTok 在前台"来定位页面,所以**它在跑的时候 TikTok 必须留在前台**。 * 用户切回界面看进度这件事本身很自然,但它会让引擎瞬间"瞎掉"。 * 与其在文档里写"运行中请勿切走"(没人会记得),不如给一个一键切回去的入口。 */ App.backToTarget = function () { var info = Device.tiktok(); if (!info.installed) { toast("\u672A\u68C0\u6D4B\u5230 TikTok \u5BA2\u6237\u7AEF"); return false; } try { app.launchPackage(info.packageName); return true; } catch (e) { console.log("[app] 切回目标应用失败: " + e); toast("\u5207\u56DE TikTok \u5931\u8D25"); return false; } }; /** * 界面回到前台时的提醒。 * * 只在**引擎正在跑**且**本次运行还没提醒过**时说一次: * 用户来回切窗口时反复弹同一个提示是纯打扰。 * * ⚠️ 2026-10-13 文案改了:运行条现在点一下是**跳回运行中的任务详情页** * (规格书 §2.4「运行条 = 状态与导航」),不再是「切回 TikTok」。 * 旧的提示语会让用户点着运行条去找 TikTok,越点越糊涂。 */ App.noticeIfRunningInBackground = function () { if (!Session.running || App.backgroundWarned) { return; } App.backgroundWarned = true; toast("\u8FD0\u884C\u4E2D\uFF1A\u5F15\u64CE\u9700\u8981 TikTok \u4FDD\u6301\u5728\u524D\u53F0\uFF0C" + "\u5207\u5230\u672C\u754C\u9762\u4F1A\u8BA9\u5B83\u8BA4\u4E0D\u51FA\u9875\u9762"); }; /** * 主界面:全局运行条 → 页面内容 → 开始运行 → 页签栏。 * * 运行条放在内容区**上面**(设计稿 §2.4:x=20, y=70, w=335, h=44,状态栏之下): * 它要在任何时候都可见 —— 包括用户切到别的 tab 去看记录的时候, * 这正是"全局单跑"的可见性来源。 */ App.renderAppScreen = function (body) { body.addView(pxPlaceholder(Device.statusBarHeight())); /* 运行条:两种来源,同一个位置。 * ① 正在跑 → 绿点 + 任务名/脚本 + 计时(点击跳回运行中那个任务的详情页); * ② 刚跑挂且用户还没关掉 F5 → 整条玫瑰红 +「执行出错已停止」+「已终止」。 * 空闲/正常完成时整条不存在,连那 10dp 间距也一并省掉 —— * 一个空容器带着 padding 仍然会占位,是很容易忽略的一处空隙。 */ App.runBar = null; var barText = ""; var barTime = ""; var barTone = "ok"; if (Session.running) { barText = Session.barText(); barTime = Session.formatElapsed(Session.elapsedMs()); barTone = Session.stopping ? "warn" : "ok"; } else if (App.failBar) { barText = App.failBar.text; barTime = App.failBar.time; barTone = "danger"; } if (barText !== "") { var statusHost = U.column(); statusHost.setPadding(U.dp(Theme.m.contentPadding), U.dp(10), U.dp(Theme.m.contentPadding), 0); App.runBar = W.runBar(barText, barTime, barTone); /* ★ 状态条整条可点击 = 「跳回运行中的任务」。 * 引擎是靠"看到 TikTok 在前台"来定位页面的;用户切到记录 tab 看一眼, * 前台就变了。与其在文档里写"运行中请勿切走",不如给一个一键跳回去的入口。 */ App.runBar.root.setOnClickListener(function () { App.backToRunning(); }); statusHost.addView(App.runBar.root); body.addView(statusHost); } else if (App.lastRunResult) { /* 未运行但上一轮刚结束:留一行结果。 * 人守在手机前看的是 TikTok 自动操作,跑完那一刻他多半没看着界面 —— * 切回来能立刻知道"上一轮跑成什么样了",而不是面对一个干净的界面猜。 */ body.addView(buildLastResultRow()); } /* 内容区外面套一层 FrameLayout:底部渐隐要**叠**在内容上(设计稿 §6: * y=577 h=80),直接加进竖向 body 会变成另起一行、占掉 80dp 高度。 */ var contentWrap = new android.widget.FrameLayout(context); var wrapParams = new android.widget.LinearLayout.LayoutParams(-1, 0); wrapParams.weight = 1; contentWrap.setLayoutParams(wrapParams); var contentHost = U.column(); contentHost.setLayoutParams(new android.widget.FrameLayout.LayoutParams(-1, -1)); contentHost.setPadding(U.dp(Theme.m.contentPadding), U.dp(Theme.m.contentPadding), U.dp(Theme.m.contentPadding), 0); contentWrap.addView(contentHost); contentWrap.addView(W.bottomFade()); body.addView(contentWrap); var runHost = new android.widget.FrameLayout(context); body.addView(runHost); var tabHost = new android.widget.FrameLayout(context); body.addView(tabHost); /* 底部导航背景横跨整宽,所以背景挂在 tabHost 上而不是 body */ tabHost.setBackground(U.solidBg(U.alphaOf(Theme.hex.tabBar, 0.96))); App.contentHost = contentHost; App.runHost = runHost; App.tabHost = tabHost; App.renderContent(); App.renderRunArea(); App.renderTabs(); }; /** * 重建页面内容区。 * * 三条分支对应三层东西: * ① 运行详情(App.recordId)—— 它自带返回头,不再加标题区; * ② 任务详情(App.detail)—— 返回头 + 该任务的长页; * ③ 一级 tab —— 标题区 + 该 tab 的页面。 * * ★ 重建前先 `commitPendingInputs()`:输入框现在是可以**直接编辑**的常驻控件, * 用户完全可能还停在某一格里就点了别处(切 tab、勾复选框触发重建)。 * 不先提交就重建,那一格的编辑会随视图一起消失 —— 而这正是 * 「界面显示的值」和「真正跑起来的值」不一致的来源。 */ App.renderContent = function () { W.commitPendingInputs(); W.beginPage(); var rerender = function () { ui.run(function () { App.renderContent(); }); }; /* 页面内部(例如「使用文本 ID」勾选后右端多出按钮)需要整页重建时用它 */ Pages.requestRerender = rerender; var host = App.contentHost; host.removeAllViews(); /* —— ② 运行详情 —— */ if (App.recordId) { var detailPage = Pages.recordDetail(Records.find(App.recordId)); U.params(detailPage, -1, 0, 1); host.addView(detailPage); return; } /* —— ② 任务详情 —— */ if (App.detail) { host.addView(W.backHeader(Pages.DETAIL_TITLES[App.detail] || "", function () { App.closeDetail(); }, function () { Device.openPermissionSettings("a11y"); })); host.addView(U.spacer(Theme.m.gapTight)); var builder = App.DETAIL_PAGES[App.detail]; if (!builder) { /* 不认识的详情 key(配置被手改过)→ 直接回任务列表, * 而不是渲染一个空白页让用户以为界面坏了。 */ App.detail = ""; Store.set("activeDetail", ""); App.renderContent(); return; } var page = builder(rerender); var pageParams = new android.widget.LinearLayout.LayoutParams(-1, 0); pageParams.weight = 1; page.setLayoutParams(pageParams); host.addView(page); return; } /* —— ③ 一级 tab —— */ var header = App.TAB_HEADERS[App.activeTab] || App.TAB_HEADERS.tasks; host.addView(W.pageHeader(header[0], header[1], function () { Device.openPermissionSettings("a11y"); })); host.addView(U.spacer(Theme.m.gapPage)); var tabBuilder = App.TAB_PAGES[App.activeTab] || App.TAB_PAGES.tasks; var tabPage = tabBuilder(); var tabParams = new android.widget.LinearLayout.LayoutParams(-1, 0); tabParams.weight = 1; tabPage.setLayoutParams(tabParams); host.addView(tabPage); }; /** * 重建「开始运行 / 停止运行」按钮区。 * * 只有**任务详情页**有这一块(设计稿:B1/B2/D1/D2/D3/E1 都没有)。 * 分工是刻意的:任务卡上的「运行」按钮负责"直接从列表起跑", * 底部 CTA 负责"在详情页调好参数后起跑/停止"。 */ App.renderRunArea = function () { var host = App.runHost; host.removeAllViews(); if (App.recordId || !App.detail) { return; } var wrapper = U.column(); wrapper.setPadding(U.dp(Theme.m.contentPadding), U.dp(12), U.dp(Theme.m.contentPadding), U.dp(12)); var button = W.runButton(function () { App.toggleRun(); }, Session.running ? "\u505C\u6B62\u8FD0\u884C" : "\u5F00\u59CB\u8FD0\u884C"); wrapper.addView(button.root); host.addView(wrapper); }; /** 重建页签栏(含底部安全区)。 */ App.renderTabs = function () { var host = App.tabHost; host.removeAllViews(); var column = U.column(); column.addView(W.tabBar(App.activeTab, function (key) { if (key === App.activeTab) { return; } App.switchTab(key); })); /* 全面屏手势机型 navBarHeight() 返回 0,此时给 24dp 余量, * 否则页签文字会贴到手势条上。 */ var safe = Device.navBarHeight(); column.addView(pxPlaceholder(safe > 0 ? safe : U.dp(24))); host.addView(column); }; /** 全屏重建。切屏与运行态变化都走它,不在别处零散重建。 */ App.renderScreen = function () { var body = App.body; body.removeAllViews(); if (App.screen === "login") { body.addView(pxPlaceholder(Device.statusBarHeight())); var login = Pages.login(); var lp = new android.widget.LinearLayout.LayoutParams(-1, 0); lp.weight = 1; login.setLayoutParams(lp); body.addView(login); return; } App.renderAppScreen(body); }; App.switchTab = function (key) { /* 切 tab = 退出二级页:底栏是"这一层"的导航, * 按了它还停在详情页里会让人以为导航坏了。 */ App.detail = ""; App.recordId = ""; Store.set("activeDetail", ""); App.activeTab = key; Store.set("activeTab", key); App.renderScreen(); console.log("[app] 切换到 tab " + key); }; /* ------------------------------------------------------------ 二级页导航 */ /** 进某个任务的详情页(C1/C6/C8/C9)。 */ App.openDetail = function (taskKey) { if (!App.DETAIL_PAGES[taskKey]) { return; } App.recordId = ""; App.detail = taskKey; if (App.activeTab !== "tasks") { App.activeTab = "tasks"; Store.set("activeTab", "tasks"); } Store.set("activeDetail", taskKey); App.renderScreen(); console.log("[app] 打开任务详情 " + taskKey); }; App.closeDetail = function () { App.detail = ""; Store.set("activeDetail", ""); App.renderScreen(); }; /** 进某条运行记录的详情(D3)。 */ App.openRecord = function (recordId) { if (!recordId) { return; } App.recordId = recordId; App.detail = ""; if (App.activeTab !== "records") { App.activeTab = "records"; Store.set("activeTab", "records"); } Store.set("activeDetail", ""); App.renderScreen(); }; App.closeRecord = function () { App.recordId = ""; App.renderScreen(); }; /** * 点运行条 → 跳回**正在运行的那个任务**的详情页。 * * 为什么是"跳回任务页"而不是"切回 TikTok":运行条是全局的, * 用户在记录 tab 上看到它、点一下,最想问的是"谁在跑 / 跑成什么样了"。 * 至于把 TikTok 拉回前台 —— 那是**引擎自己的事**(它必须待在前台), * 界面这一下只解决"我在哪"。 */ App.backToRunning = function () { if (Session.running && Session.tab) { App.openDetail(Session.tab); return; } /* 报错态的那一下:直接去看日志(F5 的「查看日志」走的是同一个入口) */ var failedId = App.failBar ? App.failBar.recordId : ""; App.failBar = null; if (failedId) { App.openRecord(failedId); return; } App.renderScreen(); }; /* ------------------------------------------------------------ 运行态 */ /** * 每秒刷新计时,并顺带做两件巡检。 * * 只改状态条上的那两个 TextView,不重建界面 —— * 每秒重建整页会让滚动位置归零,用户根本没法边跑边看配置。 * * 两件巡检: * ① 卡密到期(设计稿 F4):到期那一刻停掉运行并弹窗,而不是等用户下一次操作才发现。 * ② **引擎状态**:这是"界面上的运行态"与"真正在跑的东西"唯一的一致性来源。 * 两者都放在这里而不是另起定时器,因为「运行中」本来就是它们唯一需要的触发条件。 */ App.tickTimer = function () { if (!Session.running) { return; } if (Service.licenseStatus().status !== "active") { /* 先让引擎停手(否则它会继续操作 TikTok),然后**立刻复位界面**: * 这一刻要传递的信息是"你已经不能跑了",不是"正在收尾", * 再挂一个转圈的运行态反而让人以为还有救。 */ Engine.requestStop(Session.runId); Session.stop(); Session.stopTimer(); App.renderScreen(); Dialog.licenseExpired(function () { App.showLogin(); }); return; } App.pollEngine(); FB.sync(); /* pollEngine 可能已经把运行结束了(终态),此时状态条已经不存在 */ if (App.runBar && Session.running) { App.runBar.setElapsed(Session.formatElapsed(Session.elapsedMs())); App.runBar.setText(Session.barText()); App.runBar.setTone(Session.stopping ? "warn" : "ok"); } }; /** * 读引擎上报的状态,驱动界面的运行态。 * * ★ 必须比对 runId:状态文件是设备上的共享文件,上一轮运行留下的内容还在。 * 不比对就会出现「刚点开始运行,界面立刻显示上一轮的进度」。 */ App.pollEngine = function () { var state = Engine.readState(); if (state && state.runId === Session.runId) { /* 看到过它,才有资格在后来说"它不见了" —— 见下面的兜底判定 */ if (Engine.childAlive()) { Engine.sawChild = true; } if (Engine.isTerminalStatus(state.status)) { App.finishRun(state); return; } if (state.phase) { Session.phase = state.phase; } Session.done = state.done || 0; Session.total = state.total || 0; if (state.status === "stopping") { Session.stopping = true; } } else if (Engine.childAlive()) { /* 引擎在跑但还没写出状态:正常的启动间隙,什么都不做 */ Engine.sawChild = true; } /* —— 兜底一:点了停止但引擎迟迟不确认 —— */ if (Session.stopping && Date.now() - Session.stopRequestedAt > App.STOP_TIMEOUT_MS) { App.finishRun({ status: "stopped", message: "\u5DF2\u53D1\u51FA\u505C\u6B62\u8BF7\u6C42\uFF08\u5F15\u64CE\u672A\u786E\u8BA4\uFF09" }); return; } /* —— 兜底二:引擎没写终态就没了(被系统杀掉 / 被人手工停掉)—— * 只在"确实看到过它、现在它不见了"时判定。`engines.all()` 在不同运行时里 * 的可见性不完全一致,没看到过就当作"查不出来",宁可多显示一会儿运行中, * 也不要误判成已结束把运行态清掉。 */ if (!Session.stopping && Engine.sawChild && Date.now() - Session.startedAt > App.CHILD_GRACE_MS && !Engine.childAlive()) { App.finishRun({ status: "failed", message: "\u5F15\u64CE\u5DF2\u9000\u51FA\uFF08\u672A\u6536\u5230\u7ED3\u675F\u4FE1\u53F7\uFF09" }); } }; /** * 把界面拉回前台。 * * 引擎跑起来时前台是 TikTok(它在那边操作),用户看不到界面上的进度; * 跑完把界面切回来,用户才不用自己去最近任务里把它翻出来。 * * 为什么不用 `app.launchPackage(包名)`:那会打开 AutoX 的**主界面**, * 而不是"正在运行的这个脚本界面"。用当前 Activity 的类名 + REORDER_TO_FRONT * 才能精准命中同一个界面;它本来就在前台时,该调用无副作用。 * * 失败不抛:拉不回前台只是少了点便利,不该影响"运行已结束"这件正事。 */ App.bringToFront = function () { try { var className = activity.getClass().getName(); app.startActivity({ action: "android.intent.action.VIEW", packageName: String(context.getPackageName()), className: className, flags: android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT }); return true; } catch (e) { console.log("[app] 拉回前台失败: " + e); return false; } }; /** * 结束运行态。 * * 顺序有讲究:先**攒一条运行记录**(此刻 Session 里还有本次运行的信息, * `Session.stop()` 一跑就全没了),再复位状态并重建界面(状态条消失、 * 按钮变回"开始运行"),然后把界面拉回前台,最后提示结果 —— * 反过来的话提示会挂在一个还没被拆掉的界面上。 * * 失败终态额外做两件事(规格书 §2.3 / §2.4): * · 运行条留在原地**变成玫瑰红**(「执行出错已停止」+「已终止」), * 用户关掉 F5 之后才隐藏; * · 弹 F5,并把「查看日志」接到刚写下的那条记录上。 */ App.finishRun = function (state) { var result = { status: state.status || "failed", message: state.message || "", summary: state.summary || null }; /* ★ 记录必须在 Session.stop() 之前攒:任务/脚本/起始时间都在 Session 里。 */ var record = App.writeRecord(result); App.lastRunResult = result; Session.stop(); Session.stopTimer(); if (result.status === "failed" && record) { App.failBar = { text: Pages.shortScriptName(record.scriptName) + " \u00B7 \u6267\u884C\u51FA\u9519\u5DF2\u505C\u6B62", time: "\u5DF2\u7EC8\u6B62", recordId: record.id }; } else { App.failBar = null; } App.renderScreen(); FB.sync(); /* 失败 → 红环;正常 → 回空闲无环 */ App.bringToFront(); if (result.status === "failed") { Dialog.scriptError(result.message || "", function () { /* 「查看日志」→ 清掉报错条,再进 D3 */ var id = App.failBar ? App.failBar.recordId : ""; App.failBar = null; if (id) { App.openRecord(id); } else { App.renderScreen(); } }, function () { /* 「知道了」→ 报错条也随之消失(这一次的事已经被告知过了) */ App.failBar = null; App.renderScreen(); }); console.log("[app] 运行结束(失败):" + result.message); return; } /* toast 而不是弹窗:万一前台没能切回来(用户在别的应用里), * 系统 Toast 仍然看得见;而弹窗会挡着用户正在做的事。 */ toast(result.message || "\u8FD0\u884C\u7ED3\u675F"); console.log("[app] 运行结束:" + result.status + " / " + result.message); }; /** * 把这次运行写进运行记录(D1/D3 的数据来源)。 * * 只写**真实知道的东西**:任务/脚本来自 Session,结果文案来自引擎终态, * 日志来自引擎的 --log 通道(`Engine.readLog()`)。引擎没回传的字段留空, * D3 那边对应的一行就不渲染 —— 不编一个看起来合理的值。 */ App.writeRecord = function (result) { if (!Session.tab) { return null; } var endedAt = Date.now(); var record = Records.add({ taskKey: Session.tab, taskName: Pages.taskName(Session.tab), scriptKey: Session.scriptKey, scriptName: Session.scriptName, result: Records.resultOf(result.status).key, startedAt: Session.startedAt, endedAt: endedAt, durationMs: Math.max(0, endedAt - Session.startedAt), resultText: result.message || "", resultFile: result.summary && result.summary.resultFile ? result.summary.resultFile : "", /* ID提取勾了「同时解析数字 uid」时,引擎还会另写一份 username→uid 的映射文件。 * 以前这里只传 resultFile,映射文件虽然在手机上,界面上却看不到路径、 * 点不开也分享不了 —— 等于白生成。 */ mapFile: result.summary && result.summary.mapFile ? result.summary.mapFile : "", /* runId:记录页要靠它和断点里的 runId 对上,才知道"这条记录就是那个没跑完的"。 * 不带的话,一串历史「手动停止」记录上都会冒出「继续」。 */ runId: Session.runId || "", logs: Engine.readLog() }); console.log("[records] 已记录本次运行:" + record.id + " / " + record.result); return record; }; /** * 取当前任务详情页里选中的那个脚本,没选返回 null。 * * 从 v6 起「当前任务」= **当前打开的那个任务详情页**(App.detail)。 * 一级页(任务列表)没有"当前任务"这回事 —— 四张卡各有各的「运行」按钮, * 各带各的 taskKey(见 App.requestRun)。这正是"每个任务单独启动"的落点。 */ App.currentTask = function () { /* ★ 读配置之前先把「用户可能还在编辑」的输入框内容落地。 * Android 里点一个不可聚焦的按钮**不会**让 EditText 失焦, * 所以「按开始运行」这一刻栏位内容很可能还没进配置。 */ W.commitPendingInputs(); if (!App.detail) { return null; } return Pages.checkedScript(App.detail); }; /** * 把当前配置落盘成交给执行侧的运行配置。 * * 这里写的是**给人看、给事后追溯用**的快照(含界面完整配置、本次生效的引擎配置、 * 以及"哪些勾了但本次不会生效")。引擎真正读的是 engine-config.json —— * 分成两份是因为两边的读者不同:一个排查问题的人需要全貌,引擎只需要它能懂的那部分。 */ App.exportRunConfig = function (checked, plan) { var payload = { generatedAt: Service.today(), scriptVersion: Service.SCRIPT_VERSION, licenseStatus: Service.licenseStatus().status, tasks: checked, engine: { /* ★ 用 pendingRunId 而不是 Session.runId: * 本函数在 `Session.start()` **之前**被调用,那时会话里还是上一轮的 runId。 * 记错这一次的标识,事后追溯就会被误导到别的运行记录上。 */ runId: App.pendingRunId, scriptPath: plan ? plan.scriptPath : "", configPath: Engine.CONFIG_PATH, statePath: Engine.STATE_PATH, stopFlagPath: Engine.STOP_FLAG_PATH, config: plan ? plan.config : null, notes: plan ? plan.notes : [] }, config: Store.current(), /* ★ v8:取材清单**不在** `Store.FIELDS` 里(它是不定长列表,走独立 storage key), * 所以 `config: Store.current()` 里没有它。但"这一轮到底从哪取的材" * 恰恰是事后追溯最需要的一条,必须单独带上。 */ sources: Sources.list() }; try { var dir = App.RUN_CONFIG_PATH.substring(0, App.RUN_CONFIG_PATH.lastIndexOf("/")); files.ensureDir(dir + "/"); files.write(App.RUN_CONFIG_PATH, JSON.stringify(payload, null, 2)); return true; } catch (e) { console.log("[app] 运行配置落盘失败: " + e); return false; } }; /** * **所有**「开始运行」的唯一入口(底部 CTA 与四张任务卡上的「运行」都走它)。 * * 前置校验顺序**照规格书 §2.5 实现**,任一步不过就拦截: * ① 卡密有效? → 否:不做提示,直接回 A1 * ② 无障碍+悬浮窗?→ 否:弹 F2 * ③ 已选脚本? → 否:弹 F1 * ④ 引擎接入? → 否:弹「引擎尚未接入」(**本项目自己的补丁**: * 规格书假设所有脚本都已实现,而这里有四个任务还没接引擎。 * 静默跑一个不相关的东西比明确告知糟糕得多,所以保留这一关) * ⑤ 已有脚本在跑? → 是:弹 F3「停止并开始」;否:直接启动 * * 顺序不能随手改:卡密排第一是因为它不通时**别的都无从谈起**(且要回登录页), * 权限排第二是因为它比"选脚本"更难自己发现(缺口在系统设置里)。 * * @param taskKey 要运行哪个任务(任务卡上的按钮直接给,不必先切到那个页) */ App.requestRun = function (taskKey, opts) { /* ① 卡密:失效就直接回登录页,不弹提示 —— 那里才有可做的事 */ if (Service.licenseStatus().status !== "active") { App.showLogin(); return; } /* ② 阻断性权限(无障碍 + 悬浮窗) */ if (!Device.blockingPermissionsGranted()) { Dialog.permissionBlocked(); return; } /* ③ 该任务当前选中的脚本 */ var script = Pages.checkedScript(taskKey); if (!script) { /* v8:ID提取没有"脚本"了,它的空态是"还没添加取材" —— * 对用户说"请先选择要运行的脚本"会让他到处找那不存在的脚本。 */ Dialog.runBlocked(taskKey === "id" ? "\u8BF7\u5148\u6DFB\u52A0\u4E00\u6761\u53D6\u6750" : "\u8BF7\u5148\u9009\u62E9\u8981\u8FD0\u884C\u7684\u811A\u672C"); return; } /* ④ 引擎是否接入(见函数头注释) */ if (!script.engine) { Dialog.engineMissing(script.name); return; } /* ⑤ 全局单跑:运行中点别的任务不禁用按钮,而是弹 F3 确认(规格书 §2.3) */ if (Session.running) { Dialog.switchTask(Pages.taskName(Session.tab), Pages.taskName(taskKey), function () { App.startRun(script, opts); }); return; } /* ⑦ 有没跑完的断点、而用户点的是普通「开始运行」→ 先问一句。 * * 不问的话,用户会静默丢掉上一轮的进度(断点被新一轮覆盖), * 而且界面上完全看不出来发生过这件事。用「选一个」而不是「确认/取消」: * 两个选项都是用户可能真心想要的,只给"确定/取消"会让人没法表达"我要从零开始"。 * * 只有 ID 提取有断点,别的任务直接走原路。 */ if (taskKey === "id" && !(opts && opts.resume)) { var pending = Resume.snapshot(); if (pending.valid) { Dialog.choose({ title: "\u4E0A\u6B21\u8FD0\u884C\u8FD8\u6CA1\u8DD1\u5B8C", options: [ "\u7EE7\u7EED\u4E0A\u6B21\uFF08\u5DF2\u6536 " + pending.extracted + " \u4E2A\uFF09", "\u4ECE\u96F6\u5F00\u59CB\uFF08\u65AD\u70B9\u4F5C\u5E9F\uFF09" ], currentIndex: 0, onConfirm: function (index) { if (index === 0) { App.resumeRun(); return; } App.startRun(script, opts); } }); return; } } App.startRun(script, opts); }; /** * 继续上次没跑完的那一轮(提示条、记录行、记录详情都走这里)。 * * **不自己重跑一遍卡密/权限检查** —— 交给 `requestRun` 统一做。 * 续跑如果绕过卡密校验,就等于开了一个"卡密过期也能跑"的后门。 * * @param record 可选。从某条运行记录点进来时传,用来核对"这条记录是不是那个断点" */ App.resumeRun = function (record) { if (Session.running) { toast("\u5DF2\u6709\u4EFB\u52A1\u5728\u8FD0\u884C\uFF0C\u8BF7\u5148\u505C\u6B62"); return; } var snap = Resume.snapshot(); if (!snap.exists || snap.finished || snap.reason === "cleared") { Dialog.alert("\u6CA1\u6709\u53EF\u7EE7\u7EED\u7684\u8FD0\u884C", "\u4E0A\u6B21\u7684\u8FD0\u884C\u5DF2\u7ECF\u7ED3\u675F\uFF0C\u6216\u65AD\u70B9\u5DF2\u88AB\u6E05\u7A7A\u3002"); return; } if (!snap.valid) { Dialog.alert("\u65AD\u70B9\u65E0\u6CD5\u7EE7\u7EED", (snap.message || "\u65AD\u70B9\u5DF2\u5931\u6548") + "\n\n\u6E05\u7A7A\u65AD\u70B9\u540E\u53EF\u4EE5\u91CD\u65B0\u5F00\u59CB\u3002"); return; } /* 从记录点进来时必须核对 runId:断点只保留最后一份,可能是后来那一轮的 */ if (record && (!record.runId || !snap.cp || record.runId !== snap.cp.runId)) { Dialog.alert("\u8FD9\u6761\u8BB0\u5F55\u4E0D\u80FD\u7EE7\u7EED", "\u65AD\u70B9\u5DF2\u88AB\u4E4B\u540E\u7684\u8FD0\u884C\u8986\u76D6\u3002"); return; } App.requestRun("id", { resume: true }); }; /** 清空断点(覆盖成 state:'none',不删文件) */ App.clearCheckpoint = function () { if (!Resume.clear()) { toast("\u6E05\u7A7A\u65AD\u70B9\u5931\u8D25"); return; } App.renderScreen(); toast("\u65AD\u70B9\u5DF2\u6E05\u7A7A\uFF0C\u4E0B\u6B21\u4ECE\u5934\u5F00\u59CB"); }; /** * 底部 CTA 的唯一入口:没在跑就尝试开始(跑当前详情页那个任务的脚本),在跑就停。 * 校验逻辑与任务卡共用 `App.requestRun`,避免两处各写一遍顺序。 */ App.toggleRun = function () { if (Session.running) { App.stopRun(); App.renderScreen(); return; } if (!App.detail) { return; } App.requestRun(App.detail); }; /** * 真正进入运行态:准备引擎 → 落盘配置 → **唤起引擎** → 建状态条 → 起计时。 * * 顺序不能换:配置必须先落盘(引擎一起来就读它), * 计时必须在唤起成功之后才开始(否则引擎没起来,界面却在计时,是最难解释的一种状态)。 */ App.startRun = function (task, opts) { var plan = Engine.prepare(task, opts); if (!plan.ok) { Dialog.engineUnavailable(plan.error); return; } /* runId 要在写配置之前定下来:它会被同时写进 run-config(追溯)与引擎参数(比对) */ App.pendingRunId = Engine.newRunId(); if (!App.exportRunConfig([{ tab: task.task, script: task.key, name: task.name }], plan)) { toast("\u8FD0\u884C\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650"); return; } if (!Engine.writeConfig(plan.config)) { toast("\u5F15\u64CE\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u5B58\u50A8\u6743\u9650"); return; } var launched = Engine.launch(App.pendingRunId, plan.scriptPath, opts); if (!launched.ok) { Dialog.engineUnavailable("\u5F15\u64CE\u542F\u52A8\u5931\u8D25\uFF1A" + launched.error); return; } Session.start(task.task, task.key, task.name, App.pendingRunId); Engine.sawChild = false; App.lastRunResult = null; App.failBar = null; App.backgroundWarned = false; /* 运行条要能点击跳回**正在跑的那个任务**,所以顺手把它带进那个任务的详情页: * 用户刚点了「运行」,看到的应该是"它跑起来了",而不是停在列表上猜。 */ if (App.detail !== task.task || App.activeTab !== "tasks") { App.recordId = ""; App.detail = task.task; App.activeTab = "tasks"; Store.set("activeTab", "tasks"); Store.set("activeDetail", task.task); } console.log("[app] 已唤起引擎 " + plan.scriptPath + " runId=" + App.pendingRunId); App.renderScreen(); FB.sync(); /* 球的外环变青绿 + 开始呼吸(规格书 §10.4) */ Session.startTimer(App.tickTimer); /* notes 是"你勾了、但引擎这次做不到"的项。**如实告知**, * 完整内容也写进了 run-config.json 供事后核对。 */ var summary = Engine.summarizeNotes(plan.notes); if (summary) { toast(summary); } }; /** * 停运行。 * * 分两步、且**不立刻复位**:先向引擎发出停止请求,界面进入「正在收尾」, * 等引擎上报 stopped(或超时)才真正结束。 * * 为什么不做成"点一下就当停了":引擎收到请求后还要退出当前等待、把 TikTok * 带回信息流,通常 1 秒内完成,但它确实还在动。这时界面说「已停止」, * 用户看到 TikTok 仍在操作,只能怀疑"到底停没停"。 * * **不负责重建界面** —— 调用方要么接着切屏,要么自己补一次重建。 */ App.stopRun = function () { if (!Session.running) { return; } if (Session.stopping) { return; } if (Engine.requestStop(Session.runId)) { Session.stopping = true; Session.stopRequestedAt = Date.now(); console.log("[app] 已发出停止请求 runId=" + Session.runId); return; } /* 写标志都失败了就没什么可等的,直接复位 */ App.finishRun({ status: "stopped", message: "\u5DF2\u505C\u6B62" }); }; /* ------------------------------------------------------------ 权限弹窗 */ /** 打开权限弹窗。弹窗自己也保证同时只存在一个,这里再挡一道更直观。 */ App.openPermissions = function () { if (Dialog.active) { return; } Dialog.showPermissions(); }; /* ------------------------------------------------------------ 卡密登录 */ /** 切到登录页。重置三态,避免把上一次的失败提示带过来。 */ App.showLogin = function () { App.screen = "login"; /* 登录页不该有球:卡密都没了就没什么可控制的,留在屏幕上只是挡路 */ FB.destroy(); Pages.LICENSE_RESET(); App.renderScreen(); }; /** 登录成功:回主界面,并顺手把还缺的权限提示一次。 */ App.enterApp = function () { App.screen = "app"; /* 进主界面就把悬浮球挂上(它只在"卡密有效 + 有悬浮窗权限"时才会真的建,见 FB.canShow) */ FB.ensure(); /* 把登录页三态复位。不复位的话它会停在 verifying —— * 一旦有别的路径重新渲染登录页(而不是走 showLogin), * 用户看到的就是一个永远「登录中…」的界面。 */ Pages.LICENSE_RESET(); App.renderScreen(); if (!Device.allPermissionsGranted()) { App.openPermissions(); } }; /** * 提交卡密。 * * 为什么中间要留 450ms:校验走本地时是毫秒级的,界面会从「登录激活」 * 直接跳到主界面,用户根本看不到反馈——会以为按钮没反应于是连点。 * 留这一帧让「登录中…」真的被画出来,是**交互正确性**而不是动画点缀。 */ App.submitLicense = function (code) { var view = Pages.LICENSE_VIEW; if (typeof code !== "string" || code.replace(/[\s-]/g, "") === "") { toast("\u8BF7\u8F93\u5165\u5361\u5BC6"); return; } view.state = "verifying"; App.renderScreen(); setTimeout(function () { var result = Service.login(code); if (result.ok) { Store.set("license.status", result.status); Store.set("license.code", result.code); Store.set("license.expireAt", result.expireAt); /* ★ 这里**不要**去清 `license.draftCode`。 * 试过「激活成功后清空草稿」,真机上立刻被打回来: * `App.enterApp()` → `renderContent()` 会先跑 `commitPendingInputs()`, * 而登录页那个输入框此刻**还在待提交表里**,于是刚清掉的草稿 * 又被它自己的文字写回去(顺序反了,清理等于没做)。 * 保留草稿也更顺手:解绑后回到登录页,上次的卡密已经填好,一键即可重新激活; * 它和 `license.code` 本来就在同一个 JSON 里,多留一份不增加暴露面。 */ toast(result.message); App.enterApp(); return; } if (result.status === "expired") { Store.set("license.status", "expired"); Store.set("license.expireAt", result.expireAt || ""); } view.state = "failed"; view.message = result.message; App.renderScreen(); }, 450); }; /** 购买入口。没有可用的客服渠道就如实说,不编一个不存在的联系方式。 */ App.showPurchaseInfo = function () { Dialog.alert("\u8D2D\u4E70\u5361\u5BC6", "\u5F53\u524D\u672A\u914D\u7F6E\u5728\u7EBF\u8D2D\u4E70\u6E20\u9053\u3002\n\n" + "\u914D\u7F6E\u540E\uFF0C\u8FD9\u91CC\u4F1A\u76F4\u63A5\u6253\u5F00\u5BA2\u670D\u4F1A\u8BDD\u6216\u8D2D\u4E70\u9875\u3002"); }; /** 退出应用。优先用 ui.finish(),它才是 UI 模式下的正确出口。 */ App.exit = function () { /* 退出前必须关掉悬浮窗:脚本结束不会自动带走它,留着就是一个"孤儿球" */ FB.destroy(); try { if (typeof ui.finish === "function") { ui.finish(); return; } } catch (e) { console.log("[app] ui.finish 不可用: " + e); } try { activity.finish(); } catch (e2) { console.log("[app] 退出失败: " + e2); } }; /* ------------------------------------------------------------ 结果文件 / 日志动作 */ /** * 打开结果文件(D3 的「打开」)。 * * 文件可能已经不在了(被清理/被移走),所以先 exists 再动手 —— * 直接 startActivity 一个不存在的 file:// 在部分 ROM 上会抛 FileUriExposedException。 */ App.openResultFile = function (path) { if (!path) { return; } try { if (!files.exists(path)) { Dialog.alert("\u6587\u4EF6\u4E0D\u5B58\u5728", "\u8FD9\u4E2A\u7ED3\u679C\u6587\u4EF6\u5DF2\u4E0D\u5728\u672C\u673A\uFF1A\n\n" + path); return; } app.viewFile(path); } catch (e) { console.log("[app] 打开结果文件失败: " + e); toast("\u65E0\u6CD5\u6253\u5F00\u8BE5\u6587\u4EF6"); } }; /** 分享结果文件(D3 的「分享」)。系统分享面板由 ROM 提供,不自己造一个。 */ App.shareResultFile = function (path) { if (!path) { return; } try { if (!files.exists(path)) { toast("\u6587\u4EF6\u4E0D\u5B58\u5728\uFF0C\u65E0\u6CD5\u5206\u4EAB"); return; } app.sendIntent({ action: "android.intent.action.SEND", type: "*/*", extra: { "android.intent.extra.STREAM": path } }); } catch (e) { console.log("[app] 分享失败: " + e); toast("\u5206\u4EAB\u5931\u8D25"); } }; /** * 复制整段日志(D3 的「复制」)。 * * 拼成纯文本而不是 JSON:用户多半是要粘到聊天窗口里给人看, * 带花括号的 JSON 反而难读。 */ App.copyLogs = function (record) { if (!record) { return; } var logs = record.logs || []; if (logs.length === 0) { toast("\u672C\u6B21\u8FD0\u884C\u6CA1\u6709\u65E5\u5FD7"); return; } var lines = []; for (var i = 0; i < logs.length; i++) { var e = logs[i]; lines.push(String(e.text || "")); } var text = Pages.recordTitle(record) + "\n" + Records.timeLine(record) + "\n\n" + lines.join("\n"); try { /* setClip 是 AutoX 的全局函数;桩测环境没有它,所以带类型判断 */ if (typeof setClip === "function") { setClip(text); toast("\u65E5\u5FD7\u5DF2\u590D\u5236"); } else { toast("\u5F53\u524D\u8FD0\u884C\u65F6\u4E0D\u652F\u6301\u526A\u8D34\u677F"); } } catch (e2) { console.log("[app] 复制日志失败: " + e2); toast("\u590D\u5236\u5931\u8D25"); } }; /* ------------------------------------------------------------ 启动 */ /** * 挂载根视图。 * ui.setContentView 是标准 API;若运行时确实没有,宁可明确报错, * 也不要退回一个半可用的挂载方式——那样界面起不来还找不到原因。 */ App.mount = function (root) { if (typeof ui.setContentView !== "function") { throw new Error("当前 AutoX 运行时缺少 ui.setContentView,无法挂载界面"); } ui.setContentView(root); }; /** * 界面被关掉时,把悬浮球一起带走。 * * ★ 为什么必须有这个:AutoX 的 UI 模式下**「Activity 关闭」不等于「脚本结束」**。 * 界面脚本跑在 `:script` 进程的线程里,用户从最近任务划掉界面(或按返回退出)后, * 脚本仍在跑 —— 而悬浮球窗口是**脚本**持有的,于是球留在别的 App 上面, * 看起来就是"应用都关了,球还浮着"。 * 实测(2026-10-22):前台已是 AutoX 的 MainActivity、`:script` 进程存活、 * dumpsys 里还有 8 个 autojs 窗口残留。 * * 两条兜底,各自覆盖一种情况: * ① `events.on("exit")` —— 脚本正常结束或抛异常时清一次。 * 进程被系统杀掉时走不到这里,但那种情况窗口会随进程一起被回收,不用我们管。 * ② 轮询 `activity` 是否已 finish —— **唯一能覆盖"界面关了但脚本还活着"的路径**, * 也就是用户报的这个场景。 */ function bindLifecycleCleanup() { try { events.on("exit", function () { try { FB.destroy(); } catch (e) { /* 退出路径上不能再抛 */ } }); } catch (e) { console.log("[app] events.on(exit) 不可用(不影响其它清理路径): " + e); } /* 只有"曾经拿到过 activity、后来它没了/在销毁"才算关闭。 * 这样在 PC 侧 smoke(没有 activity 这个全局)里不会误触发 exit()。 */ var sawActivity = false; App.lifecycleTick = setInterval(function () { var gone = false; try { if (typeof activity !== "undefined" && activity !== null) { sawActivity = true; gone = activity.isFinishing() || activity.isDestroyed(); } else if (sawActivity) { gone = true; } } catch (e) { /* 拿 activity 时抛异常 = 它已经没了 */ gone = sawActivity; } if (!gone) { return; } try { clearInterval(App.lifecycleTick); } catch (ignored) { } App.lifecycleTick = null; console.log("[app] 界面已关闭,清理悬浮球并结束脚本"); try { FB.destroy(); } catch (e2) { /* 同上 */ } try { exit(); } catch (e3) { /* 老版本 AutoX 可能没有 exit() */ } }, 1500); } App.main = function () { Store.init(); /* v8:把旧的四张卡配置翻译成「取材清单」(一次性,已有清单就不动)。 * * 为什么放在这里而不是 Store.load 里:Store 不该知道 Sources 存在 * (清单是另一个储物柜),而且这里能保证**所有模块都已加载完**再动它。 * 迁移失败不许拦住界面 —— 最坏情况是用户看到空清单,自己加一条即可。 */ try { var migrated = Sources.migrateLegacy(); if (migrated && migrated.length > 0) { console.log("[app] 旧的四张卡配置已翻译成 " + migrated.length + " 条取材"); } } catch (eMig) { console.log("[app] 取材清单迁移失败(不影响使用,清单为空时可手动添加): " + eMig); } /* getEnum 保证拿回合法 tab key:旧配置里存了已删除的页签也不会白屏。 * 老页签(id/dm/nurture/video)由 Store.MIGRATIONS[5] 映射成 tasks(见 store.js)。 */ var configuredTab = Store.getEnum("activeTab", "tab"); App.activeTab = App.TAB_PAGES[configuredTab] ? configuredTab : "tasks"; /* 二级页也要能冷启动恢复 —— 但必须是**认识的那个任务**。 * 配置被手改过时回退到一级页,而不是渲染一个空白详情页。 */ var configuredDetail = Store.getEnum("activeDetail", ["", "id", "dm", "nurture", "video"]); App.detail = App.DETAIL_PAGES[configuredDetail] ? configuredDetail : ""; /* 返回键:有弹窗就先关弹窗,其次退二级页,都没有则交给系统(退出界面)。 * 用 emitter 而不是 View.OnKeyListener —— 后者要构造一个 Java 接口实例, * 写法依赖运行时的接口包装能力,换个 AutoX 版本就可能失效。 */ ui.emitter.on("back_pressed", function (event) { if (Dialog.active) { event.consumed = true; Dialog.active.close(); return; } if (App.recordId) { event.consumed = true; App.closeRecord(); return; } if (App.detail) { event.consumed = true; App.closeDetail(); } }); /* 界面回到前台时提醒一句(只在引擎正在跑时)。 * 这是 UI 生命周期事件,不是轮询:用户切走再切回才会触发,代价为零。 */ ui.emitter.on("resume", function () { App.noticeIfRunningInBackground(); /* 补建:用户多半是先去系统设置里开了悬浮窗权限、再切回来的。 * ensure() 幂等,已经建好时只做一次状态同步。 */ FB.ensure(); }); ui.statusBarColor(U.argb(Theme.hex.bg)); App.mount(App.build()); /* 弹窗需要一个浮层宿主,骨架搭好之后注入 */ Dialog.mount(App.overlayHost); /* 启动时的屏:卡密没激活就直接进登录页,不让用户先进一个什么都点不动的主界面 */ App.screen = Service.licenseStatus().status === "active" ? "app" : "login"; /* 冷启动也是"要用球"的场景:已激活时直接把它挂上(未激活时 canShow 自己会拦住) */ FB.ensure(); /* 界面关了要把球带走 —— 见 bindLifecycleCleanup 的注释 */ bindLifecycleCleanup(); App.renderScreen(); console.log("[app] 界面已就绪,screen=" + App.screen + " 页签=" + App.activeTab); console.log("[app] 无障碍=" + Device.a11yEnabled() + " 悬浮窗=" + Device.overlayEnabled() + " 通知=" + Device.notifyEnabled()); if (App.screen === "app" && !Device.allPermissionsGranted()) { App.openPermissions(); } /* 落盘一份启动结论,便于 PC 侧离线核验(日志链路可能丢) */ try { files.write("/sdcard/tiktok_app_ui_result.txt", "OK_UI_STARTED\nscreen=" + App.screen + "\ntab=" + App.activeTab + "\na11y=" + Device.a11yEnabled() + "\nlicense=" + Service.licenseStatus().status + "\ntiktok=" + JSON.stringify(Device.tiktok())); } catch (e) { console.log("[app] 结果文件写入失败: " + e); } }; /* --------------------------------------------------------------------------- * 测试钩子(规范 R16:让纯函数与内部状态可被单独驱动) * * PC 侧 tools/smoke.js 会先设 __FEIZHOU_TEST__ 再加载产物, * 借此拿到 IIFE 内部的对象做断言。设备端不会设这个标志, * 因此对实际运行零影响、零开销。 * ------------------------------------------------------------------------- */ if (typeof globalThis !== "undefined" && globalThis.__FEIZHOU_TEST__) { globalThis.__FEIZHOU__ = { Theme: Theme, Store: Store, Records: Records, Sources: Sources, Device: Device, Service: Service, Engine: Engine, Session: Session, Resume: Resume, Fingerprint: Fingerprint, W: W, Dialog: Dialog, Pages: Pages, FB: FB, App: App }; } /* 入口 */ App.main(); }());