// ==UserScript== // @name QPYS // @namespace local.qpys.shard // @version 1.0.0 // @description ①验收任务只显示分给自己的那些:固定 3 个验收账号(QPYS001/002/003),一批题按题ID升序均分连续三段,每人 1/3、最多差 1 条、不重不漏,不查任何任务分配信息、无需任何人填配置;②验收/标注界面里每个包默认只显示 20% 的帧,点一下悬浮窗那行字可按 10%/20%/…/90% 轮转切换(也可以显示全部帧);③页面上只有一个可拖动的一行小字("我的题 N 条"),没有任何按钮 // @match *://*/* // @run-at document-start // @grant none // ==/UserScript== /* ========================================================================= 这个脚本做两件事,装一份就够(同一批的人都装同一份,不用各自改配置) ── 一、把一批题平均分给 3 个验收账号,各看各的、不重不漏 ────────────────── **现在的口径(默认,最简单):固定 3 个账号 QPYS001(488) / QPYS002(490) / QPYS003(1822),不管哪个批次,一律按 1/3 分。** 不查任何"任务分配信息"。 规则(写死在脚本里;三个人装同一份脚本,规则自动一致): 1) 把这一批的**所有题**按 ID(题号/包号)从小到大排成一队; 2) 均分成连续的三段:第 1 段给 QPYS001、第 2 段给 QPYS002、第 3 段给 QPYS003, 即第 k 个人拿到的 = floor(名次 × 3 / 总数) === k-1; 3) 每段长度最多差 1 条:16 个包 → 6/5/5;15 个 → 5/5/5;45 个 → 15/15/15。 为什么按"名次"而不是"题号":题号/包号是平台给的、起点任意, floor((题号-1)/3)%3 在起点不对齐时会切成 6/6/4; 为什么不"一人一个交替拿":按名次分是**连续几包给同一个人**,一个人拿到的包是挨着的。 **要做到不重不漏,靠这两条保证**: · **脚本自己把整批题目翻页取全**:平台一页最多给 50 条(传更大的值会报 "参数错误,分页数据量暂不支持超过50"),所以脚本记住页面请求"批次包列表"用的 URL,按 page_num=50 一页页把整批题号拿完(结果按 task_key 缓存在本地,同一批只翻一次) ⇒ 三个人手里的名次表逐条一致,不会重复也不会漏。 页面自己请求的每页条数**一个字都不改**(平台就是每页 20 条); 平时搜索/翻页看到的子集同样并进名次表(只增不减)。若这一页当初是拿"半张名次表"筛的, 整批题号补齐后脚本会自动刷一次页面重筛(同一批只刷一次)。 · 名次表只跟"题ID 升序"有关,跟谁先打开页面、谁搜了什么无关。 某一页只返回几条时,如果这几条里没有你的题,页面就正常地空着,不会显示别人的。 (控制台 __fsCheck() 里"名次表(整批)"那行会显示是"已翻页取全"还是"待补全"。) · 名单就是脚本里的 CONFIG.members(固定 3 人),**不用任何人填人数**, 也不去接口里认。名单里没有的人(新增账号)在 __fsCheck() 里会显示 "我不在名单里" → 把那个人的 ID 加进 CONFIG.members 即可(顺序别动)。 · 应急口子:控制台执行一次 __fsRoster([488, 490, 1822]) 可以临时写死名单 (存本地、刷新仍有效),__fsRoster([]) 清除。 · 想回到"用平台分配名单":CONFIG.usePlatformRoster = true(会优先用批次列表里的 allocation_info.user_items + work_type=4,认不出再退回固定 3 人)。 · 想改成"按题号取余"(每人交替拿):CONFIG.shardBy = 'topicId'; 想改成"多出来的那条给最后一位":CONFIG.balanceTail = true(16 包 3 人 → 5/5/6)。 题ID 里通常混着不同的数据集(如 scene-b417… / scene-d0c7…), 它们共用同一套题ID,所以分出来每一份里两种数据都会有。 生效页面:地址以 CONFIG.pathPrefix(默认 /v/)开头的项目管理页。 ── 二、抽帧比例:默认 20%,可以自己调 10%~90% ────────────────────────── 页面原始状态是全帧;脚本默认只显示 20%,右上角只显示一行字(没有按钮)。 **直接点这行字**就在 10% → 20% → … → 90% → 全部帧 → 10% 之间轮转, 选中的比例记在本地(fs20_rate),关掉页面再打开还是它。 拖动是先按住再移动(超过 4px),不会误触发切换。 切换是瞬间生效的,不需要刷新页面。 也可以在 F12 控制台直接设:__fsRate(50) 设成 50%、__fsFull() 显示全部帧、 __fsSample() 回到抽稀(用上次选的比例);__fsRate() 单独执行看当前比例和可选档位。 比例只管"显示多少帧":抽法(顺序等间隔)和"同一比例下三人看到的帧完全一样" 这两条不变 —— 所以抽检结果仍然可以横向比对。 为什么能瞬间切换:帧列表的数据流是 GET /v2/tasks/package/{package_id}/resources ← 响应体里的 task_source[] 就是全部帧 ↓ store.commit("config/update", [config, 响应, params, checkInfo, tasksInfo]) ↓ mutations.update: i.frames = 响应.task_source ; i.frameIds[task_id] = 下标 ↓ 帧网格 / 预加载 / 上一帧下一帧 全部读 store.state.config.frames 脚本把这一串的原始数据留一份,然后把 store 里的 frames / frameIds / tasksInfo 按当前模式重算——所以一切换,列表立刻变,不用重新请求。 三个必须遵守的技术点(都是源码里实测出来的): 1) 必须保留「当前帧」。mutations.update 里有: Utils.isNull(i.frameIds[params.task_id]) && throw {msg:"task_id 匹配失败"} 当前帧 id 来自 URL 的 ?id=。它要是不在列表里,页面会弹窗报错 + location.reload() 死循环。所以脚本一定把当前帧并进保留集合。 2) 抽帧必须**确定性**:用固定公式/固定种子,不能用 Math.random(), 否则同一个人刷两次看到的 20% 都不一样,没法比对。 3) tasksInfo 是按下标与 frames 对齐的(updateViewedStatus 用 getFrameIndex 取下标 写 tasksInfo[index].is_viewed)。裁帧后必须同步重排,否则「已查看」标记会错位。 ========================================================================= */ (function () { 'use strict'; /* ============================== 配置区 ============================== */ var CONFIG = { /* ---- 题分片 ---- */ // 只在地址以这个前缀开头的页面做分片(避免污染其它网站) pathPrefix: '/v/', // 验收人名单。**现在的口径:固定 3 个账号,每人拿 1/3**(顺序固定! // 改顺序会让三个人的分片全部洗牌)。人数 N 就是这份名单的长度。 members: [ { id: 488, name: 'QPYS001' }, { id: 490, name: 'QPYS002' }, { id: 1822, name: 'QPYS003' } ], // 是否还去平台接口里找"本批分给哪几个人"。 // false = ★现在的口径:谁都不找,就按上面 members 的 3 人等分(简单、绝不会认错)。 // true = 旧行为:优先用平台批次列表里的官方分配名单(allocation_info.user_items)。 usePlatformRoster: false, // 当前登录人ID。填 null = 自动识别(推荐) me: null, // 分片依据: // 'balance' = ★默认。按"这一批列表里的名次"顺序均分:一人连续拿一段, // 每份条数最多差 1 条。例:16 个包 3 人 → 6 / 5 / 5; // 15 题 4 人 → 4 / 4 / 4 / 3。多出来的那条给顺位靠前的人。 // 'balanceTail' = 同 balance,但多出来的那条给顺位最后的人 // (16 个包 3 人 → 5 / 5 / 6)。 // 'topicIdBlock'= 按题ID排队连拿(1~N给第1人,N+1~2N给第2人…) // 缺点:批次第一个题号如果不在"N 的整数倍"边界上, // 每人条数会不平均(实测 16 个包 3 人 → 6/6/4;45题7人 → 7/7/7/7/7/7/3)。 // 'topicId' = 按题ID取余平均分(每人隔 N 个拿一个) // 条数最平均(45题7人 → 6/7/7/7/6/6/6),但一人一份是交错的。 // 'packageHash' = 按序列名哈希 // 'auto' = 先看"这题平台分给谁"(必须是"分给我"才算数); // 这页找不到"分给我的题",就自动走题ID平均分。 // 'assignee' = 只按"这题平台分给谁"筛(会筛掉没分配信息的题,慎用) shardBy: 'balance', // balance 时多出来的那几条给谁:false = 给顺位靠前的人(16包3人 → 6/5/5,★默认) // true = 给顺位最后的人(16包3人 → 5/5/6) balanceTail: false, // 本批一共有几个人验收(0 = 自动:用平台接口里的本批名单人数,取不到用 members 人数) // 正常不用填 —— 脚本会从接口响应里认名单(见 harvestRoster)。 shardCount: 0, // 我是第几个验收人(-1 = 自动:先看我在平台本批名单里的位置,再看 members 名单位置) // 正常不用填。 myIndex: -1, // 【最保险的一招】直接把"本批验收人"写成 id 列表,优先级最高,自动识别全部让位。 // 例:本批分给 QPYS001/QPYS002/QPYS003 三个人 → batchMembers: [488, 490, 1822] // 留空 = 全自动。也可以不改文件,在控制台执行一次: // __fsRoster([488, 490, 1822]) // 固定成这 3 人(存在本地,刷新后仍有效) // __fsRoster() // 看当前用的是哪份名单 // __fsRoster([]) // 清除,恢复自动识别 batchMembers: [], // 指定"分配人"字段名(只有 shardBy='auto'/'assignee' 才用得到,一般不用填) // 例:'accept_user_id' 或 'accept.user_id';填了就不再自动猜。 assigneeField: null, // packageHash 模式:是否去掉 scene-xxx-0001 结尾的流水号 packageStripSuffix: true, // 2.7.2:页面自己的请求一个字都不改 —— 平台本来就是每页 20 条, // 改它会让"任务管理页"的分页显示跟别人不一样,所以我们只让脚本自己另发请求去翻页。 enlargePageSize: false, /* ---- 2.7.1 名次表补全(一页最多 50 条,脚本自己翻页取全整批题号) ---- */ canonicalFetch: true, // true=自己翻页补齐整批题号(保证三个人名次表一致,不重不漏) canonPageSize: 50, // 脚本自己翻页用的每页条数(平台硬上限就是 50) canonMaxPages: 60, // 最多翻 60 页(3000 个包),防止跑飞 canonDelayMs: 120, // 翻页间隔(毫秒) /* ---- 抽帧比例(可自定义:10%~90%、步长 10%、默认 20%) ---- */ frameSampleRate: 0.2, // 默认抽样比例(页面上没选过时用它;选过就以本地记住的为准) frameRateSteps: [10, 20, 30, 40, 50, 60, 70, 80, 90], // 可选档位(%),点悬浮窗按这个顺序轮转 frameRateLs: 'fs20_rate', // 本地记住"上次选的比例"的 key(值是百分数;100 = 全部帧) frameDefault: 'sample', // 打开页面时的默认模式:'sample' 抽稀 | 'full' 全帧 frameMode: 'systematic', // 抽法:'systematic' 顺序等间隔(推荐)|'random' 固定种子随机 frameSeed: 20260101, // random 模式的种子 // 只对某些工序抽帧。空数组 = 不限。 // 工序 work_type: 标注1 / 审核2 / 质检3 / 验收4 ;权限 access: 浏览1 / 审查2 / 编辑3 frameWorkTypes: [], frameAccesses: [], keepCurrentFrame: true, // 必开:保留当前帧,否则页面报错死循环 syncTasksInfo: true, // 同步重排 tasksInfo,「已查看」标记不错位 syncCountFields: true, // 顺手把响应体里等于原帧总数的计数字段改小 // 进阶(默认关闭):让 7 个人抽到**不同的** 20% // 关闭 = 所有人看到同一批 20%(抽检结果可横向比对,推荐) // 开启 = 用登录 token 哈希轮转,7 人错开(合起来可覆盖 100%+) frameRotateByToken: false, frameRotateMod: 7, /* ---- 界面 ---- */ showTopButton: true, // 显示悬浮窗(可拖动,位置会记住) panelPos: null, // 悬浮窗位置 {left, top},一般不用填 debug: true // 控制台打印调试信息 }; /* =================================================================== */ var MEMBERS = CONFIG.members; var MEMBER_IDS = MEMBERS.map(function (m) { return m.id; }); var N = MEMBER_IDS.length; var MY_ID = null; var MY_SLOT = -1; // -1 = 还没识别出当前登录人 var shardEnabled = true; var FRAME_MODE = CONFIG.frameDefault === 'full' ? 'full' : 'sample'; var FRAME_RATE_PCT = Math.round(CONFIG.frameSampleRate * 100) || 20; // 当前抽帧比例(%):10~90;100 = 全帧 var DRAG_MOVED_AT = 0; // 悬浮窗上一次"真的被拖动"的时间,用来区分拖动与点击 var log = function () { if (!CONFIG.debug) return; try { console.log.apply(console, ['[验收助手]'].concat([].slice.call(arguments))); } catch (e) {} }; function isShardPage() { return location.pathname.indexOf(CONFIG.pathPrefix) === 0; } /** 编辑页(标注/验收那个 Vue3 页面)独有的信号。 * 只认 Vue3 的 __vue_app__(后台列表页是 Vue2 的 __vue__,两套产物)。 */ function framePageHint() { try { var el = document.getElementById('app') || document.querySelector('[data-v-app]'); if (!el) return false; var app = el.__vue_app__; var store = app && app.config && app.config.globalProperties && app.config.globalProperties.$store; return !!(store && store.state && store.state.config && Array.isArray(store.state.config.frames)); } catch (e) { return false; } } var FRAME_HINT = null, FRAME_HINT_T = 0; /** 是不是"某一个包/某一帧"的编辑(标注/验收)页面。 * ⚠️ 只认编辑页独有的信号:收到过帧接口响应 / 地址里有 package_id / * 页面上跑的是 Vue3 编辑页且有 config.frames。 * **不能**用 id= 判断(列表页也带项目 id),也**不能**用 task_key= * 判断 —— 任务列表页的地址就是 /v/project//project-taskIndex?task_key=…, * 认错会把整个分片功能关掉、悬浮窗也只显示抽帧按钮。 */ function isFramePage() { if (LAST_PKG) return true; // 收到过 /resources 帧列表 = 编辑页 if (/[?&]package_id=/.test(location.search)) return true; if (FRAME_HINT === true) return true; var now = Date.now(); if (FRAME_HINT !== false || now - FRAME_HINT_T > 1000) { // 否定结果 1 秒内不重复查 DOM FRAME_HINT_T = now; FRAME_HINT = framePageHint() ? true : false; } return FRAME_HINT === true; } /** 页面分类:编辑页优先(编辑页哪怕挂在 /v/ 下也不能走分片逻辑,否则会误删帧数据) */ function pageKind() { if (isFramePage()) return 'frame'; if (isShardPage()) return 'shard'; return 'other'; } /* ========================= 身份识别(分片用) ========================= */ function nameToId(name) { if (!name) return null; var m = String(name).match(/QPYS0*(\d{1,3})/i); if (!m) return null; var want = 'QPYS' + ('00' + Number(m[1])).slice(-3); for (var i = 0; i < MEMBERS.length; i++) { if (MEMBERS[i].name.toUpperCase() === want.toUpperCase()) return MEMBERS[i].id; } return null; } // 从 Vuex store 里找当前登录用户 function idFromVue() { var app = document.getElementById('app'); if (!app) return null; var vm = app.__vue__ || (app.parentElement && app.parentElement.__vue__); var state = vm && vm.$store && vm.$store.state; if (!state) return null; var hits = []; var seen = []; (function walk(o, depth) { if (!o || typeof o !== 'object' || depth > 6) return; if (seen.indexOf(o) >= 0) return; seen.push(o); if (typeof o.id === 'number' || typeof o.id === 'string') { var nm = o.username || o.account || o.name || o.userName; var nid = Number(o.id); // 只认"团队成员ID",避免把题目ID/操作人ID等当成登录人 if (nm && nameToId(nm) && MEMBER_IDS.indexOf(nid) >= 0) hits.push(nid); } for (var k in o) { try { walk(o[k], depth + 1); } catch (e) {} } })(state, 0); var uniq = hits.filter(function (v, i) { return hits.indexOf(v) === i; }); return uniq.length === 1 ? uniq[0] : null; } // 从页面右上角 "ID:1826" 里读(登录人的ID,唯一可信来源) function idFromHeader() { var box = document.querySelector('.header') || document.body; if (!box) return null; var t = box.innerText || box.textContent || ''; var m = t.match(/ID\s*[::]\s*(\d{1,10})/); if (!m) return null; var id = Number(m[1]); return id > 0 && id < 10000000 ? id : null; } // 从 localStorage 兜底找(同步,能在请求发出前就拿到) function idFromStorage() { try { var found = []; for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); var raw = localStorage.getItem(k); if (!raw || raw.length > 20000) continue; var byName = nameToId(raw); if (byName != null && found.indexOf(byName) < 0) found.push(byName); } // 只在"所有线索都指向同一个人"时才敢用,避免缓存了别人的记录 return found.length === 1 ? found[0] : null; } catch (e) {} return null; } /** 记下当前登录人。 * 注意:人员数量不固定,所以这里不再要求 id 必须在 members 名单里—— * ANY 正数 ID 都收下(按平台分配筛只需要"我是谁",不需要人数)。 * MY_SLOT 在名单里才有值,只有"按题ID平均分"才需要它。 */ function setIdentity(id) { if (id == null) return; id = Number(id); if (!(id > 0)) return; if (MY_ID === id) return; MY_ID = id; MY_SLOT = MEMBER_IDS.indexOf(id); if (MY_SLOT >= 0) log('识别到当前登录人 ID=' + id + '(' + MEMBERS[MY_SLOT].name + ')—— 团队名单第 ' + (MY_SLOT + 1) + '/' + N + ' 位(真实分片口径按"本批名单"另算,见 __fsCheck())'); else log('识别到当前登录人 ID=' + id + '(不在 members 名单里,只能按平台分配筛)'); syncTopBtn(); } function resolveIdentity() { if (CONFIG.me) { setIdentity(CONFIG.me); return MY_ID != null; } // 顺序很重要:右上角 "ID:xxx" 是平台自己显示的当前登录人,最可信,排第一。 // (以前把 Vue 数据放第一,页面里别的对象带 QPYS 名字就会被认成登录人。) var cands = [idFromHeader(), idFromVue(), idFromStorage()]; for (var i = 0; i < cands.length; i++) { if (cands[i] == null) continue; setIdentity(cands[i]); if (MY_ID != null) return true; } return MY_ID != null; } /* ========================= 题分片算法(原样保留) ========================= */ function fnv1a(str) { var h = 0x811c9dc5; for (var i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; } return h >>> 0; } /* ---------- 题ID 字段名 ---------- 以前只认一份写死的字段名(task_id/topic_id/…)。同一平台不同列表页的字段名并不一样, 一旦没认出来:每条题的 slot 都是 null → mineOf() 一律当成"我的" → 一条都不藏, 表现就是"装上了但没反应"(收到 16 / 显示 16 / 已筛 0)。现在加两层兜底。 */ var ID_FIELD = null; // 认出来的"题ID"字段名(记住,后面优先用它) var UNPARSED = 0; // 最近一批里数字ID没认出来的题数 var ID_WARNED = false; // "认不出题ID"只提醒一次 var ID_KEYS = ['topicId', 'topic_id', 'topicid', 'topicNo', 'topic_no', 'topicNum', 'topic_num', 'topicNumber', 'questionId', 'question_id', 'questionNo', 'question_no', 'questionNum', 'question_num', 'questionNumber', 'itemId', 'item_id', 'itemNo', 'item_no', 'itemNum', 'item_num', 'itemNumber', 'taskId', 'task_id', 'taskid', 'taskNo', 'task_no', 'taskNum', 'task_num', 'taskNumber', 'seqId', 'seq_id', 'seqNo', 'seq_no', 'seqNum', 'seq_num', 'sequenceId', 'sequence_id', 'sequenceNo', 'sequence_no', 'sequenceNum', 'sequence_num', 'dataId', 'data_id', 'dataNo', 'data_no', 'dataNum', 'data_num', 'markId', 'mark_id', 'markNo', 'mark_no', 'markNum', 'mark_num', 'sampleId', 'sample_id', 'caseId', 'case_id', 'serialNo', 'serial_no', 'serialNum', 'serial_num', // 这个平台的"任务列表"一行就是一个包,分片键就是包号:package_id 必须直接认出来, // 不能指望 learnIdField 的兜底(兜底一旦没跑成,slot 全是 null → 一条都不藏) 'packageId', 'package_id', 'packageid', 'packageNo', 'package_no', 'packageNum', 'package_num', 'pkgId', 'pkg_id', 'pkgNo', 'pkg_no', 'boardNum', 'board_num', 'boardNo', 'board_no', 'recordId', 'record_id', 'id', 'num', 'no', 'number', 'serial', 'code']; /** 字段名像不像"题号" */ var ID_NAME_OK = /(topic|question|item|task|data|mark|sample|case|seq|serial|record|res|frame|pcd|package|pkg|board).*(id|no|num|number|code)$|^(id|no|num|number|serial|code)$/i; /** 一定不是题号(统计/状态/分页/比例/时间之类) */ var ID_NAME_BAD = /rate|percent|ratio|status|state|type|level|count|total|size|len|length|version|time|date|flag|^is|^has|page|offset|limit|score|sort|order|index|duration|elapsed|depth|width|height|amount|progress|step/i; function asId(v, known) { var n; if (typeof v === 'number') n = v; else if (typeof v === 'string' && /^\d{1,12}$/.test(v)) n = Number(v); else return null; if (!isFinite(n) || Math.floor(n) !== n || n < 1 || n > 1e9) return null; if (!known && n < 100) return null; // 兜底猜测时要求像题号量级(本平台题ID都是5位数) return n; } function findNumId(item) { if (!item || typeof item !== 'object' || Array.isArray(item)) return null; var v, i, k; if (ID_FIELD) { v = asId(item[ID_FIELD], true); if (v !== null) return v; } for (i = 0; i < ID_KEYS.length; i++) { v = asId(item[ID_KEYS[i]], true); if (v !== null) { ID_FIELD = ID_KEYS[i]; return v; } } for (k in item) { if (ID_NAME_BAD.test(k) || !ID_NAME_OK.test(k)) continue; v = asId(item[k], false); if (v !== null) { ID_FIELD = k; return v; } } return null; } /** 字段名一个都没认出来时:拿整批题比"这一列是不是各不相同"。 * 可用标签总数/批注总数/状态码这类统计数字在同一批题里必然重复,会被淘汰。 */ function learnIdField(items) { if (!items || items.length < 2) return null; var first = items[0], keys = [], k, i, j; for (k in first) { if (ID_NAME_BAD.test(k)) continue; if (asId(first[k], false) === null) continue; // 名字不猜了:靠下面"整批各不相同"来判 keys.push(k); } var n = Math.min(items.length, 60), best = null, bestScore = -1; for (i = 0; i < keys.length; i++) { var seen = {}, uniq = 0, ok = 0; for (j = 0; j < n; j++) { var v = asId(items[j][keys[i]], false); if (v === null) continue; ok++; if (!seen[v]) { seen[v] = 1; uniq++; } } if (ok < n) continue; // 必须每条题都有值 var ratio = uniq / n; if (n >= 5 && ratio < 0.95) continue; // 有重复 → 统计/状态列,不是题号 var ns = /^(topic|question|item)/i.test(keys[i]) ? 3 : /id$/i.test(keys[i]) ? 2 : 1; var score = ns * 100 + Math.round(ratio * 10); if (score > bestScore) { bestScore = score; best = keys[i]; } } if (best) { ID_FIELD = best; log('题ID 字段名认作 "' + best + '"(不在常见名单里,按"整批各不相同"判定的,可用 __fsCheck() 核对)'); } return best; } /* ---------- 从题目本身认"本批验收人" ---------- 平台把这一批分给谁,题目里往往直接带着(如 accept_user_id)。只认名字里带 accept/verif/assign/claim/owner 的字段 —— 绝不能把"标注人/审核人"当验收人 (那是上一道工序的人),否则会把别人的题藏光、或人数算错。 */ var ROSTER_FIELD_OK = /(accept|verif|assign|allocat|claim|dispatch|owner|handler|checker|receiver)/i; function harvestRosterFromTopics(items) { if (!items || items.length < 2) return; var cnt = {}, i, k, v, id; for (i = 0; i < items.length; i++) { for (k in items[i]) { if (!ROSTER_FIELD_OK.test(k)) continue; v = items[i][k]; id = (v && typeof v === 'object') ? pickPersonId(v) : Number(v); if (!(id > 0) || MEMBER_IDS.indexOf(id) < 0) continue; cnt[id] = (cnt[id] || 0) + 1; } } var ids = []; for (k in cnt) ids.push(Number(k)); if (ids.length < 2 || ids.length > 64) return; var sids = sortedIds(ids); var cand = { ids: sids, hit: sids.length, len: sids.length, allIn: true, sc: 2, url: '这批题里的验收人字段(' + items.length + ' 条题认出 ' + sids.length + ' 人)' }; if (rosterBetter(cand, AUTO_ROSTER)) { var chg = !AUTO_ROSTER || String(AUTO_ROSTER.ids) !== String(sids); AUTO_ROSTER = cand; AUTO_ROSTER_SRC = cand.url; if (chg) log('从题目里认到本批验收人 ' + sids.length + ' 人:' + sids.join(',') + '(__fsCheck() 可核对)'); } } function findPackageName(item) { for (var k in item) { var v = item[k]; if (typeof v === 'string' && /^scene-/i.test(v)) { return CONFIG.packageStripSuffix ? v.replace(/-\d+$/, '') : v; } } return null; } function isTopicItem(o) { if (!o || typeof o !== 'object' || Array.isArray(o)) return false; for (var k in o) { if (typeof o[k] === 'string' && /^scene-/i.test(o[k])) return true; } return false; } /** 按题ID算归谁。规则见 CONFIG.shardBy: * topicId(默认) :题ID % 人数 —— 连续题号下每人条数最多差 1(最平均) * topicIdBlock :排队连拿 —— 题ID 1~N 给第0人、N+1~2N 给第1人……轮流 */ /** 名单里的 ID 排成固定顺序(数字升序、去重)。 * 必须固定:以前直接拿接口返回的数组顺序当"我是第几个", * 不同人的浏览器拿到的顺序可能不一样 → 两个人被算成同一份。 */ function sortedIds(ids) { var out = []; for (var i = 0; i < (ids ? ids.length : 0); i++) { var v = Number(ids[i]); if (v > 0 && out.indexOf(v) < 0) out.push(v); } out.sort(function (a, b) { return a - b; }); return out; } /** 本批"几人、我是第几份"——**必须一次算出来、同源**。 * v2.6.1 修的核心 bug:以前 effCount() 用"平台名单人数(4)", * effSlot() 却会退回"固定 7 人名单的下标 % 4", * 于是第 1 位和第 5 位都算第 1 份、第 3 位和第 7 位都算第 3 份: * 两个人看到完全相同的题(重复干活),15 题却数出 6+5+4+6=21 条。 * 现在三条来源各自成对(人数+顺位一起取),且**任何地方都不再取模**。 */ function roster() { // ⓪ 手动指定的本批名单(CONFIG.batchMembers 或控制台 __fsRoster([...]))——优先级最高。 // 自动识别再聪明也可能认错(实测:一个批次实际分给 3 个人,脚本却从某个接口里 // 认到一份 2 人数组,结果第 3 个人被判成"没分给你")。所以留一个"我说了算"的口子。 if (MANUAL_ROSTER && MANUAL_ROSTER.length >= 2 && MANUAL_ROSTER.length <= 64) { var mids = sortedIds(MANUAL_ROSTER); var mat = mids.indexOf(Number(MY_ID)); if (mat >= 0) return { n: mids.length, slot: mat, ids: mids, src: 'manual' }; return { n: mids.length, slot: -2, ids: mids, src: 'manualnotin' }; } // ① 平台接口里认到的"本批名单",而且我确实在这份名单里 if (CONFIG.usePlatformRoster && AUTO_ROSTER && AUTO_ROSTER.ids && AUTO_ROSTER.ids.length >= 2 && AUTO_ROSTER.ids.length <= 64) { var ids = sortedIds(AUTO_ROSTER.ids); var at = ids.indexOf(Number(MY_ID)); if (at >= 0) return { n: ids.length, slot: at, ids: ids, src: 'platform' }; // 认到了本批名单、但我不在名单里 —— 这里**绝对不能**退回"固定 7 人": // 我在名单外按 7 人切、名单里的人按 4 人切,两套切法叠在一起, // 同一道题就会被两个人同时拿到(实测 15 题数出 6/5/4/6 就是这么来的)。 // 平台分配口径下,"我不在本批名单里"就等于"这一批没分给我",明说比撞号好。 return { n: ids.length, slot: -2, ids: ids, src: 'notinroster' }; } // ② 脚本里手填的"人数 + 我的顺位"(两个都填了才用,方便一次性切批) var c = Number(CONFIG.shardCount), mi = Number(CONFIG.myIndex); if (c >= 2 && c <= 64 && mi >= 0 && mi < c) { return { n: Math.floor(c), slot: Math.floor(mi), ids: [], src: 'config' }; } // ③ 固定团队名单:人数 = 名单长度,顺位 = 我在名单里的位置(不取模) var m = MEMBER_IDS.indexOf(Number(MY_ID)); if (m >= 0) return { n: N, slot: m, ids: MEMBER_IDS.slice(), src: 'fixed' }; return { n: N, slot: -1, ids: MEMBER_IDS.slice(), src: 'none' }; } function rosterSrcText(src) { if (src === 'manual') return '手动指定的本批名单(' + (MANUAL_ROSTER || []).length + ' 人:' + (MANUAL_ROSTER || []).join(',') + ')' + (MANUAL_SRC ? ' [' + MANUAL_SRC + ']' : ''); if (src === 'manualnotin') return '手动指定的本批名单(' + (MANUAL_ROSTER || []).length + ' 人)里没有我(' + MY_ID + ')→ 这一批不显示'; if (src === 'platform') return '平台本批名单(' + AUTO_ROSTER.len + ' 人,来源 ' + AUTO_ROSTER_SRC + ')'; if (src === 'notinroster') return '平台本批名单(' + AUTO_ROSTER.len + ' 人)里没有我 → 这一批不显示'; if (src === 'config') return '脚本里手填的 shardCount/myIndex'; if (src === 'fixed') return '固定 ' + N + ' 人名单(每人 1/' + N + ',不查平台分配)'; return '未知(我不在团队名单里)'; } /* 手动指定本批验收人:存 localStorage(按 task_key 分开存一份 + 一份全局兜底), 这样同一个人切到别的批次不会被上一批的名单带偏,而刷新页面不必重新输入。 */ var MANUAL_LS = 'fs20_roster'; function loadManualRoster() { if (CONFIG.batchMembers && CONFIG.batchMembers.length >= 2) { MANUAL_ROSTER = sortedIds(CONFIG.batchMembers); MANUAL_SRC = '脚本里写死的 batchMembers'; return; } var q = parseQuery(location.search), tk = q.task_key || ''; var keys = tk ? [MANUAL_LS + ':' + tk, MANUAL_LS] : [MANUAL_LS]; for (var i = 0; i < keys.length; i++) { try { var raw = localStorage.getItem(keys[i]); if (!raw) continue; var arr = JSON.parse(raw); if (arr && arr.length >= 2) { MANUAL_ROSTER = sortedIds(arr); MANUAL_SRC = keys[i] === MANUAL_LS ? '控制台 __fsRoster([...])' : '控制台 __fsRoster([...]) · 批次 ' + tk; return; } } catch (e) {} } MANUAL_ROSTER = null; MANUAL_SRC = ''; } function setManualRoster(list) { var q = parseQuery(location.search), tk = q.task_key || ''; if (!list || !list.length) { MANUAL_ROSTER = null; MANUAL_SRC = ''; try { localStorage.removeItem(MANUAL_LS); } catch (e0) {} if (tk) { try { localStorage.removeItem(MANUAL_LS + ':' + tk); } catch (e1) {} } log('已清除手动名单 → 恢复自动识别本批验收人'); return null; } var ids = sortedIds(list); if (ids.length < 2) { log('⚠ 至少要 2 个人(收到 ' + ids.length + ' 个有效 id)'); return null; } MANUAL_ROSTER = ids; MANUAL_SRC = '控制台 __fsRoster([...])' + (tk ? ' · 批次 ' + tk : ''); try { localStorage.setItem(MANUAL_LS, JSON.stringify(ids)); } catch (e2) {} if (tk) { try { localStorage.setItem(MANUAL_LS + ':' + tk, JSON.stringify(ids)); } catch (e3) {} } var at = ids.indexOf(Number(MY_ID)); log('已把本批验收人固定为 ' + ids.length + ' 人:' + ids.join(',') + (at >= 0 ? '(我是第 ' + (at + 1) + ' 份)' : '(⚠ 这份名单里没有你 ' + MY_ID + ',你将看不到题)')); return ids; } function effCount() { return roster().n; } function effSlot() { return roster().slot; } function slotOfId(id) { if (id == null) return null; var n = effCount(); if (n < 2) return 0; // 排队连拿:每 N 个题号换一个人。用 id-1 是为了让 1~N 正好是第一段 if (CONFIG.shardBy === 'topicIdBlock') return (Math.floor((id - 1) / n) % n + n) % n; // topicId(以及 auto 落到平均分时):题ID % 人数,条数最多差 1 return ((id % n) + n) % n; } /* ============== 按"名次"均分(默认规则,条数最多差 1 条) ============== 为什么不能按题号算:题号是平台给的、起点任意。16 个包 3 人按 floor((id-1)/3)%3 会切成 6/6/4(起点相位不对就会差 2 条甚至更多)。 按"这一批列表里的名次"切就没有这个问题:名次一定是 0,1,2…连续整数, 所以 floor(名次*N/总数) 每份最多差 1 条,而且一人连续一段、不交错。 ─────────────────────────────────────────────────────────────────── */ /** 一批 id 的名次表:{__total: 去重条数, "": 升序名次} */ function rankMapOf(ids) { var uniq = [], seen = {}, i, k; for (i = 0; i < ids.length; i++) { if (ids[i] == null) continue; k = String(ids[i]); if (!seen[k]) { seen[k] = 1; uniq.push(Number(ids[i])); } } uniq.sort(function (a, b) { return a - b; }); var m = { __total: uniq.length }; for (i = 0; i < uniq.length; i++) m[String(uniq[i])] = i; return m; } /** 名次 → 归谁。每份最多差 1 条;多出来的给谁由 CONFIG.balanceTail 决定 */ function slotOfRank(rank, total) { if (rank == null || !(total > 0)) return null; var n = effCount(); if (n < 2) return 0; var s = CONFIG.balanceTail ? Math.ceil((rank + 1) * n / total) - 1 : Math.floor(rank * n / total); if (s < 0) s = 0; if (s > n - 1) s = n - 1; return s; } function isBalanced() { return CONFIG.shardBy === 'balance' || CONFIG.shardBy === 'balanceTail'; } /** 分片规则的中文说明(自检/诊断打印用) */ function shardByText() { var b = CONFIG.shardBy; if (b === 'balance') return "balance(按名次均分:一人连续一段,每份最多差 1 条,多的给前面的人)"; if (b === 'balanceTail') return "balanceTail(按名次均分:一人连续一段,每份最多差 1 条,多的给最后一位)"; if (b === 'topicIdBlock') return "topicIdBlock(按题号排队连拿:题号起点不在 N 的边界上时会不平均)"; if (b === 'topicId') return "topicId(按题号取模:条数最平均,但每人交替拿)"; if (b === 'packageHash') return "packageHash(按序列名哈希)"; if (b === 'assignee') return "assignee(只按平台分配筛)"; return String(b); } var BATCH_RANK = null; // 接口层最近一次收到的完整列表的名次表 var BATCH_IDS = null; // 本批见过的所有 id(只增不减) var BATCH_KEY = ''; // 名次表属于哪个批次(task_key) /** 当前页面的批次标识(换批次/换工序要把名次表清掉,否则会串) */ function batchKeyNow(items) { var m = (location.search || '').match(/[?&]task_key=([^&]*)/); var k = m ? decodeURIComponent(m[1]) : ''; var wt = ''; if (items && items.length && items[0] && items[0].work_type != null) wt = String(items[0].work_type); else { var m2 = (location.search || '').match(/[?&]work_type=([^&]*)/); wt = m2 ? m2[1] : ''; } return k + '|' + wt; } /** 记录这一批见过的 id 并重建名次表。 为什么只增不减:用户一搜索/一筛选,接口返回的就是子集(比如只剩 3 条), 如果拿子集重新排"名次",同一道题在"搜过的人"和"没搜的人"眼里名次不同, 就会两个人同时看到它 —— 破坏了"互不重复"。 所以名次表用"这一批见过的所有 id"的并集来排,子集响应不会打乱它。 */ function noteBatchIds(ids, items) { var key = batchKeyNow(items); if (BATCH_KEY !== key) { BATCH_KEY = key; BATCH_IDS = {}; BATCH_RANK = null; } if (!BATCH_IDS) BATCH_IDS = {}; var grew = false, i, k; for (i = 0; i < ids.length; i++) { if (ids[i] == null) continue; k = String(ids[i]); if (!BATCH_IDS[k]) { BATCH_IDS[k] = 1; grew = true; } } if (grew || !BATCH_RANK) { var all = []; for (k in BATCH_IDS) { if (Object.prototype.hasOwnProperty.call(BATCH_IDS, k)) all.push(Number(k)); } BATCH_RANK = rankMapOf(all); } return BATCH_RANK; } function slotOfBalanced(item) { if (!BATCH_RANK) return null; var id = findNumId(item); if (id == null) return null; var r = BATCH_RANK[String(id)]; if (r == null) return null; return slotOfRank(r, BATCH_RANK.__total); } /** DOM 兜底用:先查本批名次表(接口层见过的),再查"本页这几行"的名次,最后退回按题号 */ function slotOfDomRow(id, domRank) { if (BATCH_RANK) { var r1 = BATCH_RANK[String(id)]; if (r1 != null) return slotOfRank(r1, BATCH_RANK.__total); } if (domRank) { var r2 = domRank[String(id)]; if (r2 != null) return slotOfRank(r2, domRank.__total); } return slotOfId(id); } /* ================= 2.7.1 名次表补全:自己翻页把整批题号取全 ================= 为什么要补:分片规则是"按名次均分"(一人连续一段、最多差 1 条),它要求三个人 手里的**名次表完全一样**。而这个平台一页最多给 50 条 (传 page_num>50 会直接报"参数错误,分页数据量暂不支持超过50"), 如果这一批有 120 个包,每人只看到自己翻到的那一页,名次表就不一致 → 同一道题可能两个人都在做(重复),或者谁都没做(遗漏)。 做法:记住页面自己请求"批次包列表"用的那个 URL,脚本按 page_num=50 一页页拿完, 把整批题号喂给名次表;结果按 task_key 缓存到 localStorage,同一批只翻一次。 三个人翻的是同一个接口、同样的顺序 ⇒ 名次表逐条一致 ⇒ 不重不漏。 */ var CANON_LS = 'fs20_canon'; var CANON_CACHE = null; // { "|": {ids:[…], done:true} } var LAST_LIST_URL = ''; // 页面自己请求"批次包列表"的原始 URL(照它翻页) var CANON_BUSY = false; var CANON_DONE = false; // 本批已翻完 var CANON_TOTAL = null; // 本批总包数(接口给的 total,用来判断"翻齐了没有") var CANON_RELOAD_LS = 'fs20_canon_reload'; // 本批已因"名次表补全"刷过一次(防刷新循环) var CANON_RELOAD_DONE = (function () { try { return sessionStorage.getItem(CANON_RELOAD_LS) || ''; } catch (e) { return ''; } })(); var PARTIAL_FILTERED = false; // 这一页曾用"半张名次表"筛过 → 补全后要自动刷一次 /** 脚本自己翻页用的每页条数:平台硬上限 50(超过会被拒) */ function canonPer() { var n = Number(CONFIG.canonPageSize) || 50; if (n > 50) n = 50; if (n < 1) n = 20; return n; } /** 页面自己用的每页条数(第一次见到它的列表请求时定下来,之后不再变)—— 2.7.2 用它当退路 */ var PAGE_OWN_PER = null; function pageOwnPer() { if (PAGE_OWN_PER) return PAGE_OWN_PER; var m = /[?&](?:pageSize|page_size|pageNum|page_num|perPage|per_page|limit)=(\d+)/i .exec(String(LAST_LIST_URL || '')); var n = m ? Number(m[1]) : 0; if (!n || n > 50 || n < 1) n = 20; return n; } function loadCanonCache() { try { var s = localStorage.getItem(CANON_LS); if (s) { var o = JSON.parse(s); if (o && typeof o === 'object') CANON_CACHE = o; } } catch (e) {} } function saveCanonCache() { try { localStorage.setItem(CANON_LS, JSON.stringify(CANON_CACHE)); } catch (e) {} } /** 本批的缓存键:task_key | work_type(换工序要分开算) */ function canonKeyNow() { var tk = taskKeyNow(); if (!tk) return ''; var m = (location.search || '').match(/[?&]work_type=([^&]*)/); return tk + '|' + (m ? m[1] : ''); } /** 把"批次包列表"URL 改成指定页、指定每页条数(缺 page_num 参数时补一个) */ function listUrlFor(page, per) { var u = String(LAST_LIST_URL || ''); var size = (per == null) ? canonPer() : per; var re = /([?&](?:pageSize|page_size|pageNum|page_num|perPage|per_page|limit)=)\d+/gi; if (re.test(u)) { u = u.replace(re, function (all, p) { return p + String(size); }); } else { u += (u.indexOf('?') >= 0 ? '&' : '?') + 'page_num=' + size; } if (/([?&]page=)\d+/.test(u)) u = u.replace(/([?&]page=)\d+/, '$1' + page); else u += (u.indexOf('?') >= 0 ? '&' : '?') + 'page=' + page; return u; } function fetchCanonical() { if (!CONFIG.canonicalFetch || CANON_BUSY || CANON_DONE) return false; if (!isShardPage()) return false; var key = canonKeyNow(); if (!key) return false; if (!CANON_CACHE) CANON_CACHE = {}; var seen = {}, ids = [], i, k; var cached = CANON_CACHE[key]; var cachedIds = cached ? (Array.isArray(cached) ? cached : (cached.ids || null)) : null; if (cachedIds) { for (i = 0; i < cachedIds.length; i++) { k = String(cachedIds[i]); if (!seen[k]) { seen[k] = 1; ids.push(cachedIds[i]); } } } if (cachedIds && CANON_CACHE[key + '|done'] && ids.length) { CANON_DONE = true; noteBatchIds(ids, null); log('名次表:用缓存的整批题号 ' + ids.length + ' 条(' + key + ')'); reloadOnce(); return true; } if (!LAST_LIST_URL) return false; // 还没见过"批次包列表"接口,等它出现再说 CANON_BUSY = true; var per = canonPer(); var page = Math.floor(ids.length / per) + 1; var pages = 0; var triedSmall = false; // 大页被平台拒过没有 log('名次表:翻页补齐整批题号(从第 ' + page + ' 页起,每页 ' + per + ')'); function reloadOnce() { // 2.7.2:这一页当初是拿"半张名次表"筛出来的(整批题号那时还没翻齐),现在补齐了 // → 自动刷一次页面,让列表按完整名次表重筛。同一批只刷一次(防刷新循环)。 if (!PARTIAL_FILTERED) return; if (CANON_RELOAD_DONE === key) return; CANON_RELOAD_DONE = key; try { sessionStorage.setItem(CANON_RELOAD_LS, key); } catch (e) {} log('名次表已补齐 → 自动刷新一次页面,让列表按完整名次表重筛(本批只刷这一次)'); setTimeout(function () { try { location.reload(); } catch (e) {} }, 400); } function finish(ok) { CANON_BUSY = false; if (ok && CANON_TOTAL != null && ids.length < CANON_TOTAL) ok = false; // 没拿齐就不算完整 if (ok) CANON_DONE = true; if (ids.length) { noteBatchIds(ids, null); CANON_CACHE[key] = ids.slice(); if (ok) CANON_CACHE[key + '|done'] = 1; saveCanonCache(); log('名次表:整批题号 ' + ids.length + ' 条(' + (ok ? '完整' : '可能不完整') + ')→ ' + '三个人算出的名次一致,不会重复/遗漏'); } if (ok) reloadOnce(); } function step() { if (pages++ >= (CONFIG.canonMaxPages || 60)) { finish(false); return; } var x; try { x = new XMLHttpRequest(); } catch (e) { finish(false); return; } x.__noShard = true; // 要的是"全部题号",别让分片钩子把别人的题从响应里筛掉 try { x.open('GET', listUrlFor(page, per), true); } catch (e) { finish(false); return; } x.onload = function () { var data = null; try { data = JSON.parse(x.responseText); } catch (e) {} var items = data && (data.items || (data.data && data.data.items)); if (!items || !items.length) { // 平台把大页拒了("参数错误,分页数据量暂不支持超过50") // → 退回"页面自己用的每页条数",从头重翻一遍 if (!triedSmall && pages <= 1 && per > pageOwnPer()) { triedSmall = true; log('名次表:每页 ' + per + ' 条被平台拒 → 退回每页 ' + pageOwnPer() + ' 条重新翻'); per = pageOwnPer(); seen = {}; ids = []; page = 1; pages = 0; setTimeout(step, CONFIG.canonDelayMs || 120); return; } finish(true); return; } var added = 0, id, ks, j; for (j = 0; j < items.length; j++) { id = findNumId(items[j]); if (id == null) continue; ks = String(id); if (!seen[ks]) { seen[ks] = 1; ids.push(id); added++; } } log('名次表:第 ' + page + ' 页 +' + added + ' 条(累计 ' + ids.length + ')'); if (CANON_TOTAL != null && ids.length >= CANON_TOTAL) { finish(true); return; } if (items.length < per) { finish(true); return; } // 最后一页 page++; setTimeout(step, CONFIG.canonDelayMs || 120); }; x.onerror = function () { finish(false); }; try { x.send(); } catch (e) { finish(false); } } setTimeout(step, 0); return true; } function slotOf(item) { if (CONFIG.shardBy === 'packageHash') { var pkg = findPackageName(item); if (pkg) return fnv1a(pkg) % effCount(); } if (isBalanced()) { var bs = slotOfBalanced(item); // 本批列表里认得出名次 → 按名次均分 if (bs != null) return bs; // 认不出名次(例如这条题号没认出来)→ 退回按题号,至少不会乱 } return slotOfId(findNumId(item)); } /* ============== "这题平台分给谁了"(人数不固定,按分配筛) ============== 平台分配时是把题/包直接指给某个人,数据里就会出现那个人的ID。 脚本不猜人、不数人头,只认:"这条数据里有没有我的ID",有就是我的。 字段名自动识别(可用 CONFIG.assigneeField 写死)。 ─────────────────────────────────────────────────────────────────── */ var ASSIGN = { path: null, hits: 0, misses: 0, used: false, mode: 'probe' }; /** 字段名像不像"分配人/负责人" */ function assigneeScore(path) { var p = String(path).toLowerCase(), s = 0; if (/accept|verif|assign|allocat|dispatch|shard|claim|owner|handler/.test(p)) s += 6; if (/user|uid|staff|member|worker|person|people|operator|checker/.test(p)) s += 4; if (/(^|[._])ids?$|_id$|ids$/.test(p)) s += 2; if (/creat|author|mark|label|audit|qc|quality|reject/.test(p)) s -= 4; return s; } /** 在一层数据里找出"值等于我ID"的字段路径(含数组、嵌套对象、逗号串) */ function scanMyId(o, prefix, out, depth) { if (!o || typeof o !== 'object' || depth > 3) return; for (var k in o) { var v, p = prefix ? prefix + '.' + k : k; try { v = o[k]; } catch (e) { continue; } if (v === MY_ID || (typeof v === 'string' && v === String(MY_ID))) { out.push({ path: p, score: assigneeScore(p) }); } else if (typeof v === 'string' && /^\d+(\s*,\s*\d+)*$/.test(v) && v.indexOf(',') > 0) { var parts = v.split(','); for (var q = 0; q < parts.length; q++) { if (Number(parts[q]) === MY_ID) { out.push({ path: p, score: assigneeScore(p) - 1 }); break; } } } else if (Array.isArray(v)) { for (var i = 0; i < v.length; i++) { if (v[i] === MY_ID || (typeof v[i] === 'string' && v[i] === String(MY_ID))) { out.push({ path: p, score: assigneeScore(p) - 1 }); break; } else scanMyId(v[i], p, out, depth + 1); } } else if (v && typeof v === 'object') { scanMyId(v, p, out, depth + 1); } } } /** v 是不是 id 这个人(支持数字/字符串/数组/工号名 QPYS00x) */ function samePerson(v, id) { if (v === null || v === undefined) return false; if (Array.isArray(v)) { for (var i = 0; i < v.length; i++) if (samePerson(v[i], id)) return true; return false; } if (String(v) === String(id)) return true; var n = nameToId(v); return n !== null && n === id; } /** 收集一条数据里所有"像平台用户"的字段 → [{path, score, val}] * 注意 score:只有名字里带 accept/verif/assign/owner/handler 的才算 * "这一题分给了谁";标注人/审核人(mark_user_id、audit_user_id…)会被扣分, * 避免把"别人标注过"误判成"这题分给了别人"(这是"分配不能用"的根因之一)。 */ function personFields(o, prefix, out, depth) { if (!o || typeof o !== 'object' || depth > 3) return; for (var k in o) { var v, p = prefix ? prefix + '.' + k : k; try { v = o[k]; } catch (e) { continue; } if (Array.isArray(v)) { if (v.length && typeof v[0] === 'object') { for (var i = 0; i < v.length && i < 3; i++) personFields(v[i], p, out, depth + 1); } else if (v.length && v.length <= 64) { out.push({ path: p, score: assigneeScore(p) - 1, val: v }); } continue; } if (v && typeof v === 'object') { personFields(v, p, out, depth + 1); continue; } if (v === null || v === '') continue; var num = Number(v); var looksId = !isNaN(num) && num > 0 && num < 100000000; var looksName = typeof v === 'string' && /QPYS\d/i.test(v); if ((looksId || looksName) && /id|user|uid|staff|member|worker|person|people|operator|checker|accept|verif|assign|owner|handler/i.test(k)) { out.push({ path: p, score: assigneeScore(p), val: v }); } } } /** 字段名分数到这个线以上,才算"这题分给了谁"(accept_user_id≈12,mark_user_id≈2) */ var ACCEPT_SCORE = 6; /** 这条数据是不是"分给我的"。返回 null = 数据里压根没有分配信息 */ function assignHit(item) { if (MY_ID == null) return null; if (CONFIG.assigneeField) { // 用户写死了字段名 var v = item, ks = String(CONFIG.assigneeField).split('.'); for (var i = 0; i < ks.length && v; i++) v = v[ks[i]]; return samePerson(v, MY_ID) ? { path: CONFIG.assigneeField, score: 99 } : null; } var fs = []; personFields(item, '', fs, 0); var mine = null; for (var j = 0; j < fs.length; j++) { if (samePerson(fs[j].val, MY_ID) && (!mine || fs[j].score > mine.score)) mine = fs[j]; } return mine; } /** 按平台分配筛: * 'mine' = 明确分给我(或我出现在验收人字段上) * 'other' = 明确分给了别人(只在"验收人"类字段上判定) * 'unknown' = 数据里没有分配信息(例如平台按批次整体分配)→ 交给题ID分片 */ function assignVerdict(item) { if (MY_ID == null) return 'unknown'; if (CONFIG.assigneeField) { var hit = assignHit(item); if (hit) { ASSIGN.path = hit.path; ASSIGN.hits++; return 'mine'; } ASSIGN.misses++; return 'other'; } var fs = []; personFields(item, '', fs, 0); if (!fs.length) return 'unknown'; var mine = null, best = null; for (var i = 0; i < fs.length; i++) { var f = fs[i]; if (samePerson(f.val, MY_ID) && (!mine || f.score > mine.score)) mine = f; if (!best || f.score > best.score) best = f; } if (mine && mine.score >= ACCEPT_SCORE) { ASSIGN.path = mine.path; ASSIGN.hits++; return 'mine'; } if (best && best.score >= ACCEPT_SCORE) { ASSIGN.path = ASSIGN.path || best.path; ASSIGN.misses++; return 'other'; } return 'unknown'; } /** 要不要按"平台分配"来筛(auto 模式下先探测一次平台到底有没有逐题分配) */ function useAssignee() { if (CONFIG.shardBy === 'assignee') return true; if (CONFIG.shardBy === 'auto') return ASSIGN.mode === 'assignee'; return false; } /** 探测一次:这批数据里到底有没有"分给我的题"。 * 判定很保守 —— 必须在这一页真的看到"验收人字段里是我"才算逐题分配; * 只有别人的ID、或者压根没有验收人字段(平台按批次整体分配), * 都落回"按题ID平均分",避免出现"谁都看不到题"的空列表。 */ function detectAssignMode(items) { if (CONFIG.shardBy !== 'auto') return; if (ASSIGN.mode === 'assignee') return; // 已确认逐题分配,不必再探 var mine = 0, other = 0, unknown = 0, path = null; var n = Math.min(items.length, 30); var h = ASSIGN.hits, m = ASSIGN.misses, p0 = ASSIGN.path; for (var i = 0; i < n; i++) { var vd = assignVerdict(items[i]); if (vd === 'mine') { mine++; path = path || ASSIGN.path; } else if (vd === 'other') other++; else unknown++; } ASSIGN.hits = h; ASSIGN.misses = m; ASSIGN.path = p0; // 探测不计数 if (mine > 0) { ASSIGN.mode = 'assignee'; ASSIGN.path = path || p0; log('平台有逐题分配信息(字段 ' + (ASSIGN.path || '?') + ',样本 我' + mine + '/别人' + other + '/无信息' + unknown + ')→ 按分配筛选'); } else { ASSIGN.mode = 'hash'; log('这页没有"分给我的题"(样本 我0/别人' + other + '/无信息' + unknown + ')→ 按题ID平均分(本批 ' + effCount() + ' 人,我是第 ' + (effSlot() >= 0 ? effSlot() + 1 : '?') + ' 个)'); } } function mineOf(item) { if (!shardEnabled) return true; if (useAssignee()) { var vd = assignVerdict(item); if (vd === 'mine') return true; if (vd === 'other') return false; if (CONFIG.shardBy === 'assignee') return false; // 强制按分配:没分配信息就不显示 // auto 且这一条没有分配信息 → 落到题ID平均分(下面继续) } var s = effSlot(); if (s === -2) return false; // 平台或本批名单说"这一批没分给我" if (s < 0) return true; // 人数/顺位还没定:不筛(别把页面弄空) var slot = slotOf(item); return slot == null || slot === s; } var hiddenCount = 0; var filteredReqs = 0; var EMPTY_GUARD = false; // "按当前口径我一条都没有" → 暂不筛选,避免页面变空白 var LAST_STAT = null; // 最近一次列表筛选的统计(悬浮窗显示用) var LAST_TOPICS = null; // 最近一次收到的全部题(自检用,筛之前的那份) var ROSTER_LOGGED = false; // 真实分片口径只打印一次 var SAMPLE_ITEM = null; // 列表里第一条题目的字段样本(用来确认“分给谁”字段名) function filterJson(root) { if (!root || typeof root !== 'object') return; if (Array.isArray(root)) { var hasTopic = false, topics = 0, q; for (q = 0; q < root.length; q++) { if (isTopicItem(root[q])) { hasTopic = true; topics++; if (!SAMPLE_ITEM) SAMPLE_ITEM = sampleOf(root[q]); } } var topicItems = null; if (hasTopic) { topicItems = []; for (q = 0; q < root.length; q++) if (isTopicItem(root[q])) topicItems.push(root[q]); LAST_TOPICS = topicItems; // 留一份"收到时的全部题",供 __fsCheck() 自检 harvestRosterFromTopics(topicItems); // 题目里带验收人字段的话,人数就从这儿来 if (findNumId(topicItems[0]) === null) learnIdField(topicItems); var unp = 0; for (q = 0; q < topicItems.length; q++) if (findNumId(topicItems[q]) === null) unp++; UNPARSED = unp; if (unp && !ID_WARNED) { ID_WARNED = true; log('⚠ 有 ' + unp + '/' + topicItems.length + ' 条题的数字ID没认出来 → 这些题不会被筛。' + '请在控制台执行 __fsCheck(),把"题目样本"那一行发我', sampleOf(topicItems[0])); } // 按名次均分:先给这一批建名次表(条数最多差 1 条;只增不减,搜索/筛选不会打乱) if (isBalanced()) { var idList = []; for (q = 0; q < topicItems.length; q++) idList.push(findNumId(topicItems[q])); noteBatchIds(idList, topicItems); } // 2.7.2:这一页是不是拿"半张名次表"筛的?(整批题号还没翻齐,本页条数 < 本批总数) // 是的话记一笔,等 fetchCanonical 补齐后自动刷一次页面,重筛成正确的那一份。 if (isBalanced() && CONFIG.canonicalFetch && !CANON_DONE && CANON_TOTAL != null && topics < CANON_TOTAL) PARTIAL_FILTERED = true; } var sl = effSlot(); if (hasTopic && shardEnabled && (useAssignee() || sl >= 0 || sl === -2)) { detectAssignMode(topicItems); var myList = []; for (var i2 = 0; i2 < topicItems.length; i2++) if (mineOf(topicItems[i2])) myList.push(topicItems[i2]); if (!myList.length) { // 一条都不属于我 —— 两种情况要分开处理: // a) 这次响应是"完整列表"(条数 ≥ 本批名次表的大小):那多半是"人数认错了" // (比如这一批只分给 4 个人,却按固定 7 人名单切,你正好落在没人参与的份里)。 // 这时宁可不筛,也绝不能让页面变成一片空白。 // b) 这次响应只是"搜索/筛选/翻页后的子集":本批名次表比这几条大得多, // 说明"这一页确实没有你的题"是正常结果 —— 那就照筛(可以筛成空), // 否则一搜索就能看到别人的题,"互不重复"就破了。 var subset = isBalanced() && BATCH_RANK && BATCH_RANK.__total > topics; // 2.7.0:固定 3 人等分时,"这一批/这一页没有我的题"是**正常结果**(三个人各占连续 1/3, // 最后一段本来就可能是 0 条),必须照筛 —— 否则你会看到别人的题,"互不重复"就破了。 // 只有仍旧"按平台分配名单"(usePlatformRoster=true)时才保留旧的"先不筛"保护。 var trustRule = !CONFIG.usePlatformRoster; if (subset || trustRule) { EMPTY_GUARD = false; for (var i3 = root.length - 1; i3 >= 0; i3--) { var it3 = root[i3]; if (!isTopicItem(it3)) continue; if (!mineOf(it3)) { root.splice(i3, 1); hiddenCount++; } } if (trustRule && !subset) log('ℹ 这 ' + topics + ' 条题里没有你的份 → 已全部隐藏(' + rosterSrcText(roster().src) + ':3 个账号各占连续的 1/3,不会重复)'); } else if (!EMPTY_GUARD) { EMPTY_GUARD = true; log('⚠ 按当前口径(' + rosterSrcText(roster().src) + ')这 ' + topics + ' 条题一条都不属于你 → 先不筛,避免页面变空。请在控制台执行 __fsCheck(),' + '把"分片来源 / 本批名单ID"两行发我'); } } else { EMPTY_GUARD = false; for (var i = root.length - 1; i >= 0; i--) { var it = root[i]; if (!isTopicItem(it)) continue; if (!mineOf(it)) { root.splice(i, 1); hiddenCount++; } } } } if (hasTopic) { var kept = 0; for (q = 0; q < root.length; q++) if (isTopicItem(root[q])) kept++; LAST_STAT = { 收到: topics, 显示: kept, 本次隐藏: topics - kept, 累计隐藏: hiddenCount, 已筛请求: filteredReqs }; if (!ROSTER_LOGGED) { ROSTER_LOGGED = true; var rl = roster(); log('分片口径:本批 ' + rl.n + ' 人 —— ' + rosterSrcText(rl.src) + ';我是第 ' + (rl.slot >= 0 ? rl.slot + 1 : '?') + ' 份。要看每一份各多少条,控制台执行 __fsCheck()'); } } for (var j = 0; j < root.length; j++) filterJson(root[j]); } else { for (var k in root) filterJson(root[k]); } } function processShardBody(text) { if (!isShardPage() || isFramePage()) return text; // 只在列表页分片 if (!text || (text.charAt(0) !== '{' && text.charAt(0) !== '[')) return text; if (text.indexOf('scene-') < 0) return text; var data; try { data = JSON.parse(text); } catch (e) { return text; } var before = hiddenCount; filterJson(data); if (hiddenCount === before) return text; filteredReqs++; return JSON.stringify(data); } /* ========================= 抽帧 20%(核心) ========================= */ var RE_RESOURCES = /\/v2\/tasks\/package\/([^/?#]+)\/resources(?:[?#]|$)/i; var RE_TASKSINFO = /\/v2\/task-batches\/([^/?#]+)\/packages\/([^/?#]+)\/tasks(?:[?#]|$)/i; function parseQuery(url) { var q = {}; try { var i = String(url).indexOf('?'); if (i < 0) return q; var s = String(url).slice(i + 1).split('#')[0]; s.split('&').forEach(function (kv) { if (!kv) return; var p = kv.split('='); q[decodeURIComponent(p[0])] = decodeURIComponent((p[1] || '').replace(/\+/g, ' ')); }); } catch (e) {} return q; } // 当前帧 id:源码里 params.task_id = params.id function currentFrameId() { var q = parseQuery(location.search); return q.id != null && q.id !== '' ? String(q.id) : (q.task_id != null ? String(q.task_id) : null); } function shouldSample(url) { var q = parseQuery(url); var wt = parseInt(q.work_type, 10); var ac = parseInt(q.access, 10); if (CONFIG.frameWorkTypes.length && CONFIG.frameWorkTypes.indexOf(wt) < 0) return false; if (CONFIG.frameAccesses.length && CONFIG.frameAccesses.indexOf(ac) < 0) return false; return true; } // 固定种子伪随机(mulberry32):同一个 seed ⇒ 同一串数,所有人一致 function mulberry32(seed) { var a = seed >>> 0; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; var t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // 可选:按登录 token 决定轮转份数,让不同的人抽到不同的 20% function rotationOffset() { if (!CONFIG.frameRotateByToken) return 0; var tok = ''; try { tok = localStorage.getItem('token') || ''; } catch (e) { tok = ''; } if (!tok) return 0; var h = 2166136261; for (var i = 0; i < tok.length; i++) { h ^= tok.charCodeAt(i); h = Math.imul(h, 16777619); } var mod = CONFIG.frameRotateMod > 1 ? CONFIG.frameRotateMod : 7; return (h >>> 0) % mod; } /** * 计算要保留的帧下标(升序)。 * @param {number} total 帧总数 * @param {number} rate 抽样比例 0~1 * @param {string} mode 'systematic' | 'random' * @param {number} seed 随机种子 * @param {number} forced 必须保留的下标(当前帧),-1 表示无 * @param {number} rotate 轮转偏移(多人错开抽样时用) */ function pickIndices(total, rate, mode, seed, forced, rotate) { var out = []; if (!(total > 0)) return out; rotate = rotate || 0; var target = Math.round(total * rate); if (target < 1) target = 1; if (target >= total) { for (var k = 0; k < total; k++) out.push(k); return out; } var set = {}; var count = 0; var i, j, t, pool; if (mode === 'random') { var rnd = mulberry32(seed + rotate * 7919); pool = []; for (i = 0; i < total; i++) pool.push(i); for (i = 0; i < target; i++) { // 部分 Fisher–Yates,结果确定 j = i + Math.floor(rnd() * (pool.length - i)); t = pool[i]; pool[i] = pool[j]; pool[j] = t; if (!set[pool[i]]) { set[pool[i]] = 1; count++; } } } else { // 顺序等间隔:i*total/target 取整,首帧在内、间距最均匀 for (i = 0; i < target; i++) { var idx = (Math.floor(i * total / target) + rotate) % total; if (!set[idx]) { set[idx] = 1; count++; } } } // 强制并入当前帧;若超员,踢掉一个离它最近的普通帧 if (forced >= 0 && forced < total && !set[forced]) { set[forced] = 1; count++; if (count > target) { var victim = -1, best = Infinity; for (var key in set) { var v = +key; if (v === forced) continue; var d = Math.abs(v - forced); if (d < best) { best = d; victim = v; } } if (victim >= 0) { delete set[victim]; count--; } } } // 兜底补齐 var pad = 0; while (count < target && pad < total) { if (!set[pad]) { set[pad] = 1; count++; } pad++; } for (var kk in set) out.push(+kk); out.sort(function (a, b) { return a - b; }); return out; } /* ---------- 抽帧比例:10%~90%、步长 10%、默认 20%(可点悬浮窗轮转) ---------- */ /** 可选档位(%)。改 CONFIG.frameRateSteps 就能增删档位(只取 10~90 的) */ function rateSteps() { var s = CONFIG.frameRateSteps; if (!Array.isArray(s) || !s.length) s = [10, 20, 30, 40, 50, 60, 70, 80, 90]; var out = []; for (var i = 0; i < s.length; i++) { var v = Number(s[i]); if (isFinite(v) && v >= 10 && v <= 90) out.push(Math.round(v)); } return out.length ? out : [10, 20, 30, 40, 50, 60, 70, 80, 90]; } /** 把任意输入规整成合法比例(%):就近取 10 的倍数、夹在 10~90;>=100 表示全帧 */ function normRatePct(v) { var n = Number(v); if (!isFinite(n) || n <= 0) n = 20; if (n >= 100) return 100; // 100(含以上)= 显示全部帧 n = Math.round(n / 10) * 10; if (n < 10) n = 10; if (n > 90) n = 90; return n; } function frameRatePct() { return FRAME_MODE === 'full' ? 100 : FRAME_RATE_PCT; } function frameRateText() { return FRAME_MODE === 'full' ? '全部帧' : ('只看 ' + FRAME_RATE_PCT + '% 帧'); } /** 下一档:10%→20%→…→90%→全部帧→10%(全帧时回到第一档) */ function nextRatePct() { var steps = rateSteps(); if (FRAME_MODE === 'full') return steps[0]; var i = steps.indexOf(FRAME_RATE_PCT); if (i < 0) return steps[0]; return (i + 1 < steps.length) ? steps[i + 1] : 100; } /** 启动时读上次选的比例(没有就用 CONFIG.frameSampleRate) */ function loadFrameRate() { var v = null; try { v = localStorage.getItem(CONFIG.frameRateLs); } catch (e) { v = null; } var p; if (v == null || v === '') { p = normRatePct(Math.round(CONFIG.frameSampleRate * 100)); } else { p = normRatePct(v); } if (p >= 100) { FRAME_MODE = 'full'; } else { FRAME_MODE = 'sample'; FRAME_RATE_PCT = p; CONFIG.frameSampleRate = p / 100; } return frameRatePct(); } function saveFrameRate() { try { localStorage.setItem(CONFIG.frameRateLs, String(frameRatePct())); } catch (e) {} } /** * 改抽帧比例:重算已加载包的保留帧下标,然后立刻写回页面 store(不用刷新)。 * @param {number} pct 10~90(就近取 10 的倍数);100 = 全部帧 */ function setFrameRate(pct) { var p = normRatePct(pct); if (p >= 100) { FRAME_MODE = 'full'; } else { FRAME_MODE = 'sample'; FRAME_RATE_PCT = p; CONFIG.frameSampleRate = p / 100; } // 已经拦到的包:按新比例重算(当前帧仍强制保留),并清掉 lastFrames 让写回逻辑重新比一遍 var cur = currentFrameId(); Object.keys(PACKAGES).forEach(function (k) { var st = PACKAGES[k]; if (!st || !st.full) return; var forced = (CONFIG.keepCurrentFrame && cur != null && st.fullIds.indexOf(cur) >= 0) ? st.fullIds.indexOf(cur) : -1; st.keptIdx = pickIndices(st.full.length, CONFIG.frameSampleRate, CONFIG.frameMode, CONFIG.frameSeed, forced, st.rotate || 0); st.lastFrames = null; st.lastView = null; }); saveFrameRate(); var pkg = LAST_PKG || pkgFromUrl(); if (pkg) { LAST_FAIL = ''; if (!applyModeToStore(pkg)) log('切换稍后自动补上:' + LAST_FAIL); } syncPanel(); var st2 = pkg ? PACKAGES[pkg] : null; log('抽帧比例已改为「' + frameRateText() + '」' + (st2 && st2.full ? ':' + buildView(st2, FRAME_MODE, currentFrameId()).frames.length + ' / ' + st2.total + ' 帧' : '')); return frameRatePct(); } /** 点一下悬浮窗:按 10%→20%→…→90%→全部帧→10% 轮转 */ function cycleFrameRate() { return setFrameRate(nextRatePct()); } /** 悬浮窗被点一下(不是拖动):编辑页轮转抽帧比例 */ function onPanelClick() { if (isFramePage()) { cycleFrameRate(); return; } // 列表页不参与点击,避免手一抖就把"只看我的题"改了 } /* 每个包留一份原始数据,供悬浮窗瞬间切换 */ var PACKAGES = {}; // package_id -> { full[], fullIds[], items[], keptIdx[], rotate } var LAST_PKG = null; var URL_LOG = []; // 最近见到的请求,排障用 var RESP_LOG = []; // 最近见到的 JSON 响应"字段长相",用来找出"这题分给谁"是哪个字段 var LAST_FAIL = ''; // 上次切换失败的原因 /** 把一个 JSON 响应压缩成几行"字段说明",用来定位分配字段。 * 例:items[0] 的字段 = task_id=34917, accept_user_id=488, accept_user_name=QPYS001… */ function describeJson(url, data, depth) { var out = []; var val = function (v) { if (v === null) return 'null'; if (Array.isArray(v)) return 'Array(' + v.length + ')'; if (typeof v === 'object') return '{' + Object.keys(v).slice(0, 8).join(',') + '}'; var s = String(v); return s.length > 40 ? s.slice(0, 40) + '…' : s; }; var walk = function (node, path, d) { if (d > 3 || out.length > 26) return; if (Array.isArray(node)) { out.push(path + ' = Array(' + node.length + ')'); if (node.length && typeof node[0] === 'object' && node[0]) { var keys = Object.keys(node[0]); out.push(path + '[0] 字段:' + keys.map(function (k) { return k + '=' + val(node[0][k]); }).join(', ')); } return; } if (node && typeof node === 'object') { var ks = Object.keys(node); ks.forEach(function (k) { var v = node[k]; if (Array.isArray(v)) walk(v, path + (path ? '.' : '') + k, d + 1); else if (v && typeof v === 'object') walk(v, path + (path ? '.' : '') + k, d + 1); else out.push(path + (path ? '.' : '') + k + ' = ' + val(v)); }); return; } out.push(path + ' = ' + val(node)); }; walk(data, '', depth || 0); return out; } function recordResponse(url, data) { try { if (!data || typeof data !== 'object') return; for (var i = 0; i < RESP_LOG.length; i++) { if (RESP_LOG[i].u === url) { RESP_LOG.splice(i, 1); break; } // 同 URL 只留最新 } RESP_LOG.push({ u: url, shape: describeJson(url, data, 0), t: Date.now() }); if (RESP_LOG.length > 25) RESP_LOG.shift(); if (!LAST_PROJECT_KEY) { var mp = /\/v2\/projects\/(pr_[^\/?#]+)/.exec(url) || /\/v\/project\/(pr_[^\/?#]+)/.exec(url); if (mp) LAST_PROJECT_KEY = mp[1]; } harvestRoster(url, data); harvestAllocations(data, url); // 2.7.1:页面自己请求"批次包列表"时,记住它的 URL 和本批总包数, // 然后由脚本按 page_num=50 翻页把整批题号取全(见 fetchCanonical) if (/\/v2\/task-batches\/[^\/?#]+\/packages/.test(url)) { LAST_LIST_URL = url; if (!PAGE_OWN_PER) { var mper = /[?&](?:pageSize|page_size|pageNum|page_num|perPage|per_page|limit)=(\d+)/i.exec(url); PAGE_OWN_PER = mper ? Number(mper[1]) : 20; if (!PAGE_OWN_PER || PAGE_OWN_PER > 50 || PAGE_OWN_PER < 1) PAGE_OWN_PER = 20; } var tot = data.total != null ? data.total : (data.data && data.data.total != null) ? data.data.total : (data.package_total_num != null ? data.package_total_num : null); if (tot != null) CANON_TOTAL = Number(tot); if (!CANON_DONE && !CANON_BUSY) setTimeout(fetchCanonical, 200); } } catch (e) {} } /* ===== 自动识别"本批验收人名单" ===== 需求:用户不可能知道"本批有几个人",所以人数/我是第几个必须自动得到。 做法:扫所有 JSON 响应,找"长度≥2 的数组",其中大部分元素都落在名单 MEMBER_IDS 里 (命中≥60%),就认定它是本批名单。这样人数的除数 N 和我的顺位都能自己算出来。 v2.6.1:必须保证同一批次在不同人的浏览器里认出**同一份**名单 —— ID 统一升序去重,候选之间按"整份都是团队成员 > 命中人数多 > 更长"取舍。 */ var AUTO_ROSTER = null; // { ids:[升序去重], hit:命中人数, len:人数, allIn:是否全在名单内, url:来源 } var AUTO_ROSTER_SRC = ''; var MANUAL_ROSTER = null; // 手动指定的本批验收人(CONFIG.batchMembers / __fsRoster([...])) var MANUAL_SRC = ''; var DOM_SKIP_LOGGED = false; // "接口层已筛过、DOM 不再重复筛"只提示一次 /** 字段名可信度:+2 = 名字就是"验收人"(accept/verif/check/dispatch…); * -2 = 名字是"标注人/审核人/评测人"(那是上一道工序的人,最容易被认错); * 0 = 说不清。实测踩过的坑:一个批次实际分给 3 人,脚本却从一个 * [488,490] 这样的数组里认出"本批名单 2 人",把第 3 个人判成"没分给你"。 */ function rosterKeyScore(key) { var k = String(key || '').toLowerCase(); if (/accept|verif|check|dispatch|claim|allocat|assign|owner|handler|receiver/.test(k)) return 2; if (/mark|annot|label|audit|review|exam|grade|score/.test(k)) return -2; return 0; } /** 候选名单 a 是否比当前 b 更可信。 * 先看来源(本批/任务类接口 > 其它 > 用户/团队名册/统计类)+ 字段名像不像"验收人", * 再看命中人数、是否整份都是团队成员、最后看长度。 * 避免把"团队名册""排行榜""标注人列表"当成本批验收人。 */ function rosterBetter(a, b) { if (!b) return true; var as = (a.sc || 0) + (a.kw || 0), bs = (b.sc || 0) + (b.kw || 0); if (as !== bs) return as > bs; if ((a.sc || 0) !== (b.sc || 0)) return (a.sc || 0) > (b.sc || 0); if (a.hit !== b.hit) return a.hit > b.hit; if (!!a.allIn !== !!b.allIn) return !!a.allIn; // 整份都在团队名单里 → 更像"本批验收人" return a.len > b.len; } /** 来源可信度:2 = 本批/任务类接口,1 = 其它,0 = 用户名册/统计报表类 */ function rosterUrlScore(url) { var u = String(url || '').toLowerCase(); if (/accept|verif|assign|dispatch|claim|batch|task|package|project/.test(u)) return 2; if (/user|member|team|staff|employee|org|dept|role|perf|stat|rank|report|log/.test(u)) return 0; return 1; } /** 从一条用户对象里取"这个人是谁"。 * 实测坑:接口里的人常常不是扁平的 —— 有 {user:{id:488}}、{accept_user:{user_id:488}}、 * {handler:{...}} 这种挂子对象的形态,旧版只认扁平字段 ⇒ 整个数组一个 id 都取不出, * 于是这份正确名单被跳过,脚本退而认了某个 2 人的错名单。 */ function pickPersonId(it, dep) { dep = dep || 0; if (it === null || it === undefined) return null; if (typeof it === 'number') return it > 0 ? it : null; if (typeof it === 'string') { if (/^\d+$/.test(it)) return Number(it); return nameToId(it); } if (typeof it !== 'object' || Array.isArray(it)) return null; var keys = ['id', 'user_id', 'uid', 'userid', 'member_id', 'staff_id', 'user_uuid', 'employee_id', 'account_id', 'create_user', 'create_user_id', 'person_id', 'receiver_id']; for (var i = 0; i < keys.length; i++) { var v = it[keys[i]]; if (v === undefined || v === null || v === '') continue; if (typeof v === 'object') { // {user:{id:488}} if (dep >= 2) continue; var r0 = pickPersonId(v, dep + 1); if (r0 !== null) return r0; continue; } var n = Number(v); if (!isNaN(n) && n > 0 && n < 100000000) return n; var byN = nameToId(v); if (byN !== null) return byN; } for (var k in it) { if (!Object.prototype.hasOwnProperty.call(it, k)) continue; var val = it[k]; if (/(^|_)(user|member|staff|worker|person)?_?id$/i.test(k) && !/task|topic|package|project|batch|group|role|dept|org/i.test(k)) { var m = Number(val); if (!isNaN(m) && m > 0 && m < 100000000) return m; var b = nameToId(val); if (b !== null) return b; } // 人是挂在子对象里的:{handler:{...}} / {verifier:{user_id:488}} if (dep < 2 && val && typeof val === 'object' && !Array.isArray(val) && /user|member|staff|person|account|handler|owner|verif|accept|check|mark|audit|quality/i.test(k)) { var r1 = pickPersonId(val, dep + 1); if (r1 !== null) return r1; } } return null; } function harvestRoster(url, data) { var best = null; var sc = rosterUrlScore(url); var consider = function (ids, key) { if (!ids || ids.length < 2) return; var hit = 0; for (var j = 0; j < ids.length; j++) if (MEMBER_IDS.indexOf(ids[j]) >= 0) hit++; // 名单里的人占多数才算"本批名单",避免把"全站用户列表"当成本批 if (hit < 2 || hit < Math.ceil(ids.length * 0.6)) return; var sids = sortedIds(ids); var cand = { ids: sids, hit: hit, len: sids.length, allIn: sids.length === ids.length, url: url, sc: sc, kw: rosterKeyScore(key) }; if (rosterBetter(cand, best)) best = cand; }; var walk = function (node, d, key) { if (!node || typeof node !== 'object' || d > 5) return; if (Array.isArray(node)) { if (node.length >= 2 && node.length <= 400) { var ids = [], bad = 0, lim = Math.min(node.length, 80); for (var i = 0; i < lim; i++) { var id = pickPersonId(node[i]); if (id === null) bad++; else ids.push(id); } // 允许个别元素认不出(混进 null/空对象/统计行),但不允许大面积认不出 if (bad <= Math.max(1, Math.floor(ids.length * 0.4))) consider(ids, key); // 实测坑:一个批次的人员数组常常把**各道工序的人混在一起** //(allocation_info.user_items = 标注4人 + 审核1人 + 验收3人), // 直接看整份数组会因"命中比例只有 50%"被否掉。带 work_type 的数组 // 先按"验收工序(4)"过滤一遍再当候选。 var wtIds = [], wtN = 0; for (var w = 0; w < lim; w++) { var el = node[w]; if (!el || typeof el !== 'object' || el.work_type == null) continue; wtN++; if (Number(el.work_type) !== ACCEPT_WT) continue; var wid = pickPersonId(el); if (wid !== null) wtIds.push(wid); } if (wtN >= 2 && wtIds.length >= 2) consider(wtIds, 'accept:' + key); } for (var k = 0; k < node.length && k < 8; k++) walk(node[k], d + 1, key); return; } var rk = (d > 0 || key) ? rowBatchKey(node) : ''; if (rk) { // 带"批次 key"的对象 = 批次列表里的一行(不是本批就是别的批)。 // 只有明确知道当前 task_key 且对得上才认;否则整棵子树跳过 —— // 实测踩过的坑:随机挑中别批次的 2 人名单,把本批 3 人的人数数成 2。 var tk0 = taskKeyNow(); if (!tk0 || rk !== tk0) return; } for (var kk in node) { if (!Object.prototype.hasOwnProperty.call(node, kk)) continue; var v = node[kk]; try { walk(v, d + 1, kk); } catch (e2) {} // 名单可能是一整串名字:{accept_users:"QPYS001,QPYS002,QPYS003"} if (typeof v === 'string' && v.length < 600 && /QPYS/i.test(v)) { var nms = v.match(/QPYS\s*0*[1-9]\d*/gi) || []; if (nms.length >= 2) { var nids = []; for (var q = 0; q < nms.length; q++) { var nid = nameToId(nms[q]); if (nid !== null) nids.push(nid); } consider(nids, kk); } } } }; try { walk(data, 0, ''); } catch (e3) {} if (best && rosterBetter(best, AUTO_ROSTER)) { var chg = !AUTO_ROSTER || AUTO_ROSTER.len !== best.len || String(AUTO_ROSTER.ids) !== String(best.ids); AUTO_ROSTER = best; AUTO_ROSTER_SRC = best.url; if (chg) log('发现本批验收人名单:' + best.len + ' 人(' + best.url + ')', best.ids); } } /* ===== 本批"工序分配名单":唯一权威的名单来源 ===== 实测(用探测脚本在"任务管理"页抓到的真实响应): GET /v2/projects/{项目key}/task-batches?…&page=N → data.items[i] 每一行是一个批次,行里有: task_batch_key : "260729ae480" ← 就是任务页地址里的 task_key allocation_info: { user_items:[ {user_id, user_name, work_type, access}, … ], guild_items:[ … 团队 … ] } user_items 里的 work_type = 1标注 / 2审核 / 3质检 / 4验收, "验收工序 用户(3) QPYS001(488) QPYS002(490) QPYS003(1822)" 就是 user_items 里 work_type=4 的那几个(分配弹窗点开时**不再发新请求**, 数据早就跟着批次列表回来了 —— 所以只能从这条响应里认)。 两个致命细节(都是实测踩过的坑): ① **必须按 task_batch_key 对上本批**。批次列表一页 20 行,随便挑一行就是 别的批次的人(实测就是这么把一份 2 人名单认成本批的); ② **必须按 work_type=4 过滤**。不过滤就会把标注人/审核人(shibiaoaaa01、 xq-luoyongling)当成本批验收人,人数直接数错。 另外把认到的名单按 task_batch_key 缓存到 localStorage:只要在任何页面 (比如任务管理页)见过一次本批的分配配置,以后打开验收页就能直接用。 */ var ACCEPT_WT = 4; var ROSTER_CACHE = {}; // task_batch_key → [验收人 id,升序] var CACHE_LS = 'fs20_batch_roster'; var LAST_PROJECT_KEY = ''; var ROSTER_PROBED = {}; var ROSTER_PROBE_URLS = []; function taskKeyNow() { var m = (location.search || '').match(/[?&]task_key=([^&]*)/); var tk = m ? decodeURIComponent(m[1]) : ''; if (tk) return tk; m = /\/v2\/task-batches\/([^\/?#]+)/.exec(location.href || ''); return m ? m[1] : ''; } function projectKeyNow() { if (LAST_PROJECT_KEY) return LAST_PROJECT_KEY; var m = /\/v\/project\/(pr_[^\/?#]+)/.exec(location.pathname || ''); return m ? m[1] : ''; } function loadRosterCache() { try { var s = localStorage.getItem(CACHE_LS); if (s) { var o = JSON.parse(s); if (o && typeof o === 'object') ROSTER_CACHE = o; } } catch (e) {} } function saveRosterCache() { try { localStorage.setItem(CACHE_LS, JSON.stringify(ROSTER_CACHE)); } catch (e) {} } function cacheSet(key, ids) { if (!key || !ids || ids.length < 2 || ids.length > 64) return false; var a = sortedIds(ids); if (ROSTER_CACHE[key] && String(ROSTER_CACHE[key]) === String(a)) return false; ROSTER_CACHE[key] = a; saveRosterCache(); return true; } /** 批次列表行里"这一批的 key"(必须长度够,免得把普通字段当 key) */ function rowBatchKey(row) { if (!row || typeof row !== 'object' || Array.isArray(row)) return ''; var names = ['task_batch_key', 'task_key', 'batch_key', 'taskBatchKey', 'batchKey']; for (var i = 0; i < names.length; i++) { var v = row[names[i]]; if (typeof v === 'string' && v.length >= 8 && v.length <= 64) return v; } for (var k in row) { if (!Object.prototype.hasOwnProperty.call(row, k)) continue; if (!/^(task_)?batch_?key$|^task_key$/i.test(k)) continue; var v2 = row[k]; if (typeof v2 === 'string' && v2.length >= 8 && v2.length <= 64) return v2; } return ''; } /** 从 allocation_info(或同义字段)里取出**验收工序**的人。 必须过滤 work_type=4:不过滤就会把标注/审核的人算进来。 */ function acceptIdsFromAlloc(alloc) { if (!alloc || typeof alloc !== 'object' || Array.isArray(alloc)) return null; var arrs = []; for (var k in alloc) { if (!Object.prototype.hasOwnProperty.call(alloc, k)) continue; var v = alloc[k]; if (Array.isArray(v) && v.length >= 1 && /user|member|person|staff|accept|verif|assign/i.test(k)) arrs.push(v); } for (var i = 0; i < arrs.length; i++) { var list = arrs[i], ids = [], hasWt = false, out = 0; for (var j = 0; j < list.length && j < 200; j++) { var it = list[j]; if (it && typeof it === 'object' && it.work_type != null) { hasWt = true; if (Number(it.work_type) !== ACCEPT_WT) continue; } var id = pickPersonId(it); if (id !== null) ids.push(id); else out++; } if (hasWt && ids.length >= 1 && out <= 2) return ids; } return null; } /** 采纳一份名单(升序去重;至少 2 人、至多 64 人) */ function adoptRoster(ids, url) { if (!CONFIG.usePlatformRoster) return false; // 现口径:固定 3 人等分,不采纳平台名单 if (!ids || ids.length < 2 || ids.length > 64) return false; var sids = sortedIds(ids); var hit = 0; for (var i = 0; i < sids.length; i++) if (MEMBER_IDS.indexOf(sids[i]) >= 0) hit++; var cand = { ids: sids, hit: hit, len: sids.length, allIn: hit === sids.length, url: url, sc: 3, kw: 4 }; if (!rosterBetter(cand, AUTO_ROSTER)) return false; AUTO_ROSTER = cand; AUTO_ROSTER_SRC = url; log('本批验收人名单:' + sids.length + ' 人', sids); return true; } /** 从任意响应里收集"批次分配配置"(allocation_info),并按 key 缓存 */ function harvestAllocations(data, url) { if (!data || typeof data !== 'object') return 0; var found = 0, seen = 0; var walk = function (node, d) { if (!node || typeof node !== 'object' || d > 6 || seen > 5000) return; seen++; if (Array.isArray(node)) { for (var i = 0; i < node.length && i < 200; i++) walk(node[i], d + 1); return; } var alloc = node.allocation_info || node.allocationInfo || null; if (alloc && typeof alloc === 'object') { var ids = acceptIdsFromAlloc(alloc); var rk = rowBatchKey(node); if (ids) { found++; if (rk && cacheSet(rk, ids)) { log('记住批次 ' + rk + ' 的验收人:' + ids.length + ' 人', sortedIds(ids)); } var tk = taskKeyNow(); if (tk && rk === tk) adoptRoster(ids, '批次分配配置 allocation_info(' + url.replace(/^https?:\/\/[^/]+/, '') + ')'); } } for (var k in node) { if (!Object.prototype.hasOwnProperty.call(node, k)) continue; var v = node[k]; if (v && typeof v === 'object') walk(v, d + 1); } }; try { walk(data, 0); } catch (e) {} return found; } /** 用本地缓存的名单(进验收页时第一时间就知道几人、我是第几份) */ function applyCachedRoster() { var tk = taskKeyNow(); if (!tk) return false; var ids = ROSTER_CACHE[tk]; if (!ids || ids.length < 2) return false; if (AUTO_ROSTER && AUTO_ROSTER.ids && String(AUTO_ROSTER.ids) === String(sortedIds(ids))) return true; return adoptRoster(ids, '本地缓存(批次 ' + tk + ',之前在某页见过它的分配配置)'); } /** 主动去问一次"本批的分配配置":按 task_key 过滤批次列表,命中就直接定名单。 为什么需要:验收任务页自己**不返回**本批名单(实测),而分配弹窗点开时 也不再发请求 —— 不主动问就只能靠"用户先去任务管理页逛一圈"。 */ function probeRoster(force) { var tk = taskKeyNow(), pk = projectKeyNow(); if (!tk || !pk) return false; if (ROSTER_PROBED[tk] && !force) return false; ROSTER_PROBED[tk] = 1; var url = '/v2/projects/' + pk + '/task-batches?task_name=&task_key=' + encodeURIComponent(tk) + '&line_status=&page_num=100&page=1&creator_ids=&file_name=&seg_dir_name='; ROSTER_PROBE_URLS.push(url); try { var x = new XMLHttpRequest(); x.open('GET', url, true); x.onload = function () { try { var j = JSON.parse(x.responseText); var f = harvestAllocations(j, url); var got = applyCachedRoster(); log('主动查本批分配配置:收到 ' + f + ' 条' + (got ? ',已定名单' : ',还没对上本批(' + tk + ')') + (AUTO_ROSTER ? ',当前 ' + AUTO_ROSTER.len + ' 人' : '')); } catch (e) {} }; x.send(); } catch (e) { return false; } return true; } function pkgFromUrl() { var q = parseQuery(location.search); return q.package_id || null; } function idsOf(arr) { var out = []; for (var i = 0; i < (arr ? arr.length : 0); i++) { var f = arr[i]; out.push(f && f.task_id != null ? String(f.task_id) : ''); } return out; } function sameSeq(a, b) { if (!a || !b || a.length !== b.length) return false; for (var i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } function buildView(st, mode, curId) { var idxs; if (mode === 'full') { idxs = []; for (var i = 0; i < st.full.length; i++) idxs.push(i); } else { idxs = st.keptIdx.slice(); // 当前帧万一不在抽样集里(用户手动翻到过),临时并入,避免 task_id 匹配失败 if (CONFIG.keepCurrentFrame && curId != null) { var f = st.fullIds.indexOf(curId); if (f >= 0 && idxs.indexOf(f) < 0) { idxs.push(f); idxs.sort(function (a, b) { return a - b; }); } } } var frames = idxs.map(function (i) { return st.full[i]; }); return { idxs: idxs, frames: frames }; } /** store 里现在装的是不是这个包的数据 */ function storeIsThisPackage(st, cfg) { if (!st || !cfg || !Array.isArray(cfg.frames) || !cfg.frames.length) return false; var ids = idsOf(cfg.frames); // 1) 正好是全帧 —— 注意:axios 会把响应文本 JSON.parse 一遍, // 拿到的数组和我们手里的不是同一个对象,只能比内容 if (sameSeq(ids, st.fullIds)) return true; // 2) 正好是我们上次给出去的那份(抽样结果) if (st.lastFrames && sameSeq(ids, idsOf(st.lastFrames))) return true; // 3) 兜底:当前帧属于这个包,且 store 里的帧全都来自这个包 var cur = currentFrameId(); if (cur != null && st.fullIds.indexOf(cur) >= 0 && ids.indexOf(cur) >= 0) { for (var i = 0; i < ids.length; i++) { if (st.fullIds.indexOf(ids[i]) < 0) return false; } return true; } return false; } /** 接口没拦到时兜底:把页面已经加载好的帧列表当作"原始全帧" */ function adoptFromStore(pkgId, cfg) { var exist = PACKAGES[pkgId]; if (exist && exist.full) return exist; if (!cfg || !Array.isArray(cfg.frames) || !cfg.frames.length) return null; var arr = cfg.frames.slice(); var ids = idsOf(arr); var cur = currentFrameId(); var forced = (CONFIG.keepCurrentFrame && cur != null) ? ids.indexOf(cur) : -1; var rot = rotationOffset(); var st = { full: arr, fullIds: ids, items: Array.isArray(cfg.tasksInfo) ? cfg.tasksInfo : null, keptIdx: pickIndices(arr.length, CONFIG.frameSampleRate, CONFIG.frameMode, CONFIG.frameSeed, forced, rot), rotate: rot, total: arr.length, packageId: pkgId, adopted: true, lastFrames: arr, at: Date.now() }; PACKAGES[pkgId] = st; LAST_PKG = pkgId; log('接口没拦到,改用页面已加载的帧列表作为全帧数据(' + arr.length + ' 帧)'); return st; } /** 取当前包的原始数据:优先用拦到的,其次用页面里的 */ function ensurePackage(pkgId) { var id = pkgId || LAST_PKG || pkgFromUrl(); if (!id) return null; var st = PACKAGES[id]; if (st && st.full) return st; var cfg = findConfigState(); return cfg ? adoptFromStore(id, cfg) : null; } // 把某个包的原始响应拆成"原始数据 + 抽样结果" function preparePackage(obj, url) { if (!obj || typeof obj !== 'object') return null; var arr = obj.task_source; if (!Array.isArray(arr) || arr.length === 0) return null; if (!shouldSample(url)) { log('按配置跳过该请求', url); return null; } var q = parseQuery(url); var pkgId = q.package_id || 'unknown'; var total = arr.length; var ids = arr.map(function (f) { return f && f.task_id != null ? String(f.task_id) : ''; }); var cur = currentFrameId(); var forced = -1; if (CONFIG.keepCurrentFrame && cur != null) { forced = ids.indexOf(cur); if (forced < 0) log('注意:当前帧 id=' + cur + ' 不在本包帧列表里(可能跨包了)'); } var rot = rotationOffset(); var kept = pickIndices(total, CONFIG.frameSampleRate, CONFIG.frameMode, CONFIG.frameSeed, forced, rot); var st = { full: arr, fullIds: ids, items: PACKAGES[pkgId] && PACKAGES[pkgId].items, // 可能先到,后面补 keptIdx: kept, rotate: rot, total: total, packageId: pkgId, at: Date.now() }; if (kept.length >= total) { log('帧数不足,未抽稀'); st.keptIdx = st.full.map(function (_, i) { return i; }); } PACKAGES[pkgId] = st; LAST_PKG = pkgId; log('包 ' + pkgId + ':共 ' + total + ' 帧,抽样保留 ' + kept.length + ' 帧(' + CONFIG.frameMode + ')'); return st; } // 改写响应体:默认模式是 sample 就当场裁掉 function transformResources(obj, url) { var st = preparePackage(obj, url); if (!st) return null; var view = buildView(st, FRAME_MODE, currentFrameId()); st.lastView = view.frames.length; if (FRAME_MODE !== 'sample' || view.frames.length >= st.full.length) { st.lastFrames = st.full; // 全帧模式:不改写,页面拿原始数据 return null; } obj.task_source = view.frames; st.lastFrames = view.frames; // 记下 store 里将会持有的数组引用,按钮比对用 if (CONFIG.syncCountFields) { ['total', 'total_count', 'count', 'frame_count', 'frame_total', 'num', 'size'].forEach(function (key) { if (typeof obj[key] === 'number' && obj[key] === st.total) obj[key] = view.frames.length; }); } return obj; } /* ------------ 找到页面的 store,按钮切换时直接改它 ------------ */ function findConfigState() { var cands = []; try { var byId = document.getElementById('app'); if (byId) cands.push(byId); var dv = document.querySelector('[data-v-app]'); if (dv) cands.push(dv); if (document.body) { var kids = document.body.children; for (var i = 0; i < kids.length; i++) cands.push(kids[i]); } } catch (e) {} for (var c = 0; c < cands.length; c++) { var el = cands[c]; if (!el) continue; // Vue3 try { var app = el.__vue_app__; var store = app && app.config && app.config.globalProperties && app.config.globalProperties.$store; if (store && store.state && store.state.config && Array.isArray(store.state.config.frames)) { return store.state.config; } } catch (e) {} // Vue2 try { var vm = el.__vue__; var s2 = vm && vm.$store; if (s2 && s2.state && s2.state.config && Array.isArray(s2.state.config.frames)) { return s2.state.config; } } catch (e) {} } // 页面里的全局实例 try { var inst = window.instance; if (inst) { var s3 = inst.$store || inst.store; if (s3 && s3.state && s3.state.config && Array.isArray(s3.state.config.frames)) return s3.state.config; var sm = inst.storeManager; if (sm && typeof sm.getStore === 'function') { var s4 = sm.getStore(); if (s4 && s4.state && s4.state.config && Array.isArray(s4.state.config.frames)) return s4.state.config; } } } catch (e) {} return null; } /** 把当前模式写回 store:frames / frameIds / tasksInfo 一起改 */ function applyModeToStore(pkgId) { var cfg = findConfigState(); if (!cfg || !Array.isArray(cfg.frames) || !cfg.frames.length) { LAST_FAIL = '页面数据还没就绪(读不到 store 里的帧列表)'; return false; } var st = ensurePackage(pkgId || LAST_PKG); if (!st) { LAST_FAIL = '还没拿到帧列表(接口没拦到,页面里也还没加载)'; return false; } // store 里现在必须是这个包的数据才动手(比内容,不比引用) if (!storeIsThisPackage(st, cfg)) { LAST_FAIL = 'store 里当前不是这个包的帧数据(可能还在加载,或已经切到别的包)'; return false; } if (!Array.isArray(st.items) && Array.isArray(cfg.tasksInfo)) st.items = cfg.tasksInfo; var view = buildView(st, FRAME_MODE, currentFrameId()); // 已经是目标状态就别重复写(定时器会反复调用这里) if (sameSeq(idsOf(cfg.frames), idsOf(view.frames))) { st.lastFrames = cfg.frames; st.lastView = view.frames.length; if (LAST_FAIL) log('重试成功:' + LAST_FAIL); LAST_FAIL = ''; return true; } var map = {}; for (var i = 0; i < view.frames.length; i++) { var f = view.frames[i]; if (f && f.task_id != null) map[String(f.task_id)] = i; } cfg.frames = view.frames; cfg.frameIds = map; if (CONFIG.syncTasksInfo && Array.isArray(st.items) && Array.isArray(cfg.tasksInfo)) { cfg.tasksInfo = view.idxs.map(function (k) { return st.items[k]; }); } st.lastFrames = view.frames; st.lastView = view.frames.length; LAST_FAIL = ''; log('已切换到「' + (FRAME_MODE === 'sample' ? '只看 ' + frameRatePct() + '%' : '全部帧') + '」:' + view.frames.length + ' / ' + st.total + ' 帧' + (st.adopted ? '(全帧数据取自页面缓存)' : '')); return true; } /** * 定时兜底:不管模式按钮有没有被点过,只要当前是 sample 模式, * 且 store 里还挂着"全帧",就自动收拢到当前比例(默认 20%)。这样即使接口没拦到也能生效。 */ function autoEnforce() { if (FRAME_MODE !== 'sample') return; var cfg = findConfigState(); if (!cfg || !Array.isArray(cfg.frames) || !cfg.frames.length) return; var pkg = LAST_PKG || pkgFromUrl(); if (!pkg) return; var st = PACKAGES[pkg]; if (!st || !st.full) st = adoptFromStore(pkg, cfg); if (!st) return; if (sameSeq(idsOf(cfg.frames), st.fullIds)) applyModeToStore(pkg); } /** * 每帧状态(tasksInfo)和 frames 是按下标一一对应的, * 帧列表被裁短之后必须把 tasksInfo 也按同样规则重排, * 否则页面会把"第 3 帧的状态"显示成"第 3 项"的状态。 * 这里用帧 id 反查下标,顺序无关,安全。 */ function alignTasksInfo() { var cfg = findConfigState(); if (!cfg || !Array.isArray(cfg.frames) || !cfg.frames.length) return; var pkg = LAST_PKG || pkgFromUrl(); if (!pkg) return; var st = PACKAGES[pkg]; if (!st || !st.full || !Array.isArray(st.items)) return; if (st.items.length !== st.full.length) return; // 原始 items 还没到齐 if (!Array.isArray(cfg.tasksInfo)) return; if (cfg.tasksInfo.length === cfg.frames.length) return; // 已经对齐 var before = cfg.tasksInfo.length; var ids = idsOf(cfg.frames); var out = []; for (var i = 0; i < ids.length; i++) { var k = st.fullIds.indexOf(ids[i]); out.push(k >= 0 && st.items[k] !== undefined ? st.items[k] : cfg.tasksInfo[i]); } cfg.tasksInfo = out; log('已把每帧状态 tasksInfo 从 ' + before + ' 项对齐到抽样后的 ' + out.length + ' 项'); } /* ======================= 网络拦截(一个钩子管两件事) ======================= */ function installHooks() { var proto = XMLHttpRequest.prototype; var rawText = Object.getOwnPropertyDescriptor(proto, 'responseText'); var rawResp = Object.getOwnPropertyDescriptor(proto, 'response'); var rawOpen = proto.open; var rawSend = proto.send; proto.open = function (method, url) { try { this.__u = url == null ? '' : String(url); } catch (e) { this.__u = ''; } // 排障用:记下每一次请求,看抽帧接口到底有没有被拦到 try { if (typeof url === 'string' && url.indexOf('/v2/') >= 0) { URL_LOG.push({ u: url, res: RE_RESOURCES.test(url), t: Date.now() }); if (URL_LOG.length > 60) URL_LOG.shift(); } } catch (e) {} if (CONFIG.enlargePageSize && isShardPage() && typeof url === 'string') { // 实测这个平台的列表接口用的是 page_num(如 ?…&page_num=20&page=1)。 // 必须撑大,否则列表被分页 → 每人只看到一部分包 → "名次均分"在三个人 // 手里算出的名次表不一样,就会重复/遗漏。 // 【2.7.1】平台限制:分页数据量最大 50(传 5000 会报"参数错误,分页数据量暂不支持超过50"), // 所以这里只撑到 50,剩下的靠 fetchCanonical() 自己翻页把整批题号补齐。 var bigger = url.replace(/([?&](?:pageSize|page_size|pageNum|page_num|perPage|per_page|limit)=)\d+/gi, function (all, p) { return p + '50'; }); if (bigger !== url) { log('pageSize 已撑大:', url, '->', bigger); arguments[1] = bigger; } } return rawOpen.apply(this, arguments); }; proto.send = function () { try { var u = this.__u || ''; this.__isRes = RE_RESOURCES.test(u); this.__isTasks = RE_TASKSINFO.test(u); if (this.__isTasks) { var self = this; this.addEventListener('loadend', function () { try { var data; if (self.responseType === 'json') data = rawResp.get.call(self); else { var t = rawText.get.call(self); data = t ? JSON.parse(t) : null; } var m = RE_TASKSINFO.exec(u); var pkgId = m && m[2]; if (data && Array.isArray(data.items) && pkgId) { if (!PACKAGES[pkgId]) PACKAGES[pkgId] = { packageId: pkgId }; PACKAGES[pkgId].items = data.items; log('已记录 tasksInfo:包 ' + pkgId + ' 共 ' + data.items.length + ' 项'); } } catch (e) {} }); } } catch (e) { log('send 钩子异常', e); } return rawSend.apply(this, arguments); }; function handle(xhr, value) { if (xhr.readyState !== 4) return value; if (xhr.__outDone) return xhr.__out; var v = value; // 只"看"不"改":把每个 JSON 响应的字段长相记下来,用来定位 // ①"这题分给谁"是哪个字段 ②本批验收人名单在哪个接口(harvestRoster 会用它算人数) if (!xhr.__isRes && !xhr.__noShard && typeof v === 'string' && v && (v.charAt(0) === '{' || v.charAt(0) === '[') && v.length < 400000) { try { recordResponse(xhr.__u, JSON.parse(v)); } catch (e) {} } if (xhr.__isRes && typeof v === 'string') { try { var obj = JSON.parse(v); var out = transformResources(obj, xhr.__u); if (out) v = JSON.stringify(out); } catch (e) { log('抽帧解析失败,按原样放行', e); } } if (typeof v === 'string' && !xhr.__isRes && !xhr.__isTasks && !xhr.__noShard) { try { v = processShardBody(v); } catch (e) { log('分片过滤出错', e); } } xhr.__outDone = true; xhr.__out = v; return v; } if (rawText && rawText.get) { Object.defineProperty(proto, 'responseText', { configurable: true, enumerable: rawText.enumerable, get: function () { return handle(this, rawText.get.call(this)); } }); } if (rawResp && rawResp.get) { Object.defineProperty(proto, 'response', { configurable: true, enumerable: rawResp.enumerable, get: function () { var type = this.responseType; var r = rawResp.get.call(this); if (type === '' || type === 'text') return handle(this, r); if (type === 'json' && this.readyState === 4 && this.__isRes && this.__jsonOut === undefined) { try { this.__jsonOut = transformResources(r, this.__u) || null; } catch (e) { this.__jsonOut = null; } } return this.__jsonOut != null ? this.__jsonOut : r; } }); } } function installFetchHook() { var rawFetch = window.fetch; if (!rawFetch) return; window.fetch = function (input, init) { var url = typeof input === 'string' ? input : (input && input.url) || ''; if (CONFIG.enlargePageSize && isShardPage()) { // 【2.7.1】平台上限 50(传更大的值会报"参数错误,分页数据量暂不支持超过50") var bigger = url.replace(/([?&](?:pageSize|page_size|pageNum|page_num|perPage|per_page|limit)=)\d+/gi, function (all, p) { return p + '50'; }); if (bigger !== url && typeof input === 'string') input = bigger; } var isRes = RE_RESOURCES.test(url); var isTasks = RE_TASKSINFO.test(url); return rawFetch.call(this, input, init).then(function (res) { if (!isRes && !isTasks && !isShardPage()) return res; var ct = res.headers.get('content-type') || ''; if (ct.indexOf('json') < 0) return res; return res.clone().text().then(function (txt) { try { if (!isRes && txt && (txt.charAt(0) === '{' || txt.charAt(0) === '[') && txt.length < 400000) { recordResponse(url, JSON.parse(txt)); } } catch (e) {} if (isTasks) { try { var td = JSON.parse(txt); var tm = RE_TASKSINFO.exec(url); var tpid = tm && tm[2]; if (td && Array.isArray(td.items) && tpid) { if (!PACKAGES[tpid]) PACKAGES[tpid] = { packageId: tpid }; PACKAGES[tpid].items = td.items; log('已记录 tasksInfo:包 ' + tpid + ' 共 ' + td.items.length + ' 项'); } } catch (e) {} } var out = txt; if (isRes) { try { var obj = JSON.parse(txt); var o2 = transformResources(obj, url); if (o2) out = JSON.stringify(o2); } catch (e) {} } out = isRes || isTasks ? out : processShardBody(out); if (out === txt) return res; return new Response(out, { status: res.status, statusText: res.statusText, headers: res.headers }); }).catch(function () { return res; }); }); }; } /* ===================== DOM 兜底过滤(只针对分片) ===================== */ function topicColIndex(table) { var ths = table.querySelectorAll('thead th .cell'); for (var i = 0; i < ths.length; i++) { if ((ths[i].textContent || '').trim() === '题ID') return i; } return -1; } function applyDomFilter() { if (!isShardPage() || isFramePage()) return; var s0 = effSlot(); if (s0 < 0 && s0 !== -2) return; // -2 = 这一批没分给我,下面照样把行藏掉 // 接口层已经把列表筛过了(表格里本来就只剩我的题),DOM 这一层就不要再动: // DOM 只能看到"题ID"这一列,而接口层的分片键可能是另一个字段(实测这批的响应里 // 只有 package_id,没有题号),两把尺子量同一批数据会多藏行。 if (DOM_SKIP_LOGGED === false && (filteredReqs > 0 || (LAST_STAT && LAST_STAT['收到'] > 0))) { DOM_SKIP_LOGGED = true; log('列表已由接口层筛过 → DOM 兜底不再重复筛(避免两套分片键不一致)'); } if (filteredReqs > 0 || (LAST_STAT && LAST_STAT['收到'] > 0)) return; var tables = document.querySelectorAll('.el-table'); for (var t = 0; t < tables.length; t++) { var table = tables[t]; var col = topicColIndex(table); if (col < 0) continue; var rows = table.querySelectorAll('.el-table__body-wrapper tbody tr'); var visible = 0; var s = effSlot(); var keep = [], drop = []; // 先收齐这一页显示的题号,建名次表(按名次均分,每份条数最多差 1) var rowIds = [], rowTrs = []; for (var r0 = 0; r0 < rows.length; r0++) { var tds0 = rows[r0].children; if (!tds0 || tds0.length <= col) continue; var m0 = (tds0[col].textContent || '').trim().match(/^(\d+)$/); if (!m0) continue; rowIds.push(Number(m0[1])); rowTrs.push(rows[r0]); } var domRank = isBalanced() ? rankMapOf(rowIds) : null; for (var r = 0; r < rowTrs.length; r++) { var tr = rowTrs[r]; var id = rowIds[r]; var mine = true; // 按平台分配筛的模式下,DOM 这一层看不出“这题分给谁”,交给接口层筛,这里不动 if (shardEnabled && !useAssignee()) { if (s === -2) mine = false; else if (s >= 0) mine = (slotOfDomRow(id, domRank) === s); } if (mine) { keep.push(tr); visible++; } else { drop.push(tr); } } if (drop.length && !keep.length) { // 一条都不属于我 —— 和接口层同样的判断:本页只是"子集"时照藏(可以藏成空), // 只有看起来是完整列表时才不藏,避免"人数认错了"导致整页空白。 var subsetDom = isBalanced() && BATCH_RANK && BATCH_RANK.__total > rowTrs.length; // 同上:固定 3 人等分时照藏(藏成空也是正常结果) if (subsetDom || !CONFIG.usePlatformRoster) { EMPTY_GUARD = false; for (var h2 = 0; h2 < drop.length; h2++) drop[h2].style.display = 'none'; visible = 0; if (!subsetDom) log('ℹ 页面上这 ' + drop.length + ' 条题里没有你的份 → 已全部隐藏(固定 3 人等分)'); } else { for (var h0 = 0; h0 < drop.length; h0++) drop[h0].style.display = ''; visible = drop.length; if (!EMPTY_GUARD) { EMPTY_GUARD = true; log('⚠ 页面上这 ' + drop.length + ' 条题一条都不属于你 → 先不藏行,避免页面变空。' + '执行 __fsCheck() 看"分片来源 / 本批名单ID"'); } } } else { for (var h1 = 0; h1 < keep.length; h1++) keep[h1].style.display = ''; for (var h2 = 0; h2 < drop.length; h2++) drop[h2].style.display = 'none'; } var box = table.closest('.tableContainer') || table.parentElement; var total = box && box.querySelector('.el-pagination__total'); if (total) { if (!total.getAttribute('data-orig')) total.setAttribute('data-orig', total.textContent); var txt2 = shardEnabled ? '我的份额 ' + visible + ' 条' : total.getAttribute('data-orig'); if (total.textContent !== txt2) total.textContent = txt2; } } } var domTimer = null; function scheduleDomFilter() { if (domTimer) return; domTimer = setTimeout(function () { domTimer = null; try { applyDomFilter(); } catch (e) { log('DOM 过滤出错', e); } syncTopBtn(); }, 80); } /* ==================== 悬浮窗(可拖动,尽量简单) ==================== */ var panel = null, PL = {}; function savePanelPos() { try { if (panel && panel.style) { localStorage.setItem('fs20_panel_pos', JSON.stringify({ left: panel.style.left, top: panel.style.top })); } } catch (e) {} } function loadPanelPos() { try { if (!CONFIG.panelPos) { var p = JSON.parse(localStorage.getItem('fs20_panel_pos') || 'null'); if (p && p.left && p.top) CONFIG.panelPos = p; } } catch (e) {} } function setLine(el, t) { if (el && el.textContent !== t) el.textContent = t; } /** 按住标题栏拖动整个窗口,位置记到 localStorage */ function bindDrag(el, handle) { var dragging = false, moved = false, sx = 0, sy = 0, ox = 0, oy = 0; function move(e) { if (!dragging) return; e = e || window.event; // 位移超过 4px 才算"拖动",否则松手时按点击处理(用来切换抽帧比例) if (Math.abs(e.clientX - sx) + Math.abs(e.clientY - sy) > 4) moved = true; var w = window.innerWidth || 1200, h = window.innerHeight || 800; var l = Math.max(0, Math.min(ox + (e.clientX - sx), w - 60)); var t = Math.max(0, Math.min(oy + (e.clientY - sy), h - 28)); el.style.left = l + 'px'; el.style.top = t + 'px'; } function up() { if (!dragging) return; dragging = false; if (moved) DRAG_MOVED_AT = Date.now(); if (document.removeEventListener) { document.removeEventListener('mousemove', move, true); document.removeEventListener('mouseup', up, true); } savePanelPos(); } handle.addEventListener('mousedown', function (e) { e = e || window.event; var r = el.getBoundingClientRect ? el.getBoundingClientRect() : { left: 0, top: 0 }; dragging = true; sx = e.clientX; sy = e.clientY; ox = r.left; oy = r.top; el.style.right = 'auto'; el.style.left = r.left + 'px'; el.style.top = r.top + 'px'; if (document.addEventListener) { document.addEventListener('mousemove', move, true); document.addEventListener('mouseup', up, true); } if (e.preventDefault) e.preventDefault(); }); } /** 悬浮窗只显示一行字,**没有任何按钮**: * 列表页 → "我的题 N 条";编辑页 → "只看 20% 帧(58→12)|点这行切换"。 * 按住可以拖走(位置记在 localStorage 里);**点一下**在编辑页就是切换抽帧比例 * (10%→20%→…→90%→全部帧→10%,选择记在 localStorage 的 fs20_rate 里)。 */ function buildPanel() { if (panel) return panel; var d = document.createElement('div'); d.id = 'fs20-panel'; d.title = '按住可拖动 | 点一下切换抽帧比例'; d.style.cssText = 'position:fixed;z-index:2147483647;right:12px;top:64px;padding:4px 10px;' + 'border-radius:14px;background:rgba(15,23,42,.9);color:#e2e8f0;border:1px solid #334155;' + 'box-shadow:0 2px 10px rgba(0,0,0,.3);font:12px/1.6 "Microsoft YaHei",Arial,sans-serif;' + 'user-select:none;cursor:move;white-space:nowrap'; if (CONFIG.panelPos) { d.style.left = CONFIG.panelPos.left; d.style.top = CONFIG.panelPos.top; d.style.right = 'auto'; } PL.text = document.createElement('span'); d.appendChild(PL.text); (document.body || document.documentElement).appendChild(d); bindDrag(d, d); // 点一下(不是拖动)= 切换抽帧比例(编辑页才有效) d.addEventListener('click', function () { if (Date.now() - DRAG_MOVED_AT < 400) return; // 刚拖动过,不算点击 try { onPanelClick(); } catch (e) { log('切换出错', e); } }); panel = d; return d; } function syncTopBtn() { syncPanel(); } /** 悬浮窗上唯一的内容:一行状态字(没有按钮) */ function panelText() { if (pageKind() === 'frame') { var hint = '|点这行切换'; if (FRAME_MODE !== 'sample') return '显示全部帧' + hint; var st = PACKAGES[LAST_PKG]; if (!st || !st.keptIdx || !st.fullIds) return '只看 ' + FRAME_RATE_PCT + '% 帧' + hint; return '只看 ' + FRAME_RATE_PCT + '% 帧(' + st.fullIds.length + '→' + st.keptIdx.length + ')' + hint; } if (MY_ID == null) return '我的题:未识别登录人'; if (effSlot() === -2) return '我的题:这一批没分给你'; if (EMPTY_GUARD) return '我的题 0 条 · 人数存疑,暂不筛'; if (!LAST_STAT) return '我的题:加载中…'; if (!LAST_STAT.显示 && !CONFIG.usePlatformRoster) return '我的题 0 条(这一批没分给你)'; return '我的题 ' + LAST_STAT.显示 + ' 条'; } function syncPanel() { if (!CONFIG.showTopButton) { if (panel && panel.parentNode) panel.parentNode.removeChild(panel); return; } if (!document || !document.body) return; buildPanel(); if (panel.parentNode !== (document.body || document.documentElement)) { (document.body || document.documentElement).appendChild(panel); } setLine(PL.text, panelText()); } /* ---- 界面上没有按钮了。下面两个只挂在控制台(F12)里,万一需要临时看全量时用: __fsMine() 筛回我的题 / __fsAll() 看全部题 / __fsRate(50) 抽帧设成 50% / __fsSample() 抽稀帧 / __fsFull() 看全部帧 ---- */ function setShardEnabled(on) { shardEnabled = !!on; hiddenCount = 0; filteredReqs = 0; LAST_STAT = null; ASSIGN.hits = 0; ASSIGN.misses = 0; ASSIGN.used = false; try { applyDomFilter(); } catch (e) {} syncPanel(); log('筛题已' + (shardEnabled ? '打开' : '关闭') + '(接口层下次请求生效)'); } /** 控制台/点击用:切换"抽稀 / 全部帧"。抽稀用的是上次选的比例(默认 20%) */ function setFrameMode(mode) { if (mode === 'full') { FRAME_MODE = 'full'; } else { FRAME_MODE = 'sample'; CONFIG.frameSampleRate = FRAME_RATE_PCT / 100; } saveFrameRate(); var pkg = LAST_PKG || pkgFromUrl(); if (pkg) { LAST_FAIL = ''; if (!applyModeToStore(pkg)) log('切换稍后自动补上:' + LAST_FAIL); } syncPanel(); } /** 列表里第一条题目的字段样本:用来确认"分给谁"是哪个字段名 */ function sampleOf(item) { var out = {}; for (var k in item) { var v = item[k]; if (v == null) { out[k] = v; continue; } if (typeof v !== 'object') { out[k] = (typeof v === 'string' && v.length > 60) ? (v.slice(0, 60) + '…') : v; } else if (Object.prototype.toString.call(v) === '[object Array]') { out[k] = 'Array(' + v.length + ')'; } else { var sub = {}; for (var k2 in v) { var v2 = v[k2]; if (v2 == null || typeof v2 !== 'object') { sub[k2] = (typeof v2 === 'string' && v2.length > 40) ? (v2.slice(0, 40) + '…') : v2; } } out[k] = sub; } } return out; } function diagText() { var o = { 时间: (new Date()).toLocaleString(), 地址: (typeof location !== 'undefined' ? location.href : ''), 页面类型: pageKind(), 是否编辑页: isFramePage(), 登录人ID: MY_ID, 名单位置: MY_SLOT, 本批人数: effCount(), 我是第几个: (effSlot() >= 0 ? effSlot() + 1 : null), 名单: MEMBER_IDS, 分片来源: rosterSrcText(roster().src), 分片名单ID: roster().ids, 分片开关: shardEnabled, 分片规则: shardByText(), 识别到的分配字段: ASSIGN.path, 分配判断模式: ASSIGN.mode, 分配命中未命中: ASSIGN.hits + ' / ' + ASSIGN.misses, 本批名单来源: AUTO_ROSTER ? (AUTO_ROSTER.url + '(' + AUTO_ROSTER.len + ' 人)') : '未识别到,用固定名单', 本批名单ID: AUTO_ROSTER ? AUTO_ROSTER.ids : null, 最近列表统计: LAST_STAT, 抽帧: { 当前模式: FRAME_MODE, 比例: frameRatePct() + '%' + (FRAME_MODE === 'full' ? '(显示全部帧)' : ''), 可选档位: rateSteps().map(function (v) { return v + '%'; }).join('/') + '/全部帧', 抽法: CONFIG.frameMode, 已知包: Object.keys(PACKAGES) }, 列表样本: SAMPLE_ITEM, 响应样本: RESP_LOG.slice(-12), 最近请求: URL_LOG.slice(-15) }; return JSON.stringify(o, null, 2); } /* 诊断信息不再做按钮:需要时在 F12 控制台执行 __fs20() 就能打印出上面这份 JSON。 __fsCheck() = 人话版自检(本批几人、我是第几份、每一份各多少条、有没有题被两人拿到)。 */ function fsCheck() { var r = roster(); var L = []; L.push('=== 验收助手 · 分片自检 ==='); L.push('页面地址 : ' + (typeof location !== 'undefined' ? location.href : '')); L.push('页面类型 : ' + pageKind() + (isFramePage() ? '(编辑页)' : '')); L.push('登录人ID : ' + MY_ID + '(团队名单第 ' + (MEMBER_IDS.indexOf(Number(MY_ID)) + 1) + ' 位;0 = 不在名单里)'); L.push('分片来源 : ' + rosterSrcText(r.src)); L.push('分片口径 : ' + (CONFIG.usePlatformRoster ? '优先用平台官方分配名单,认不出再按固定名单' : '固定 ' + N + ' 人等分(每人 1/' + N + ',不查平台分配)')); L.push('本批人数 N : ' + r.n); L.push('我是第几份 : ' + (r.slot === -2 ? '—(这一批没分给我:我不在认到的本批名单里)' : r.slot >= 0 ? (r.slot + 1) + ' / ' + r.n + '(0 起 ' + r.slot + ')' : '未定(按题ID分片不启用)')); L.push('本批名单ID : ' + (r.ids && r.ids.length ? JSON.stringify(r.ids) : '(无)')); L.push('本批 task_key : ' + (taskKeyNow() || '(地址里没有)') + ' / 项目 ' + (projectKeyNow() || '(未知)')); if (CONFIG.usePlatformRoster) { L.push('分配名单缓存 : ' + (function () { var ks = [], kk; for (kk in ROSTER_CACHE) if (Object.prototype.hasOwnProperty.call(ROSTER_CACHE, kk)) ks.push(kk + ':' + ROSTER_CACHE[kk].length + '人'); return ks.length ? ks.length + ' 个批次(' + ks.slice(0, 5).join(' ') + ')' : '(空 —— 还没在任何页面见过批次分配的响应)'; })()); L.push('主动查询名单 : ' + (ROSTER_PROBE_URLS.length ? ROSTER_PROBE_URLS.length + ' 次(' + ROSTER_PROBE_URLS[0].replace(/^https?:\/\/[^/]+/, '') + ')' : '(没发出去:地址里没有 task_key 或项目 key)')); } L.push('分片规则 : ' + shardByText()); L.push('名次表(整批) : ' + (BATCH_RANK ? (BATCH_RANK.__total + ' 条' + (CANON_DONE ? '(已翻页取全,三人一致)' : '(先用页面响应拼的,正在/待翻页补全)')) : '(还没建)') + (LAST_LIST_URL ? ' 翻页源=' + LAST_LIST_URL : ' 翻页源=(还没见过批次包列表接口)')); L.push('最近列表统计 : ' + JSON.stringify(LAST_STAT)); L.push('题ID字段 : ' + (ID_FIELD ? ('"' + ID_FIELD + '"') : '(还没认出来)')); if (SAMPLE_ITEM) L.push('题目样本 : ' + JSON.stringify(SAMPLE_ITEM)); if (UNPARSED) L.push('⚠ 未识别ID : ' + UNPARSED + ' 条题的数字ID没认出来,这些题不会被筛(把"题目样本"这行发我)'); if (EMPTY_GUARD) L.push('⚠ 空手保护 : 按当前口径你一条题都没有 → 已暂不筛选(多半是"人数"认错了,把上面两行发我)'); if (!LAST_TOPICS || !LAST_TOPICS.length) { L.push('各份条数 : (还没收到题目列表,刷新一下列表页再看)'); } else { var by = {}, i, k; var chkRank = null; if (isBalanced()) { var chkIds = []; for (i = 0; i < LAST_TOPICS.length; i++) chkIds.push(findNumId(LAST_TOPICS[i])); chkRank = rankMapOf(chkIds); } for (i = 0; i < LAST_TOPICS.length; i++) { var id = findNumId(LAST_TOPICS[i]); if (id == null) continue; var s = chkRank ? slotOfRank(chkRank[String(id)], chkRank.__total) : slotOfId(id); if (s == null) continue; if (!by[s]) by[s] = []; by[s].push(id); } var parts = []; for (k = 0; k < r.n; k++) parts.push(k + '→' + (by[k] ? by[k].length : 0) + '条'); L.push('各份条数(0起) : ' + parts.join(' ')); var all = [], seen = {}, dup = [], kk, z; for (kk in by) for (z = 0; z < by[kk].length; z++) all.push(by[kk][z]); for (z = 0; z < all.length; z++) { if (seen[all[z]]) dup.push(all[z]); else seen[all[z]] = 1; } L.push('题目总数 : ' + all.length + '(去重后 ' + Object.keys(seen).length + ')'); L.push('重复检查 : ' + (dup.length ? '⚠ 有题同时落在两份:' + dup.join(',') : '无重复(每道题只属于一份)')); var orphan = []; if (r.src === 'fixed' && r.n > 2) { L.push('提示 : 没认到"本批名单",现在按固定的 ' + r.n + ' 人名单切成 ' + r.n + ' 份。如果这一批其实只分给了其中几个人,那没参与的那几份里的题谁都看不到' + '(不会重复,但会漏)。要精确,请把任务列表页的 __fs20() 输出发我。'); } if (r.src === 'notinroster' || r.src === 'manualnotin') { L.push('提示 : 认到的本批名单里没有你(' + MY_ID + ')。如果界面上确实写了你在这一批里,' + '就是脚本把"本批名单"认错了 —— 在控制台执行一次下面这行,把本批验收人写死即可(最保险):'); L.push(' __fsRoster([' + (r.ids || []).concat([Number(MY_ID)]).join(', ') + '])'); } if (r.src === 'fixed') { L.push('提示 : 如果这一批只分给了固定的几个人,也可以直接写死,最保险:'); L.push(' __fsRoster([488, 490, 1822]) // 换成这一批的实际验收人 id'); } if (orphan.length) L.push('⚠ 无人认领的份: ' + orphan.join(',')); var mine = (r.slot >= 0 && by[r.slot]) ? by[r.slot] : []; L.push('我的题 : ' + mine.length + ' 条' + (mine.length ? '(' + mine[0] + ' … ' + mine[mine.length - 1] + ')' : '')); } var txt = L.join('\n'); try { console.log(txt); } catch (e) {} return txt; } /* =============================== 启动 =============================== */ installHooks(); installFetchHook(); if (isShardPage()) { resolveIdentity(); var timer = setInterval(function () { if (resolveIdentity()) { clearInterval(timer); scheduleDomFilter(); } }, 150); setTimeout(function () { clearInterval(timer); }, 40000); } function boot() { loadPanelPos(); shardEnabled = true; // 筛题常开,界面上没有开关(也不读旧的 fs20_shard 记录) try { var fm = localStorage.getItem('fs20_frame_mode'); // 旧版只记过"看全部帧"这一种开关(没人写过它,留着兼容);2.7.3 起比例记在 // fs20_rate 里,所以只有没存过新 key 时才认这条旧记录,免得把新选的比例盖掉。 if (fm === 'full' && !localStorage.getItem(CONFIG.frameRateLs)) FRAME_MODE = 'full'; var sc = localStorage.getItem('fs20_shard_count'); if (sc != null && /^\d+$/.test(sc)) CONFIG.shardCount = Number(sc); var mi = localStorage.getItem('fs20_my_index'); if (mi != null && /^\d+$/.test(mi)) CONFIG.myIndex = Number(mi); loadManualRoster(); // 手动指定的本批验收人(CONFIG.batchMembers / __fsRoster) loadRosterCache(); // 之前在任何页面见过的"本批分配配置"(按 task_batch_key 缓存) loadCanonCache(); // 2.7.1:上次翻页取全的"整批题号"(名次表用它才三人一致) } catch (e) {} if (isShardPage()) resolveIdentity(); syncTopBtn(); if (isShardPage()) { var obs = new MutationObserver(function () { scheduleDomFilter(); }); obs.observe(document.documentElement, { childList: true, subtree: true }); } // 本批名单:先用缓存(同步、立刻可用),再主动去问一次接口(验收页自己不返回名单) // 现口径(CONFIG.usePlatformRoster=false)下完全不问,只按固定 3 人等分。 if (isShardPage() && CONFIG.usePlatformRoster) { try { applyCachedRoster(); probeRoster(); setTimeout(function () { applyCachedRoster(); probeRoster(true); }, 2500); } catch (e) {} } // 2.7.1 名次表:先试缓存(同步就能用);没有就等"批次包列表"响应出现后自动翻页取全 if (isShardPage()) { try { fetchCanonical(); setTimeout(function () { fetchCanonical(); }, 1200); } catch (e) {} } // 帧列表就绪后把面板刷新出来(编辑器页面每次翻帧都会重新请求) setInterval(function () { autoEnforce(); alignTasksInfo(); syncTopBtn(); }, 700); log('脚本已就绪(分片页:' + isShardPage() + ',抽帧默认:' + FRAME_MODE + ')'); } // 抽帧比例:优先用上次在悬浮窗上选的那个(10%~90%,默认 20%) try { loadFrameRate(); } catch (e) {} if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } // 自检入口:控制台执行 __fs20() 打印诊断;__fsRate(n) 设抽帧比例;另有 __fsMine()/__fsAll()/__fsSample()/__fsFull() window.__fsMine = function () { setShardEnabled(true); }; window.__fsAll = function () { setShardEnabled(false); }; window.__fsSample = function () { setFrameMode('sample'); }; window.__fsFull = function () { setFrameMode('full'); }; // __fsRate() 看当前抽帧比例和可选档位 // __fsRate(50) 直接设成 50%(10~90 就近取 10 的倍数;100 = 显示全部帧) window.__fsRate = function (pct) { if (pct === undefined) { console.log('当前抽帧:' + frameRateText() + '(' + frameRatePct() + '%)|可选档位:' + rateSteps().map(function (v) { return v + '%'; }).join(' / ') + ' / 全部帧'); console.log('用法:__fsRate(50) 直接设置 | 在编辑页点一下悬浮窗那行字也能切换(10%→90%→全部帧→10%)'); return frameRatePct(); } return setFrameRate(pct); }; window.__fsCheck = fsCheck; // __fsRoster([488,490,1822]) = 直接把本批验收人固定成这 3 人(最保险,立刻刷新生效) // __fsRoster() 看当前名单 | __fsRoster([]) 清除,恢复自动识别 window.__fsRoster = function (list) { if (list === undefined) { var r = roster(); console.log('当前本批名单:', r.ids, '| 人数 =', r.n, '| 我 =', MY_ID, '第', (r.slot >= 0 ? r.slot + 1 : '—'), '份 | 来源:' + rosterSrcText(r.src)); console.log('用法:__fsRoster([488,490,1822]) 固定本批验收人 | __fsRoster([]) 清除并恢复自动识别'); return r; } var ids = setManualRoster(Array.isArray(list) ? list : []); // 设过 / 清除过都刷一次页面,让新的分片口径重新算一遍(清除后恢复自动识别) if (Array.isArray(list)) { log('马上就刷新页面,让新的分片口径生效…'); try { location.reload(); } catch (e) {} } return ids; }; window.__fs20 = function () { console.log('当前模式:', FRAME_MODE, '| 分片页:', isShardPage(), '| 我:', MY_ID, 'slot=', MY_SLOT, '| 本批人数=', effCount(), '| 我是第', (effSlot() + 1), '个 | 规则:', CONFIG.shardBy, '| 筛题:', shardEnabled ? '开' : '关'); console.log('分配判断:字段 =', ASSIGN.path, '| 模式 =', ASSIGN.mode, '| 命中/未命中 =', ASSIGN.hits + '/' + ASSIGN.misses, '| 最近统计 =', LAST_STAT); console.log('列表样本(用来确认“分给谁”字段名):', SAMPLE_ITEM); console.log('完整诊断(可整段复制给开发者):'); console.log(diagText()); console.log('URL 里的 package_id:', pkgFromUrl(), '| LAST_PKG:', LAST_PKG, '| 失败原因:', LAST_FAIL || '(无)'); Object.keys(PACKAGES).forEach(function (k) { var v = PACKAGES[k]; if (!v.full) return; console.log('包 ' + k + ':总 ' + v.total + ' 帧,抽样保留 ' + v.keptIdx.length + ' 帧' + (v.adopted ? '(取自页面缓存)' : '(来自接口)') + ',轮转位 ' + v.rotate); console.log(' 保留下标:', v.keptIdx); console.log(' 保留帧ID:', v.keptIdx.map(function (i) { return v.fullIds[i]; })); }); var cfg = findConfigState(); if (cfg) { console.log('页面 store 里现在有 ' + cfg.frames.length + ' 帧,当前帧 id =', currentFrameId()); console.log(' 当前帧序列:', idsOf(cfg.frames).slice(0, 30), '…'); console.log(' frameIds 键数:', Object.keys(cfg.frameIds || {}).length, '| tasksInfo 长度:', Array.isArray(cfg.tasksInfo) ? cfg.tasksInfo.length : String(cfg.tasksInfo)); } else { console.log('读不到页面 store(__fs20 无法访问)'); } console.log('最近的 /v2/ 请求(res=true 表示被抽帧钩子拦到):'); URL_LOG.slice(-15).forEach(function (r) { console.log(' ', r.res ? '✅' : ' ', r.u); }); console.log('CONFIG =', CONFIG); }; })();