// ==UserScript== // @name 数据标注平台 - 分配助手(团队/用户/任务) // @namespace annotate-platform.local // @version 0.5.0 // @description 给任务批量分配团队或用户,任务列表全量读取、可搜索勾选;窗口可拖动、可最小化;权限一键统一。 // @author you // @match *://*/* // @grant none // @run-at document-idle // ==/UserScript== // // 通用版说明: // - @match 用全站匹配;是否生效由下方 isPlatformPage() 自动识别(按平台路由 /v/project/ 或 /project/ 判断)。 // - 同一套平台无论部署在哪个 IP/域名,接口结构一致,本脚本都能直接用。 // - 若某平台没被自动识别,可把它的 域名:端口 填进 FORCE_HOSTS(可选)。 // // 接口(已从平台前端源码反推确认): // 任务列表 GET /v2/projects/{project_key}/task-batches (item.task_batch_key 即 task_key) // 团队列表 GET /v2/guilds ;用户列表 GET /v2/users // 分配任务 POST /v2/accesses body:{ project_key, work_type(1标注/2审核/3质检/4验收), users:{id:权限}, gids:{id:权限}, task_key:[...] 或 is_all:1 } // 权限值 1=查看 2=审阅 3=编辑 ; 鉴权 请求头 access-key = localStorage token(去引号) (function () { 'use strict'; // 可选:显式指定生效的主机(域名:端口)。留空则按页面特征自动识别。 const FORCE_HOSTS = []; function isPlatformPage() { // 1) 显式主机列表 if (FORCE_HOSTS.indexOf(location.host) !== -1) return true; // 2) 平台路由特征:/v/project// 或 /project//(同一套系统的统一路由) return /\/v\/project\//.test(location.pathname) || /\/project\//.test(location.pathname); } if (!isPlatformPage()) return; console.log('[分配助手] 已注入 ' + location.href); const CHUNK = 60; const WORK_TYPES = { 1: '标注', 2: '审核', 3: '质检', 4: '验收' }; const PERMS = { 1: '查看', 2: '审阅', 3: '编辑' }; // ---------- 工具 ---------- function ls(key) { try { return JSON.parse(localStorage.getItem(key)) || null; } catch (e) { return localStorage.getItem(key) || null; } } function orgId() { const info = ls('userInfo') || {}; return (info && info.organize_id) ? String(info.organize_id) : ''; } function apiHeaders() { return { 'Content-Type': 'application/json;charset=UTF-8', 'time': String(Date.now()), 'access-key': (localStorage.getItem('token') || '').replace(/"/g, '') }; } async function apiGet(url, params) { const qs = new URLSearchParams(params || {}).toString(); const res = await fetch(url + (qs ? '?' + qs : ''), { headers: apiHeaders() }); if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + res.statusText); return res.json(); } async function apiPost(url, body) { const res = await fetch(url, { method: 'POST', headers: apiHeaders(), body: JSON.stringify(body) }); if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + res.statusText); return res.json(); } function debounce(fn, ms) { let t; return function () { clearTimeout(t); const a = arguments, self = this; t = setTimeout(() => fn.apply(self, a), ms); }; } function el(tag, attrs, html) { const n = document.createElement(tag); if (attrs) for (const k in attrs) n.setAttribute(k, attrs[k]); if (html != null) n.innerHTML = html; return n; } function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function toast(msg, kind) { const box = el('div', { class: 'xpa-toast xpa-' + (kind || 'ok') }, esc(msg)); document.body.appendChild(box); setTimeout(() => box.remove(), 2600); } function selectedMap(arr, idField) { const m = {}; arr.forEach(it => { if (it.__checked) m[it[idField]] = String(it.__perm || '1'); }); return m; } // ---------- 数据 ---------- async function fetchTeams() { const d = await apiGet('/v2/guilds', { page: 1, page_num: 99999, guild_name: '', organize_id: orgId(), split_class: '=' }); if (d.code !== '') throw new Error(d.msg || '拉取团队失败'); return (d.data.items || []).map(it => ({ gid: String(it.gid), name: it.guild_name })); } async function fetchUsers() { const d = await apiGet('/v2/users', { page: 1, page_num: 99999, organize_id: orgId(), split_class: '=' }); if (d.code !== '') throw new Error(d.msg || '拉取用户失败'); return (d.data.items || []).map(it => ({ user_id: String(it.user_id), user_name: it.user_name, role: it.role })); } // 任务全量读取:自动翻页直到取完 total_num async function fetchAllTasks(project_key) { const list = []; let page = 1; for (; page <= 60; page++) { const d = await apiGet('/v2/projects/' + encodeURIComponent(project_key) + '/task-batches', { page, page_num: 100, split_class: '=', task_name: '', task_key: '', line_status: '', creator_ids: '', file_name: '', seg_dir_name: '' }); if (d.code !== '') throw new Error(d.msg || '拉取任务失败'); const items = d.data.items || []; items.forEach(it => list.push({ task_key: String(it.task_batch_key != null ? it.task_batch_key : it.task_key), name: it.task_batch_name || it.task_name || it.task_batch_key })); const total = (d.data.meta && d.data.meta.total_num) || 0; if (!items.length || (total && list.length >= total)) break; } return list; } async function fetchCurrent(task_key) { const d = await apiGet('/v2/accesses/task-key/' + encodeURIComponent(task_key), { user_id: '', gid: '', split_class: '/' }); if (d.code !== '') return {}; const item = (d.data.items || {})[String(workTypeSel.value)] || {}; return { users: item.users || {}, gids: item.gids || {} }; } function singleSelectedKey() { const k = taskSelectedKeys(); return k.length === 1 ? k[0] : null; } function applyCurrent(cur) { currentSnapshot = cur; teams.forEach(t => { const a = cur.gids[t.gid] !== undefined; t.__assigned = a; t.__checked = a; t.__perm = cur.gids[t.gid] || t.__perm || '1'; }); users.forEach(u => { const a = cur.users[u.user_id] !== undefined; u.__assigned = a; u.__checked = a; u.__perm = cur.users[u.user_id] || u.__perm || '1'; }); renderActive(); updateSummary(); } async function autoRead() { if (!autoEcho) return; const tk = singleSelectedKey(); if (!tk) { readTaskKey = null; return; } if (tk === readTaskKey) return; readTaskKey = tk; try { const cur = await fetchCurrent(tk); applyCurrent(cur); } catch (e) { readTaskKey = null; toast('读取当前分配失败:' + e.message, 'warn'); } } function scheduleAutoRead() { clearTimeout(autoReadTimer); autoReadTimer = setTimeout(autoRead, 250); } // ---------- 预设 ---------- const PRESET_KEY = 'assign_preset_v4'; function savePreset() { const p = { work_type: workTypeSel.value, teams: teams.filter(t => t.__checked).map(t => ({ gid: t.gid, name: t.name, perm: t.__perm || '1' })), users: users.filter(u => u.__checked).map(u => ({ user_id: u.user_id, user_name: u.user_name, perm: u.__perm || '1' })) }; localStorage.setItem(PRESET_KEY, JSON.stringify(p)); toast('已保存当前分配为预设', 'ok'); } function loadPreset() { const raw = localStorage.getItem(PRESET_KEY); if (!raw) { toast('还没有保存过预设', 'warn'); return; } const p = JSON.parse(raw); if (p.work_type) workTypeSel.value = p.work_type; const tm = {}, um = {}; (p.teams || []).forEach(x => { tm[x.gid] = x.perm; }); (p.users || []).forEach(x => { um[x.user_id] = x.perm; }); teams.forEach(t => { t.__checked = tm[t.gid] !== undefined; t.__perm = tm[t.gid] || '1'; }); users.forEach(u => { u.__checked = um[u.user_id] !== undefined; u.__perm = um[u.user_id] || '1'; }); renderActive(); updateSummary(); toast('已套用预设', 'ok'); } // ---------- 状态 ---------- let tasks = []; // {task_key,name,__checked} let teams = []; // {gid,name,__checked,__perm} let users = []; // {user_id,user_name,role,__checked,__perm} let tasksLoading = false, teamsLoading = false, usersLoading = false; let taskShown = 0, teamShown = 0, userShown = 0; let actMode = 'team'; let projectKey, workTypeSel, runBtn, manualKeys; let taskSearch, taskListWrap, taskCount, isAllCb, taskSelAll, taskClear; let teamWrap, userWrap, teamSearch, userSearch, countTeam, countUser, tabAll, permAllSel; let tabTeam, tabUser; let sumTasks, sumObj, bodyWrap, minIco; let panel, fab; let readTaskKey = null, currentSnapshot = null, autoReadTimer = null; let autoEcho = true; // 选中单个任务后自动回显已分配 let autoReload = false; // 分配成功后自动刷新平台页(默认关) // ---------- 任务列表 ---------- function buildTaskRow(t) { const row = el('div', { class: 'xpa-task' + (t.__checked ? ' on' : '') }); const cb = el('input', { type: 'checkbox', class: 'xpa-cb' }); cb.checked = !!t.__checked; cb.onchange = () => { t.__checked = cb.checked; row.classList.toggle('on', cb.checked); updateSummary(); scheduleAutoRead(); }; const body = el('div', { class: 'xpa-task-body' }); body.appendChild(el('div', { class: 'xpa-task-name' }, esc(t.name))); body.appendChild(el('div', { class: 'xpa-task-key' }, t.task_key)); row.appendChild(cb); row.appendChild(body); row.onclick = (e) => { if (e.target === cb) return; t.__checked = !t.__checked; cb.checked = t.__checked; row.classList.toggle('on', t.__checked); updateSummary(); scheduleAutoRead(); }; return row; } function renderTasks() { if (!taskListWrap) return; const kw = (taskSearch && taskSearch.value || '').toLowerCase(); const list = tasks.filter(t => !kw || t.name.toLowerCase().includes(kw) || t.task_key.toLowerCase().includes(kw)); taskListWrap.innerHTML = ''; if (!list.length) { taskListWrap.innerHTML = tasksLoading ? '
加载中...
' : (projectKey.value.trim() ? '
该项目暂无任务
' : '
请先填写项目ID并加载任务
'); return; } const slice = list.slice(0, taskShown); slice.forEach(t => taskListWrap.appendChild(buildTaskRow(t))); if (list.length > taskShown) { const more = el('div', { class: 'xpa-more' }, '加载更多(还有 ' + (list.length - taskShown) + ' 条)'); more.onclick = () => { taskShown += CHUNK; renderTasks(); }; taskListWrap.appendChild(more); } taskCount.textContent = '共 ' + list.length + ' 个任务'; } function taskSelectedKeys() { return tasks.filter(t => t.__checked).map(t => t.task_key); } function updateSummary() { const st = tasks.filter(t => t.__checked).length; if (sumTasks) sumTasks.textContent = isAllCb && isAllCb.checked ? '全部任务' : '任务 ' + st + '/' + tasks.length; if (sumObj) sumObj.textContent = '对象 ' + (teams.filter(t => t.__checked).length + users.filter(u => u.__checked).length); } async function loadTasks() { const pk = projectKey.value.trim(); if (!pk) { toast('请先填写项目ID', 'warn'); return; } tasksLoading = true; renderTasks(); try { tasks = await fetchAllTasks(pk); tasks.forEach(t => { t.__checked = false; }); taskShown = CHUNK; // 若当前 URL 带 task_key,自动勾选对应任务 const cur = new URLSearchParams(location.search).get('task_key'); if (cur) { const hit = tasks.find(t => t.task_key === cur); if (hit) hit.__checked = true; } renderTasks(); updateSummary(); readTaskKey = null; scheduleAutoRead(); toast('已读取 ' + tasks.length + ' 个任务', 'ok'); } catch (e) { toast('读取任务失败:' + e.message, 'err'); } finally { tasksLoading = false; renderTasks(); } } // ---------- 团队/用户列表 ---------- function buildObjRow(item) { const row = el('div', { class: 'xpa-row' }); const cb = el('input', { type: 'checkbox', class: 'xpa-cb' }); cb.checked = !!item.__checked; cb.onchange = () => { item.__checked = cb.checked; updateSummary(); }; row.appendChild(cb); row.appendChild(el('span', { class: 'xpa-row-name' }, esc(item._label))); if (item.__assigned) row.appendChild(el('span', { class: 'xpa-badge' }, '已分配')); const perm = el('select', { class: 'xpa-perm' }); Object.keys(PERMS).forEach(p => { const op = el('option', { value: p }, PERMS[p]); if (p === String(item.__perm || '1')) op.selected = true; perm.appendChild(op); }); perm.onchange = () => { item.__perm = perm.value; }; row.appendChild(perm); return row; } function renderTeams() { if (!teamWrap) return; const kw = (teamSearch && teamSearch.value || '').toLowerCase(); const list = teams.filter(t => !kw || t.name.toLowerCase().includes(kw)); if (countTeam) countTeam.textContent = list.length ? '共 ' + list.length + ' 个团队' : ''; teamWrap.innerHTML = ''; if (!list.length) { teamWrap.innerHTML = teamsLoading ? '
加载中...
' : '
暂无团队
'; return; } const slice = list.slice(0, teamShown); slice.forEach(t => { t._label = t.name; teamWrap.appendChild(buildObjRow(t)); }); if (list.length > teamShown) { const more = el('div', { class: 'xpa-more' }, '加载更多(还有 ' + (list.length - teamShown) + ' 条)'); more.onclick = () => { teamShown += CHUNK; renderTeams(); }; teamWrap.appendChild(more); } } function renderUsers() { if (!userWrap) return; const kw = (userSearch && userSearch.value || '').toLowerCase(); const list = users.filter(u => !kw || u.user_name.toLowerCase().includes(kw)); if (countUser) countUser.textContent = list.length ? '共 ' + list.length + ' 个用户' : ''; userWrap.innerHTML = ''; if (!list.length) { userWrap.innerHTML = usersLoading ? '
加载中...
' : '
暂无用户
'; return; } const slice = list.slice(0, userShown); slice.forEach(u => { u._label = u.user_name; userWrap.appendChild(buildObjRow(u)); }); if (list.length > userShown) { const more = el('div', { class: 'xpa-more' }, '加载更多(还有 ' + (list.length - userShown) + ' 条)'); more.onclick = () => { userShown += CHUNK; renderUsers(); }; userWrap.appendChild(more); } } function renderActive() { if (actMode === 'team') renderTeams(); else renderUsers(); } function activeArray() { return actMode === 'team' ? teams : users; } function checkAllForActive(v) { activeArray().forEach(x => { x.__checked = v; }); renderActive(); updateSummary(); } function applyPermForActive() { if (!permAllSel) return; const p = permAllSel.value; activeArray().forEach(r => { r.__perm = p; }); renderActive(); toast('已把当前' + (actMode === 'team' ? '团队' : '用户') + '权限统一设为「' + PERMS[p] + '」', 'ok'); } function setMode(mode) { actMode = mode; tabTeam.classList.toggle('xpa-tab-active', mode === 'team'); tabUser.classList.toggle('xpa-tab-active', mode === 'user'); const show = mode === 'team'; teamSearch.style.display = show ? 'block' : 'none'; teamWrap.style.display = show ? 'block' : 'none'; userSearch.style.display = show ? 'none' : 'block'; userWrap.style.display = show ? 'none' : 'block'; if (countTeam) countTeam.style.display = show ? 'block' : 'none'; if (countUser) countUser.style.display = show ? 'none' : 'block'; refreshObj(); } // ---------- 数据拉取(并行、先到先渲染) ---------- function refreshObj() { if (!teams.length && !teamsLoading) { teamsLoading = true; if (countTeam) countTeam.textContent = '加载中...'; fetchTeams().then(t => { teams = t; teams.forEach(x => { if (currentSnapshot) { const a = currentSnapshot.gids[x.gid] !== undefined; x.__assigned = a; x.__checked = a; x.__perm = currentSnapshot.gids[x.gid] || '1'; } else { x.__assigned = false; x.__checked = false; x.__perm = '1'; } }); teamShown = CHUNK; if (actMode === 'team') renderTeams(); updateSummary(); }) .catch(e => { if (countTeam) countTeam.textContent = ''; toast('拉取团队失败:' + e.message, 'err'); }) .finally(() => { teamsLoading = false; if (actMode === 'team') renderTeams(); }); } if (!users.length && !usersLoading) { usersLoading = true; if (countUser) countUser.textContent = '加载中...'; fetchUsers().then(t => { users = t; users.forEach(x => { if (currentSnapshot) { const a = currentSnapshot.users[x.user_id] !== undefined; x.__assigned = a; x.__checked = a; x.__perm = currentSnapshot.users[x.user_id] || '1'; } else { x.__assigned = false; x.__checked = false; x.__perm = '1'; } }); userShown = CHUNK; if (actMode === 'user') renderUsers(); updateSummary(); }) .catch(e => { if (countUser) countUser.textContent = ''; toast('拉取用户失败:' + e.message, 'err'); }) .finally(() => { usersLoading = false; if (actMode === 'user') renderUsers(); }); } renderActive(); } // ---------- 执行分配 ---------- async function doAssign() { const project_key = projectKey.value.trim(); if (!project_key) { toast('请填写项目ID', 'warn'); return; } const gids = selectedMap(teams, 'gid'); const usr = selectedMap(users, 'user_id'); if (Object.keys(gids).length === 0 && Object.keys(usr).length === 0) { toast('至少勾选一个团队或用户', 'warn'); return; } // 与平台真实请求体一致(work_type 必须用字符串,且带上空搜索字段) const body = { project_key, work_type: String(workTypeSel.value), task_name: '', line_status: '', creator_ids: '', file_name: '', seg_dir_name: '', users: usr, gids }; if (isAllCb && isAllCb.checked) { body.is_all = 1; } else { let keys = taskSelectedKeys(); if (!keys.length) keys = manualKeys.value.split(/[\s,,\n]+/).filter(Boolean); if (!keys.length) { toast('请勾选任务,或在“手动任务KEY”里填写', 'warn'); return; } body.task_key = keys; } const allTarget = isAllCb && isAllCb.checked; const wn = WORK_TYPES[String(workTypeSel.value)] || workTypeSel.value; const tCnt = Object.keys(gids).length; const uCnt = Object.keys(usr).length; const origT = teams.filter(t => t.__checked && t.__assigned).length; const origU = users.filter(u => u.__checked && u.__assigned).length; const msg = '确认执行分配?\n\n工序:' + wn + '\n目标:' + (allTarget ? '全部任务(is_all)' : '已选任务 ' + (body.task_key ? body.task_key.length : 0) + ' 个') + '\n将写入 团队:' + tCnt + ' 个(原有 ' + origT + ',新增 ' + (tCnt - origT) + ')' + '\n将写入 用户:' + uCnt + ' 个(原有 ' + origU + ',新增 ' + (uCnt - origU) + ')' + '\n\n「原有」= 该任务该工序已分配(带“已分配”标签);取消勾选即从分配中移除。' + '\n⚠️ 本次会覆盖这些任务该工序原本的分配!'; if (!(await confirmAssign(msg))) return; // 记录分配前的数量(单选任务时),用于对比是否真的写入 let before = null; const stk = singleSelectedKey(); if (stk) { try { const c = await fetchCurrent(stk); before = { t: Object.keys(c.gids || {}).length, u: Object.keys(c.users || {}).length }; } catch (e) {} } runBtn.disabled = true; runBtn.textContent = '分配中...'; try { console.log('[分配助手] 勾选的用户', users.filter(u => u.__checked).map(u => ({ id: u.user_id, name: u.user_name, role: u.role, perm: u.__perm }))); console.log('[分配助手] 勾选的团队', teams.filter(t => t.__checked).map(t => ({ id: t.gid, name: t.name, perm: t.__perm }))); console.log('[分配助手] 提交分配', body); const d = await apiPost('/v2/accesses', body); console.log('[分配助手] 接口返回', d); if (d.code !== '') throw new Error(d.msg || '分配失败'); // 回读校验:确认到底写入了多少 let after = null; try { if (stk) { const c = await fetchCurrent(stk); after = { t: Object.keys(c.gids || {}).length, u: Object.keys(c.users || {}).length }; } } catch (e) {} const diff = (before && after) ? '(分配前 团队' + before.t + '/用户' + before.u + ' → 分配后 团队' + after.t + '/用户' + after.u + ')' : ''; console.log('[分配助手] 分配前后对比', { before, after }); toast('接口已返回成功' + diff + (autoReload ? ',正在刷新平台...' : ',请刷新平台查看'), 'ok'); if (autoReload) setTimeout(() => location.reload(), 1400); minimize(true); } catch (e) { toast('分配失败:' + e.message, 'err'); } finally { runBtn.disabled = false; runBtn.textContent = '执行分配'; } } // ---------- 窗口 ---------- function minimize(collapse) { bodyWrap.style.display = collapse ? 'none' : 'block'; panel.classList.toggle('xpa-min', !!collapse); if (minIco) minIco.textContent = collapse ? '▢' : '—'; } // 面板内确认弹窗(avoid 原生 confirm 被环境静默拦截返回 false) function confirmAssign(msg) { return new Promise(resolve => { const overlay = el('div', { class: 'xpa-modal' }); const box = el('div', { class: 'xpa-modal-box' }); box.appendChild(el('div', { class: 'xpa-modal-title' }, '确认执行分配')); box.appendChild(el('div', { class: 'xpa-modal-msg' }, msg.replace(/\n/g, '
'))); const btns = el('div', { class: 'xpa-modal-btns' }); const cancel = el('button', { class: 'xpa-btn xpa-sm' }, '取消'); const ok = el('button', { class: 'xpa-btn xpa-sm xpa-btn-primary' }, '确定执行'); const done = (v) => { overlay.remove(); resolve(v); }; ok.onclick = () => done(true); cancel.onclick = () => done(false); overlay.onclick = (e) => { if (e.target === overlay) done(false); }; btns.appendChild(cancel); btns.appendChild(ok); box.appendChild(btns); overlay.appendChild(box); document.body.appendChild(overlay); }); } function collapsible(sectionHead, sectionBody) { sectionHead.onclick = () => { const hidden = sectionBody.style.display === 'none'; sectionBody.style.display = hidden ? 'block' : 'none'; sectionHead.classList.toggle('xpa-fold', !hidden); }; } function buildPanel() { fab = el('div', { id: 'xpa-fab', class: 'xpa-fab' }, '👥 分配'); fab.onclick = () => { const hidden = panel.classList.contains('xpa-hide'); if (hidden) { panel.classList.remove('xpa-hide'); refreshObj(); } else panel.classList.add('xpa-hide'); }; const panelEl = el('div', { class: 'xpa-panel' }); panelEl.appendChild(el('div', { class: 'xpa-accent' })); const head = el('div', { class: 'xpa-head' }); const logo = el('div', { class: 'xpa-logo' }, '分'); const tw = el('div', { class: 'xpa-titles' }); tw.appendChild(el('div', { class: 'xpa-title' }, '分配助手')); tw.appendChild(el('div', { class: 'xpa-sub' }, '团队 · 用户 · 任务')); const led = el('div', { class: 'xpa-led' }); const right = el('div', { class: 'xpa-right' }); const minBtn = el('div', { class: 'xpa-btn' }); minIco = el('span', {}, '—'); minBtn.appendChild(minIco); minBtn.onclick = () => minimize(bodyWrap.style.display !== 'none'); const closeX = el('div', { class: 'xpa-btn' }, '×'); closeX.onclick = () => panelEl.classList.add('xpa-hide'); right.appendChild(minBtn); right.appendChild(closeX); head.appendChild(logo); head.appendChild(tw); head.appendChild(led); head.appendChild(right); bodyWrap = el('div', { class: 'xpa-body' }); // 顶部统计(任务 / 对象),放到正文,避免标题行放不下把按钮挤掉 const stats = el('div', { class: 'xpa-stats' }); const st1 = el('div', { class: 'xpa-stat' }); st1.appendChild(el('span', {}, '任务')); sumTasks = el('b', {}, '0/0'); st1.appendChild(sumTasks); const st2 = el('div', { class: 'xpa-stat' }); st2.appendChild(el('span', {}, '对象')); sumObj = el('b', {}, '0'); st2.appendChild(sumObj); stats.appendChild(st1); stats.appendChild(st2); bodyWrap.appendChild(stats); // ===== 任务选择 ===== const sec1h = el('div', { class: 'xpa-sec-head' }, '任务选择'); sec1h.appendChild(el('span', { class: 'xpa-chev' }, '▾')); const sec1 = el('div', { class: 'xpa-sec-body' }); const rowPk = el('div', { class: 'xpa-line' }); projectKey = el('input', { class: 'xpa-input', placeholder: '项目ID project_key' }); const guess = (location.pathname.match(/\/v\/project\/([^/]+)/i) || location.pathname.match(/\/project\/([^/]+)/i) || [])[1]; if (guess) projectKey.value = guess; const loadBtn = el('button', { class: 'xpa-btn xpa-btn-primary xpa-sm' }, '加载任务'); loadBtn.onclick = loadTasks; rowPk.appendChild(projectKey); rowPk.appendChild(loadBtn); sec1.appendChild(rowPk); taskSearch = el('input', { class: 'xpa-input xpa-search', placeholder: '搜索任务名称 / 任务KEY...' }); taskSearch.oninput = debounce(() => { taskShown = CHUNK; renderTasks(); }, 200); sec1.appendChild(taskSearch); const ctl1 = el('div', { class: 'xpa-ctl' }); const sa = el('label', { class: 'xpa-check' }); taskSelAll = el('input', { type: 'checkbox' }); taskSelAll.onchange = () => { tasks.forEach(t => { t.__checked = taskSelAll.checked; }); renderTasks(); updateSummary(); scheduleAutoRead(); }; sa.appendChild(taskSelAll); sa.appendChild(el('span', {}, '全选')); const clr = el('button', { class: 'xpa-btn xpa-sm' }, '清空'); clr.onclick = () => { tasks.forEach(t => { t.__checked = false; }); taskSelAll.checked = false; readTaskKey = null; renderTasks(); updateSummary(); }; const ia = el('label', { class: 'xpa-check xpa-check-accent' }); isAllCb = el('input', { type: 'checkbox' }); isAllCb.onchange = updateSummary; ia.appendChild(isAllCb); ia.appendChild(el('span', {}, '全部任务(is_all)')); ctl1.appendChild(sa); ctl1.appendChild(clr); ctl1.appendChild(ia); sec1.appendChild(ctl1); taskCount = el('div', { class: 'xpa-count' }, ''); sec1.appendChild(taskCount); taskListWrap = el('div', { class: 'xpa-list' }); sec1.appendChild(taskListWrap); collapsible(sec1h, sec1); // ===== 分配对象 ===== const sec2h = el('div', { class: 'xpa-sec-head' }, '分配对象'); sec2h.appendChild(el('span', { class: 'xpa-chev' }, '▾')); const sec2 = el('div', { class: 'xpa-sec-body' }); const tabs = el('div', { class: 'xpa-tabs' }); tabTeam = el('button', { class: 'xpa-tab' }, '按团队'); tabUser = el('button', { class: 'xpa-tab' }, '按用户'); tabTeam.onclick = () => setMode('team'); tabUser.onclick = () => setMode('user'); tabs.appendChild(tabTeam); tabs.appendChild(tabUser); sec2.appendChild(tabs); const ctl2 = el('div', { class: 'xpa-ctl' }); const oa = el('label', { class: 'xpa-check' }); tabAll = el('input', { type: 'checkbox' }); tabAll.onchange = () => checkAllForActive(tabAll.checked); oa.appendChild(tabAll); oa.appendChild(el('span', {}, '全选')); permAllSel = el('select', { class: 'xpa-permall' }); Object.keys(PERMS).forEach(p => { permAllSel.appendChild(el('option', { value: p }, '全部:' + PERMS[p])); }); const permBtn = el('button', { class: 'xpa-btn xpa-sm' }, '统一权限'); permBtn.onclick = applyPermForActive; ctl2.appendChild(oa); ctl2.appendChild(permAllSel); ctl2.appendChild(permBtn); sec2.appendChild(ctl2); teamSearch = el('input', { class: 'xpa-input xpa-search', placeholder: '搜索团队...' }); teamSearch.oninput = debounce(() => { teamShown = CHUNK; renderTeams(); }, 200); sec2.appendChild(teamSearch); countTeam = el('div', { class: 'xpa-count' }, ''); sec2.appendChild(countTeam); teamWrap = el('div', { class: 'xpa-list' }); sec2.appendChild(teamWrap); userSearch = el('input', { class: 'xpa-input xpa-search', placeholder: '搜索用户...' }); userSearch.oninput = debounce(() => { userShown = CHUNK; renderUsers(); }, 200); userSearch.style.display = 'none'; sec2.appendChild(userSearch); countUser = el('div', { class: 'xpa-count' }, ''); countUser.style.display = 'none'; sec2.appendChild(countUser); userWrap = el('div', { class: 'xpa-list' }); userWrap.style.display = 'none'; sec2.appendChild(userWrap); collapsible(sec2h, sec2); // ===== 执行设置 ===== const sec3h = el('div', { class: 'xpa-sec-head' }, '执行设置'); sec3h.appendChild(el('span', { class: 'xpa-chev' }, '▾')); const sec3 = el('div', { class: 'xpa-sec-body' }); const line1 = el('div', { class: 'xpa-line' }); workTypeSel = el('select', { class: 'xpa-input' }); Object.keys(WORK_TYPES).forEach(k => { workTypeSel.appendChild(el('option', { value: k }, WORK_TYPES[k])); }); workTypeSel.onchange = () => { readTaskKey = null; scheduleAutoRead(); }; line1.appendChild(el('label', { class: 'xpa-lab' }, '工序')); line1.appendChild(workTypeSel); sec3.appendChild(line1); const line2 = el('div', { class: 'xpa-line' }); const readB = el('button', { class: 'xpa-btn xpa-sm' }, '读取当前'); readB.onclick = async () => { const tk = singleSelectedKey() || manualKeys.value.split(/[\s,,\n]+/).filter(Boolean)[0]; if (!tk) { toast('请先勾选一个任务', 'warn'); return; } try { const cur = await fetchCurrent(tk); readTaskKey = tk; applyCurrent(cur); toast('已读取并回显当前分配', 'ok'); } catch (e) { toast('读取失败:' + e.message, 'err'); } }; const savePB = el('button', { class: 'xpa-btn xpa-sm' }, '存预设'); savePB.onclick = savePreset; const loadPB = el('button', { class: 'xpa-btn xpa-sm' }, '套预设'); loadPB.onclick = loadPreset; line2.appendChild(readB); line2.appendChild(savePB); line2.appendChild(loadPB); sec3.appendChild(line2); const lineAuto = el('div', { class: 'xpa-line' }); const auCb = el('input', { type: 'checkbox' }); auCb.checked = autoEcho; auCb.onchange = () => { autoEcho = auCb.checked; if (autoEcho) { readTaskKey = null; scheduleAutoRead(); } }; const auLab = el('label', { class: 'xpa-check' }); auLab.appendChild(auCb); auLab.appendChild(el('span', {}, '选中任务后自动回显已分配')); lineAuto.appendChild(auLab); sec3.appendChild(lineAuto); const lineRL = el('div', { class: 'xpa-line' }); const rlCb = el('input', { type: 'checkbox' }); rlCb.checked = autoReload; rlCb.onchange = () => { autoReload = rlCb.checked; }; const rlLab = el('label', { class: 'xpa-check' }); rlLab.appendChild(rlCb); rlLab.appendChild(el('span', {}, '分配成功后自动刷新平台查看')); lineRL.appendChild(rlLab); sec3.appendChild(lineRL); manualKeys = el('textarea', { class: 'xpa-input xpa-ta', placeholder: '手动任务KEY(备用,逗号/回车分隔)' }); const curKey = new URLSearchParams(location.search).get('task_key'); if (curKey) manualKeys.value = curKey; sec3.appendChild(manualKeys); runBtn = el('button', { class: 'xpa-btn xpa-btn-primary xpa-run' }, '执行分配'); runBtn.onclick = doAssign; sec3.appendChild(runBtn); collapsible(sec3h, sec3); bodyWrap.appendChild(sec1h); bodyWrap.appendChild(sec1); bodyWrap.appendChild(sec2h); bodyWrap.appendChild(sec2); bodyWrap.appendChild(sec3h); bodyWrap.appendChild(sec3); panelEl.appendChild(head); panelEl.appendChild(bodyWrap); document.body.appendChild(fab); document.body.appendChild(panelEl); panel = panelEl; panelEl.style.left = Math.max(10, window.innerWidth - 372) + 'px'; panelEl.style.top = '120px'; head.addEventListener('mousedown', e => { if (e.target.closest('.xpa-btn')) return; const R = panelEl.getBoundingClientRect(), st = { x: e.clientX - R.left, y: e.clientY - R.top }; const move = ev => { const w = panelEl.offsetWidth || 360, h = Math.max(70, panelEl.offsetHeight || 120); panelEl.style.left = Math.max(0, Math.min(ev.clientX - st.x, window.innerWidth - w - 6)) + 'px'; panelEl.style.top = Math.max(0, Math.min(ev.clientY - st.y, window.innerHeight - h + 30)) + 'px'; }; const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); e.preventDefault(); }); setMode('team'); injectCss(); refreshObj(); updateSummary(); if (guess) loadTasks(); } function injectCss() { const css = ` .xpa-fab{position:fixed;right:18px;top:150px;z-index:999999;background:linear-gradient(135deg,#06b6d4,#3b82f6);color:#04101f;border-radius:9px;padding:10px 13px;font-size:13px;font-weight:700;cursor:pointer;box-shadow:0 0 14px rgba(56,189,248,.5);font-family:system-ui,'Microsoft YaHei';user-select:none;} .xpa-fab:hover{box-shadow:0 0 22px rgba(56,189,248,.7);} .xpa-panel{position:fixed;z-index:999998;width:360px;max-height:80vh;background:linear-gradient(155deg,rgba(13,20,38,.97),rgba(8,12,24,.97));backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(56,189,248,.28);border-radius:14px;box-shadow:0 14px 46px rgba(0,0,0,.62),0 0 0 1px rgba(56,189,248,.08),0 0 26px rgba(56,189,248,.12);color:#dbeafe;font:13px/1.6 system-ui,'Microsoft YaHei','PingFang SC',sans-serif;overflow:hidden;} .xpa-panel.xpa-min{width:230px;} .xpa-panel.xpa-hide{display:none;} .xpa-accent{position:relative;height:4px;background:linear-gradient(90deg,#22d3ee,#3b82f6,#818cf8);overflow:hidden;} .xpa-accent::after{content:'';position:absolute;top:0;left:-40%;width:40%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.85),transparent);animation:xpa-shine 3.2s linear infinite;} @keyframes xpa-shine{to{left:120%;}} .xpa-head{display:flex;align-items:center;gap:9px;padding:10px 12px 9px;cursor:move;user-select:none;background:linear-gradient(180deg,rgba(56,189,248,.10),transparent);} .xpa-logo{width:27px;height:27px;border-radius:8px;background:linear-gradient(135deg,#22d3ee,#3b82f6);display:flex;align-items:center;justify-content:center;color:#04101f;font-size:14px;font-weight:800;flex:none;box-shadow:0 0 12px rgba(34,211,238,.55);} .xpa-titles{flex:1;min-width:0;} .xpa-title{font-size:14px;font-weight:700;color:#e6f7ff;letter-spacing:.5px;text-shadow:0 0 8px rgba(56,189,248,.35);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .xpa-sub{font-size:11px;color:#7aa0c0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .xpa-led{width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 9px #22c55e;flex:none;animation:xpa-led 1.6s ease-in-out infinite;} @keyframes xpa-led{0%,100%{opacity:1;}50%{opacity:.35;}} .xpa-right{display:flex;align-items:center;gap:6px;flex:none;margin-left:auto;} .xpa-stats{display:flex;gap:8px;margin:4px 0 8px;} .xpa-stat{flex:1;background:rgba(30,41,59,.5);border:1px solid rgba(56,189,248,.16);border-radius:10px;padding:6px 8px;text-align:center;} .xpa-stat b{display:block;font-family:Consolas,monospace;font-size:15px;font-weight:700;line-height:1.3;color:#38bdf8;text-shadow:0 0 10px rgba(56,189,248,.5);font-variant-numeric:tabular-nums;} .xpa-stat span{font-size:10px;color:#7aa0c0;} .xpa-pill{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600;color:#7dd3fc;background:rgba(56,189,248,.10);border:1px solid rgba(56,189,248,.3);white-space:nowrap;} .xpa-btn{min-width:24px;height:24px;border:1px solid rgba(56,189,248,.3);border-radius:7px;background:rgba(30,41,59,.6);color:#7aa0c0;cursor:pointer;font-size:14px;line-height:1;display:flex;align-items:center;justify-content:center;padding:0 6px;transition:all .15s;font-family:inherit;} .xpa-btn:hover{background:rgba(56,189,248,.18);color:#e6f7ff;border-color:rgba(56,189,248,.6);} .xpa-body{max-height:calc(80vh - 62px);overflow-y:auto;padding:6px 12px 12px;} .xpa-body::-webkit-scrollbar{width:5px;} .xpa-body::-webkit-scrollbar-thumb{background:rgba(56,189,248,.35);border-radius:4px;} .xpa-sec-head{display:flex;align-items:center;justify-content:space-between;font-size:12px;font-weight:700;color:#7dd3fc;padding:9px 2px 5px;cursor:pointer;border-bottom:1px solid rgba(56,189,248,.14);margin-bottom:6px;user-select:none;} .xpa-sec-head:hover{color:#38bdf8;} .xpa-chev{transition:transform .15s;color:#7aa0c0;} .xpa-sec-head.xpa-fold .xpa-chev{transform:rotate(-90deg);} .xpa-sec-body{margin-bottom:8px;} .xpa-line{display:flex;gap:6px;align-items:center;margin-bottom:6px;} .xpa-lab{color:#7aa0c0;font-size:12px;flex:none;} .xpa-input{flex:1;min-width:0;background:rgba(18,22,31,.8);color:#dfe6f1;border:1px solid rgba(56,189,248,.22);border-radius:7px;padding:7px 9px;font-size:13px;font-family:inherit;outline:none;} .xpa-input:focus{border-color:rgba(56,189,248,.6);box-shadow:0 0 8px rgba(56,189,248,.25);} .xpa-search{margin-bottom:6px;width:100%;display:block;} .xpa-ta{min-height:46px;resize:vertical;margin-bottom:8px;} .xpa-ctl{display:flex;align-items:center;gap:6px;margin-bottom:6px;flex-wrap:wrap;} .xpa-check{display:inline-flex;align-items:center;color:#dfe6f1;font-size:12px;cursor:pointer;margin:0;} .xpa-check input{margin:0 4px 0 0;} .xpa-check-accent{color:#7dd3fc;} .xpa-permall{background:rgba(18,22,31,.8);color:#dfe6f1;border:1px solid rgba(56,189,248,.22);border-radius:6px;padding:5px 6px;font-size:12px;flex:1;} .xpa-count{color:#7aa0c0;font-size:11px;margin:0 2px 5px;} .xpa-list{max-height:180px;overflow-y:auto;border:1px solid rgba(56,189,248,.16);border-radius:8px;background:rgba(10,14,24,.45);margin-bottom:8px;} .xpa-list::-webkit-scrollbar{width:5px;} .xpa-list::-webkit-scrollbar-thumb{background:rgba(56,189,248,.35);border-radius:4px;} .xpa-task,.xpa-row{display:flex;align-items:center;gap:8px;padding:7px 9px;border-top:1px solid rgba(56,189,248,.08);cursor:pointer;transition:background .12s;} .xpa-task:first-child,.xpa-row:first-child{border-top:none;} .xpa-task:hover,.xpa-row:hover{background:rgba(56,189,248,.10);} .xpa-task.on{background:rgba(34,211,238,.10);border-left:3px solid #22d3ee;} .xpa-cb{flex:none;accent-color:#06b6d4;} .xpa-task-body{min-width:0;flex:1;} .xpa-task-name{color:#e2e8f0;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .xpa-task-key{color:#7aa0c0;font-size:10px;font-family:Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .xpa-row-name{flex:1;min-width:0;color:#dfe6f1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;} .xpa-badge{flex:none;font-size:10px;color:#7dd3fc;background:rgba(34,211,238,.14);border:1px solid rgba(34,211,238,.35);border-radius:999px;padding:1px 7px;white-space:nowrap;} .xpa-perm{flex:none;background:rgba(18,22,31,.8);color:#dfe6f1;border:1px solid rgba(56,189,248,.22);border-radius:5px;padding:3px 5px;font-size:12px;} .xpa-more{color:#7a9bec;font-size:12px;text-align:center;padding:8px;cursor:pointer;border-top:1px solid rgba(56,189,248,.1);} .xpa-more:hover{text-decoration:underline;} .xpa-empty{color:#7aa0c0;text-align:center;padding:18px 0;font-size:12px;} .xpa-tabs{display:flex;gap:6px;margin-bottom:7px;} .xpa-tab{flex:1;background:rgba(30,41,59,.6);color:#dfe6f1;border:1px solid rgba(56,189,248,.2);border-radius:7px;padding:7px;font-size:12px;cursor:pointer;font-family:inherit;transition:all .12s;} .xpa-tab-active{background:linear-gradient(135deg,#06b6d4,#3b82f6);border-color:transparent;color:#04101f;font-weight:700;box-shadow:0 0 12px rgba(56,189,248,.4);} .xpa-btn-primary{background:linear-gradient(135deg,#06b6d4,#3b82f6);color:#04101f;font-weight:700;box-shadow:0 0 12px rgba(56,189,248,.4);} .xpa-btn-primary:hover{box-shadow:0 0 18px rgba(56,189,248,.6);} .xpa-sm{flex:none;padding:8px 11px;font-size:12px;border-radius:7px;border:1px solid rgba(56,189,248,.3);background:rgba(30,41,59,.6);color:#7aa0c0;cursor:pointer;font-family:inherit;} .xpa-sm:hover{background:rgba(56,189,248,.18);color:#e6f7ff;} .xpa-run{width:100%;padding:11px;font-size:14px;margin-top:2px;} .xpa-run:disabled{opacity:.6;cursor:not-allowed;} .xpa-modal{position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;} .xpa-modal-box{width:320px;background:linear-gradient(155deg,rgba(20,28,48,.99),rgba(10,15,28,.99));border:1px solid rgba(56,189,248,.35);border-radius:14px;box-shadow:0 16px 50px rgba(0,0,0,.65);padding:16px 16px 14px;color:#dbeafe;} .xpa-modal-title{font-size:14px;font-weight:700;color:#e6f7ff;margin-bottom:10px;} .xpa-modal-msg{font-size:13px;color:#b9c7dd;line-height:1.7;margin-bottom:14px;word-break:break-all;} .xpa-modal-btns{display:flex;gap:8px;} .xpa-modal-btns .xpa-btn{flex:1;} .xpa-toast{position:fixed;left:50%;top:70px;transform:translateX(-50%);z-index:999999;background:#22304a;color:#eaf1ff;border-radius:8px;padding:10px 18px;font-size:13px;box-shadow:0 4px 16px rgba(0,0,0,.4);font-family:system-ui,'Microsoft YaHei';} .xpa-err{background:#5a2d33;color:#ffd9dd;} .xpa-warn{background:#5a4326;color:#ffe9c2;} `; document.head.appendChild(el('style', {}, css)); } function start() { if (document.getElementById('xpa-fab')) return; try { buildPanel(); } catch (e) { console.error('[分配助手] 初始化失败', e); alert('分配助手初始化失败:' + (e && e.message ? e.message : e)); } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start(); })();