// ==UserScript== // @name 活字填表 - 网申表单快速填写工具 // @namespace https://scriptcat.org/huozi-form/ // @version 1.6.0 // @description 通用网申表单快速填写工具。v1.6 新增行政区/地址弹窗适配:锁定原始字段,忽略弹窗搜索框抢焦点,并支持通过搜索或分级点击选择城市/区县。数据仅保存在脚本管理器本地,不自动提交。 // @author 法研小新 // @match *://*/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_setClipboard // @license MIT // @tag 求职 // @tag 网申 // @tag 填表 // @tag 效率工具 // ==/UserScript== (function () { 'use strict'; const APP = '活字填表'; const VERSION = '1.6.0'; const KEY = 'huozi_public_v11'; const PRE_IMPORT_KEY = 'huozi_public_v11_preimport'; const sleep = ms => new Promise(r => setTimeout(r, ms)); const clone = obj => JSON.parse(JSON.stringify(obj)); const uid = (prefix='id') => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2,7)}`; const nowText = () => { const d = new Date(), p = n => String(n).padStart(2,'0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`; }; function defaultData(initialized=false) { return { app: APP, version: VERSION, initialized, categories: [ { id:'basic', name:'个人信息', open:true, blocks:[] }, { id:'education', name:'教育经历', open:false, blocks:[] }, { id:'family', name:'家庭信息', open:false, blocks:[] }, { id:'work', name:'实习 / 工作', open:false, blocks:[] }, { id:'other', name:'其他', open:false, blocks:[] } ], seq: [], templates: [], settings: { skipFilled:false } }; } function normalizeData(obj) { obj = obj && typeof obj === 'object' ? obj : defaultData(false); obj.app = APP; obj.version = VERSION; if (typeof obj.initialized !== 'boolean') obj.initialized = true; if (!Array.isArray(obj.categories)) obj.categories = defaultData(true).categories; obj.categories = obj.categories.map((c, i) => ({ id: c.id || uid('cat'), name: String(c.name || `分类${i+1}`), open: !!c.open, blocks: Array.isArray(c.blocks) ? c.blocks.map(b => ({name:String(b?.name || ''), value:String(b?.value ?? '')})) : [] })); if (!obj.categories.some(c => c.id === 'other')) obj.categories.push({id:'other',name:'其他',open:false,blocks:[]}); obj.seq = Array.isArray(obj.seq) ? obj.seq.map(x => ({ name:String(x?.name || x?.value || ''), value:String(x?.value ?? ''), type:x?.type || 'normal' })) : []; obj.templates = Array.isArray(obj.templates) ? obj.templates.map(t => ({ id:t.id || uid('tpl'), name:String(t.name || '未命名模板'), seq:Array.isArray(t.seq) ? t.seq.map(x => ({name:String(x?.name || x?.value || ''),value:String(x?.value ?? ''),type:x?.type || 'normal'})) : [] })) : []; obj.settings = obj.settings && typeof obj.settings === 'object' ? obj.settings : {}; obj.settings.skipFilled = !!obj.settings.skipFilled; return obj; } function loadData() { try { const raw = GM_getValue(KEY, ''); if (raw) return normalizeData(JSON.parse(raw)); } catch {} return defaultData(false); } let data = loadData(); let current = null; let currentTarget = null; // 用户实际点击到的网页元素,用于自定义下拉兜底 let lastValue = ''; // 最近一次尝试填写的值,供“强制选择”使用 let editMode = false; let dragIndex = null; let runState = null; function save() { data.app = APP; data.version = VERSION; GM_setValue(KEY, JSON.stringify(data)); } /* ------------------------- 按需启用 / 记住本站 ------------------------- */ const SITE_KEY = 'huozi_public_sites_v11'; function currentHost() { return location.hostname.replace(/^www\./, '').toLowerCase(); } function getSites() { try { const raw = GM_getValue(SITE_KEY, '[]'); const arr = JSON.parse(raw); return Array.isArray(arr) ? arr : []; } catch { return []; } } function saveSites(sites) { GM_setValue(SITE_KEY, JSON.stringify([...new Set(sites)])); } function isRememberedSite() { return getSites().includes(currentHost()); } function refreshSiteButton() { const btn = document.querySelector('#hzSite'); if (!btn) return; btn.textContent = isRememberedSite() ? '取消本站' : '记住本站'; btn.title = isRememberedSite() ? '取消以后进入此网站时自动显示' : '以后进入此网站时自动显示活字填表'; } function rememberCurrentSite() { const sites = getSites(); if (!sites.includes(currentHost())) { sites.push(currentHost()); saveSites(sites); } refreshSiteButton(); alert(`已记住当前网站:\n${currentHost()}\n\n以后进入这个网站会自动显示活字填表。`); } function forgetCurrentSite() { saveSites(getSites().filter(x => x !== currentHost())); refreshSiteButton(); alert('已取消当前网站的自动启用。\n\n本次页面仍可继续使用。'); } function toggleRememberSite() { isRememberedSite() ? forgetCurrentSite() : rememberCurrentSite(); } function visible(el) { if (!el) return false; const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 2 && r.height > 2 && s.display !== 'none' && s.visibility !== 'hidden'; } function looksLikeDropdown(el) { if (!el || el === document.body || el === document.documentElement) return false; const role = (el.getAttribute?.('role') || '').toLowerCase(); const ariaPopup = (el.getAttribute?.('aria-haspopup') || '').toLowerCase(); const cls = String(el.className || '').toLowerCase(); if (role === 'combobox' || ['listbox','menu','tree'].includes(ariaPopup)) return true; if (/(select|dropdown|picker|cascader|combobox|choice)/.test(cls)) return true; const input = el.matches?.('input') ? el : el.querySelector?.('input'); if (input) { const ph = (input.getAttribute('placeholder') || '').trim(); const aria = (input.getAttribute('aria-label') || '').trim(); if (input.readOnly) return true; if (/(请选择|选择|搜索|select|choose|search)/i.test(ph + ' ' + aria)) return true; if ((input.getAttribute('role') || '').toLowerCase() === 'combobox') return true; if (input.getAttribute('aria-haspopup')) return true; } return false; } function heuristicDropdownRoot(el) { if (!el) return null; let node = el; for (let i = 0; node && i < 6; i++, node = node.parentElement) { if (node === document.body || node === document.documentElement) break; if (!visible(node)) continue; const r = node.getBoundingClientRect(); // 避免误把整张表单/页面当作“下拉框” if (r.height > 180 || r.width > Math.max(window.innerWidth * 0.95, 1200)) continue; if (looksLikeDropdown(node)) return node; } return null; } function fieldRoot(el) { if (!el) return null; const component = el.closest( '.el-cascader,.el-select,.ant-select,.el-radio-group,.ant-radio-group,[role="radiogroup"],[role="combobox"]' ); if (component) return component; const known = el.closest('select,textarea,input,[contenteditable="true"]'); if (known) { const dropdown = heuristicDropdownRoot(known); return dropdown || known; } return heuristicDropdownRoot(el); } function fieldName(root) { if (!root) return '未选择'; const item = root.closest('.el-form-item,[class*="form-item"],.ant-form-item'); if (item) { const label = item.querySelector('label.el-form-item__label,label,.ant-form-item-label'); const text = label?.textContent?.replace(/\s+/g,' ').trim(); if (text) return text.replace(/[::*]/g,'').trim(); } const input = root.matches?.('input,textarea') ? root : root.querySelector?.('input,textarea'); const ph = input?.getAttribute?.('placeholder') || ''; return ph || '当前字段'; } function updateCurrentIndicator() { const el = document.querySelector('#hzCurrent'); if (el) el.textContent = `当前:${fieldName(current)}`; } function isPopupInteractionTarget(el) { if (!el || !el.closest) return false; return !!el.closest( '.el-select-dropdown,.el-picker-panel,.el-date-picker,.el-cascader__dropdown,' + '.ant-select-dropdown,.ant-picker-dropdown,' + '.s-dialog__overlay,.s-dialog[role="dialog"],' + '[role="listbox"],[role="menu"]' ); } document.addEventListener('mousedown', e => { if (typeof panel !== 'undefined' && panel.contains(e.target)) return; // v1.6:点击“是/否”选项或日历日期时,不要把 current 改成弹层本身。 // 从用户截图可见,旧版会把“当前:是否为学生干部”错误变成“当前:是”。 if (isPopupInteractionTarget(e.target)) return; currentTarget = e.target; const root = fieldRoot(e.target); if (root) { current = root; updateCurrentIndicator(); } }, true); document.addEventListener('focusin', e => { if (typeof panel !== 'undefined' && panel.contains(e.target)) return; if (isPopupInteractionTarget(e.target)) return; currentTarget = e.target; const root = fieldRoot(e.target); if (root) { current = root; updateCurrentIndicator(); } }, true); function nativeSetInputValue(input, value) { if (!input) return false; const proto = input.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; setter ? setter.call(input, value) : (input.value = value); input.dispatchEvent(new Event('input', {bubbles:true})); return true; } function setText(root, value) { const input = root.matches?.('input,textarea') ? root : root.querySelector?.('input,textarea'); if (!input || input.disabled || input.readOnly) return false; input.focus(); nativeSetInputValue(input, value); input.dispatchEvent(new Event('change', {bubbles:true})); input.blur(); return true; } function setSearchText(input, value) { if (!input) return false; input.focus(); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; setter ? setter.call(input, value) : (input.value = value); input.dispatchEvent(new Event('input', {bubbles:true})); return true; } function elementUIRoot(root, selector) { if (!root) return null; if (root.matches?.(selector)) return root; return root.closest?.(selector) || root.querySelector?.(selector) || null; } function vueInstanceFrom(el) { let node = el; for (let i = 0; node && i < 6; i++, node = node.parentElement) { if (node.__vue__) return node.__vue__; } return null; } function elementUIOptionLabel(opt) { if (!opt) return ''; const values = [ opt.currentLabel, opt.label, opt.value, opt.$el?.innerText, opt.$el?.textContent ]; for (const v of values) { if (v !== undefined && v !== null) { const s = String(v).replace(/\s+/g, ' ').trim(); if (s) return s; } } return ''; } function chooseElementUISelectViaVue(root, value) { const select = elementUIRoot(root, '.el-select'); if (!select) return false; const vm = select.__vue__ || vueInstanceFrom(select); if (!vm) return false; const cleanValue = String(value).replace(/\s+/g, ' ').trim(); const options = Array.isArray(vm.options) ? vm.options : []; let hit = options.find(opt => elementUIOptionLabel(opt) === cleanValue); if (!hit && cleanValue.length > 1) { hit = options.find(opt => elementUIOptionLabel(opt).includes(cleanValue)); } if (!hit) return false; try { // Element UI 2.x 的 el-select 正常选项流程。 if (typeof vm.handleOptionSelect === 'function') { vm.handleOptionSelect(hit, true); return true; } if (typeof hit.selectOptionClick === 'function') { hit.selectOptionClick(); return true; } if (hit.$el) { hit.$el.click(); return true; } } catch {} return false; } function parseDateForElementUI(value) { const d = parseDateValue(value); if (!d) return null; const date = new Date(d.year, d.month - 1, d.day, 12, 0, 0, 0); if ( date.getFullYear() !== d.year || date.getMonth() !== d.month - 1 || date.getDate() !== d.day ) return null; return date; } function formatDateForInput(value) { const d = parseDateValue(value); if (!d) return null; return `${d.year}-${String(d.month).padStart(2,'0')}-${String(d.day).padStart(2,'0')}`; } async function chooseElementUIDateViaInput(root, value) { const editor = elementUIRoot(root, '.el-date-editor'); if (!editor) return false; const input = editor.querySelector('input.el-input__inner') || editor.querySelector('input'); if (!input || input.disabled) return false; const formatted = formatDateForInput(value); if (!formatted) return false; try { /* Element UI 2.x 的日期输入框虽然 readonly,但组件本身仍监听: input -> userInput change -> handleChange -> parseString -> emitInput 因此这里不只是改 input.value,而是主动触发组件原生事件链。 */ input.focus(); const setter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, 'value' )?.set; if (setter) setter.call(input, formatted); else input.value = formatted; input.dispatchEvent(new Event('input', {bubbles:true})); await sleep(30); input.dispatchEvent(new Event('change', {bubbles:true})); await sleep(60); // 部分封装层在 Enter / blur 时才执行最终校验。 input.dispatchEvent(new KeyboardEvent('keydown', { key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true, cancelable:true })); input.dispatchEvent(new KeyboardEvent('keyup', { key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true, cancelable:true })); input.blur(); await sleep(140); // 若组件接受了日期,通常仍会保持 yyyy-MM-dd 或等价格式。 const normalized = String(input.value || '') .replace(/[年月.]/g,'-') .replace(/日/g,'') .replace(/\//g,'-') .replace(/\s+/g,''); return normalized === formatted; } catch { return false; } } async function chooseElementUIDateViaVue(root, value) { const editor = elementUIRoot(root, '.el-date-editor'); if (!editor) return false; const date = parseDateForElementUI(value); if (!date) return false; const vm = editor.__vue__ || vueInstanceFrom(editor); if (!vm) return false; try { // 若 picker 尚未创建,先让 Element UI 自己创建面板实例。 if (!vm.picker && typeof vm.showPicker === 'function') { vm.showPicker(); await sleep(60); } // Element UI Picker 内部监听 picker 的 pick 事件; // 走这个路径可以同时更新 v-model、显示值和 change 逻辑。 if (vm.picker && typeof vm.picker.$emit === 'function') { vm.picker.$emit('pick', date, false); await sleep(40); return true; } // 次级兜底:直接调用 Picker 的 emitInput。 if (typeof vm.emitInput === 'function') { vm.emitInput(date); if (typeof vm.$emit === 'function') vm.$emit('change', date); await sleep(40); return true; } } catch {} return false; } async function chooseElementUISelectViaDOM(root, value) { const select = elementUIRoot(root, '.el-select'); if (!select) return false; const cleanValue = String(value).replace(/\s+/g, ' ').trim(); // 已展开时直接找 Element UI 的真实 li 选项。 let options = [...document.querySelectorAll('.el-select-dropdown__item')] .filter(el => visible(el) && !el.classList.contains('is-disabled')); let hit = options.find(el => optionText(el) === cleanValue); if (!hit && cleanValue.length > 1) { hit = options.find(el => optionText(el).includes(cleanValue)); } if (hit) { hit.click(); await sleep(80); return true; } // 未展开:点击 el-select 自身/内部 input,由 Element UI 自己打开菜单。 const input = select.querySelector('input.el-input__inner'); (input || select).click(); await sleep(160); options = [...document.querySelectorAll('.el-select-dropdown__item')] .filter(el => visible(el) && !el.classList.contains('is-disabled')); hit = options.find(el => optionText(el) === cleanValue); if (!hit && cleanValue.length > 1) { hit = options.find(el => optionText(el).includes(cleanValue)); } if (!hit) return false; hit.click(); await sleep(80); return true; } function chooseRadio(root, value) { const area = root.closest('.el-form-item,[class*="form-item"],.ant-form-item,tr,td,li') || root.parentElement || root; const clean = el => (el.innerText || el.textContent || '').replace(/\s+/g,' ').trim(); const candidates = [...area.querySelectorAll('label,.el-radio,.el-radio-button,[role="radio"]')].filter(visible); let hit = candidates.find(x => clean(x) === value) || candidates.find(x => clean(x).includes(value)); if (!hit) return false; hit.dispatchEvent(new MouseEvent('mousedown', {bubbles:true,cancelable:true})); hit.click(); hit.dispatchEvent(new MouseEvent('mouseup', {bubbles:true,cancelable:true})); return true; } function visibleOptions() { const selector = [ '[role="option"]', '[role="menuitem"]', '[role="treeitem"]', '.el-select-dropdown__item', '.ant-select-item-option', '.el-cascader-node', '.el-cascader-menu__item', '[class*="dropdown"] [class*="item"]', '[class*="popup"] [class*="item"]', '[class*="popover"] [class*="item"]', '[class*="menu"] [class*="item"]', '[class*="option"]' ].join(','); return [...document.querySelectorAll(selector)] .filter(el => visible(el) && !(typeof panel !== 'undefined' && panel.contains(el))); } function optionText(el) { return (el.innerText || el.textContent || '').replace(/\s+/g,' ').trim(); } function popupContainers() { const selector = [ '[role="listbox"]', '[role="menu"]', '.ant-select-dropdown', '.ant-picker-dropdown', '.el-select-dropdown', '.el-picker-panel', '.el-date-picker', '[class*="dropdown"]', '[class*="popup"]', '[class*="popover"]', '[class*="calendar"]', '[class*="picker-panel"]' ].join(','); return [...document.querySelectorAll(selector)] .filter(el => visible(el) && !(typeof panel !== 'undefined' && panel.contains(el))); } function isInsidePopup(el) { return popupContainers().some(p => p === el || p.contains(el)); } function promoteClickable(el) { if (!el) return null; let best = el; let node = el; const originalText = optionText(el); for (let i=0; node && i<4; i++, node=node.parentElement) { if (!node || typeof panel !== 'undefined' && panel.contains(node)) break; const t = optionText(node); if (t !== originalText) break; const role = (node.getAttribute?.('role') || '').toLowerCase(); const cls = String(node.className || '').toLowerCase(); let cursor = ''; try { cursor = getComputedStyle(node).cursor; } catch {} if ( ['option','menuitem','treeitem','radio'].includes(role) || /(^|[\s_-])(option|item|cell|menu-item|select-item)([\s_-]|$)/.test(cls) || ['LI','BUTTON','LABEL','TD','A'].includes(node.tagName) || cursor === 'pointer' ) { best = node; } } return best; } function allVisibleTextCandidates(value) { const cleanValue = String(value).replace(/\s+/g,' ').trim(); if (!cleanValue) return []; const selector = [ '[role="option"]', '[role="menuitem"]', '[role="treeitem"]', 'li', 'button', 'label', 'td', '[class*="option"]', '[class*="item"]', '[class*="cell"]', 'span', 'div' ].join(','); const raw = [...document.querySelectorAll(selector)]; const out = []; const seen = new Set(); for (const el of raw) { if (!visible(el)) continue; if (typeof panel !== 'undefined' && panel.contains(el)) continue; const r = el.getBoundingClientRect(); if (r.width < 3 || r.height < 3 || r.width > 900 || r.height > 160) continue; if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) continue; const text = optionText(el); if (!text || text.length > 80) continue; const exact = text === cleanValue; const includes = cleanValue.length > 1 && text.includes(cleanValue) && text.length <= Math.max(cleanValue.length + 16, 28); if (!exact && !includes) continue; const clickable = promoteClickable(el) || el; if (seen.has(clickable)) continue; seen.add(clickable); const role = (clickable.getAttribute?.('role') || '').toLowerCase(); const cls = String(clickable.className || '').toLowerCase(); let cursor = ''; try { cursor = getComputedStyle(clickable).cursor; } catch {} let score = exact ? 100 : 40; if (isInsidePopup(clickable)) score += 120; if (['option','menuitem','treeitem'].includes(role)) score += 60; if (/(option|select|dropdown|menu|item)/.test(cls)) score += 35; if (cursor === 'pointer') score += 20; if (['LI','BUTTON','LABEL','TD'].includes(clickable.tagName)) score += 15; if (current) { const a = current.getBoundingClientRect(); const b = clickable.getBoundingClientRect(); const dx = Math.abs((a.left+a.right)/2 - (b.left+b.right)/2); const dy = Math.abs((a.top+a.bottom)/2 - (b.top+b.bottom)/2); score -= Math.min((dx + dy) / 100, 30); } out.push({el: clickable, score, exact}); } return out.sort((a,b) => b.score - a.score); } function findAnyMatchingOption(value) { const structured = findMatchingOption(value); if (structured) return structured; return allVisibleTextCandidates(value)[0]?.el || null; } function parseDateValue(value) { const s = String(value ?? '').trim(); if (!s) return null; const normalized = s .replace(/[年月.]/g,'-') .replace(/日/g,'') .replace(/\//g,'-') .replace(/\s+/g,''); const m = normalized.match(/(?:^|[^\d])(\d{4})-(\d{1,2})(?:-(\d{1,2}))?(?:$|[^\d])/) || normalized.match(/^(\d{4})-(\d{1,2})(?:-(\d{1,2}))?$/); if (!m) return null; const year = Number(m[1]), month = Number(m[2]), day = m[3] ? Number(m[3]) : 1; if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null; return {year, month, day, hasDay: !!m[3]}; } function looksLikeDateField(root) { if (!root) return false; const input = root.matches?.('input') ? root : root.querySelector?.('input'); const type = (input?.type || '').toLowerCase(); const ph = (input?.getAttribute?.('placeholder') || '').toLowerCase(); const aria = (input?.getAttribute?.('aria-label') || '').toLowerCase(); const cls = (String(root.className || '') + ' ' + String(input?.className || '')).toLowerCase(); const name = (fieldName(root) || '').toLowerCase(); return ( ['date','month','datetime-local'].includes(type) || /(date|calendar|picker|日期|年月)/i.test(cls + ' ' + ph + ' ' + aria) || /(日期|时间|入学|毕业|开始|结束|出生|date|time)/i.test(name) ); } function findCalendarPopup() { const known = [...document.querySelectorAll( '.ant-picker-dropdown,.ant-picker-panel-container,.el-picker-panel,.el-date-picker,[class*="date-picker"],[class*="datepicker"],[class*="calendar"]' )].filter(el => visible(el) && !(typeof panel !== 'undefined' && panel.contains(el))); const looksCalendar = el => { const text = (el.innerText || '').replace(/\s+/g,' '); return ( /\d{4}\s*年\s*\d{1,2}\s*月/.test(text) || /日\s*[一二三四五六]/.test(text) || /(Sun|Mon|Tue|Wed|Thu|Fri|Sat)/i.test(text) ); }; return known.find(looksCalendar) || popupContainers().find(looksCalendar) || null; } function calendarYearMonth(popup) { const text = (popup?.innerText || '').replace(/\s+/g,' '); let m = text.match(/(\d{4})\s*年\s*(\d{1,2})\s*月/); if (m) return {year:Number(m[1]), month:Number(m[2])}; m = text.match(/\b(\d{4})[-/](\d{1,2})\b/); if (m) return {year:Number(m[1]), month:Number(m[2])}; return null; } function calendarNav(popup) { const q = s => popup.querySelector(s); let prevYear = q( '.el-date-picker__prev-btn.el-icon-d-arrow-left,' + '.ant-picker-header-super-prev-btn,' + '[aria-label*="Previous year" i],[title*="上一年"],[aria-label*="上一年"],[aria-label*="前一年"]' ); let prevMonth = q( '.el-date-picker__prev-btn.el-icon-arrow-left,' + '.ant-picker-header-prev-btn,' + '[aria-label*="Previous month" i],[title*="上一月"],[aria-label*="上一月"],[aria-label*="上个月"]' ); let nextMonth = q( '.el-date-picker__next-btn.el-icon-arrow-right,' + '.ant-picker-header-next-btn,' + '[aria-label*="Next month" i],[title*="下一月"],[aria-label*="下一月"],[aria-label*="下个月"]' ); let nextYear = q( '.el-date-picker__next-btn.el-icon-d-arrow-right,' + '.ant-picker-header-super-next-btn,' + '[aria-label*="Next year" i],[title*="下一年"],[aria-label*="下一年"],[aria-label*="后一年"]' ); // 通用兜底:日历头部常见四个箭头按钮,顺序通常为 上一年 / 上一月 / 下一月 / 下一年 if (!prevMonth || !nextMonth) { const pr = popup.getBoundingClientRect(); const buttons = [...popup.querySelectorAll('button')] .filter(visible) .filter(b => { const r = b.getBoundingClientRect(); return r.top < pr.top + Math.max(90, pr.height * 0.25); }) .sort((a,b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left); if (buttons.length >= 4) { prevYear ||= buttons[0]; prevMonth ||= buttons[1]; nextMonth ||= buttons[buttons.length-2]; nextYear ||= buttons[buttons.length-1]; } else if (buttons.length >= 2) { prevMonth ||= buttons[0]; nextMonth ||= buttons[buttons.length-1]; } } return {prevYear, prevMonth, nextMonth, nextYear}; } async function chooseDate(root, value, silent=false) { const d = parseDateValue(value); if (!d) return false; if (elementUIRoot(root, '.el-date-editor')) { // v1.6:优先走 Element UI 自己监听的 input/change 事件链。 // 这一条不依赖 userscript 能否访问 Vue 的 __vue__ 实例。 const inputOK = await chooseElementUIDateViaInput(root, value); if (inputOK) return true; // 若页面环境允许访问 Vue 实例,再尝试组件内部 pick/emitInput。 const vueOK = await chooseElementUIDateViaVue(root, value); if (vueOK) return true; } // 最后才使用可见日历面板的 DOM 点击作为兜底。 let popup = findCalendarPopup(); // 如果日期弹层尚未打开,先尝试打开当前字段 if (!popup) { for (const trigger of triggerCandidates(root)) { dispatchPointerSequence(trigger); await sleep(180); popup = findCalendarPopup(); if (popup) break; } } if (!popup) { if (!silent) alert(`没有识别到日期选择器。\n目标日期:${value}`); return false; } const iso = `${d.year}-${String(d.month).padStart(2,'0')}-${String(d.day).padStart(2,'0')}`; const direct = popup.querySelector( `[title="${iso}"],[data-date="${iso}"],[data-value="${iso}"]` ); if (direct && visible(direct)) { const clickTarget = direct.querySelector?.('.ant-picker-cell-inner,span,div') || direct; await clickOption(clickTarget); return true; } let ym = calendarYearMonth(popup); if (ym) { let diff = (d.year - ym.year) * 12 + (d.month - ym.month); const nav = calendarNav(popup); // 优先用“上一年/下一年”减少跨年点击次数 if (diff <= -12 && nav.prevYear) { const years = Math.floor(Math.abs(diff) / 12); for (let i=0; i= 12 && nav.nextYear) { const years = Math.floor(diff / 12); for (let i=0; i { const cls = String(td.className || '').toLowerCase(); return ( !/(disabled|prev-month|next-month)/.test(cls) && optionText(td) === dayText ); }); if (!dayCandidates.length) { dayCandidates = [...popup.querySelectorAll('td,[role="gridcell"],[class*="cell"],button,span,div')] .filter(visible) .filter(el => optionText(el) === dayText) .filter(el => { const cls = String(el.className || '').toLowerCase(); const ariaDisabled = el.getAttribute?.('aria-disabled'); return ( ariaDisabled !== 'true' && !/(disabled|prev-month|next-month|outside|other-month)/.test(cls) ); }) .map(promoteClickable) .filter(Boolean); } if (dayCandidates.length) { // 优先 Ant 的 in-view / Element Plus 的 available dayCandidates.sort((a,b) => { const ca = String(a.className || '').toLowerCase(); const cb = String(b.className || '').toLowerCase(); const sa = /(in-view|available|current-month)/.test(ca) ? 1 : 0; const sb = /(in-view|available|current-month)/.test(cb) ? 1 : 0; return sb - sa; }); const targetDay = dayCandidates[0]; try { // Element UI 的 click handler 挂在日期 td 上,直接原生 click 最接近真实选择。 targetDay.click(); await sleep(140); return true; } catch { await clickOption(targetDay); return true; } } if (!silent) alert(`日期选择器已打开,但没有找到 ${iso} 对应的日期格。`); return false; } function dispatchPointerSequence(el) { if (!el) return; const common = {bubbles:true,cancelable:true,view:window}; try { el.dispatchEvent(new PointerEvent('pointerdown', {...common, pointerId:1, pointerType:'mouse', isPrimary:true})); } catch {} el.dispatchEvent(new MouseEvent('mousedown', common)); try { el.dispatchEvent(new PointerEvent('pointerup', {...common, pointerId:1, pointerType:'mouse', isPrimary:true})); } catch {} el.dispatchEvent(new MouseEvent('mouseup', common)); el.dispatchEvent(new MouseEvent('click', common)); } async function clickOption(option) { if (!option) return false; option.scrollIntoView({block:'nearest'}); await sleep(40); dispatchPointerSequence(option); await sleep(180); return true; } function triggerCandidates(root) { const result = []; const seen = new Set(); const add = el => { if (!el || seen.has(el)) return; if (el === document.body || el === document.documentElement) return; if (!visible(el)) return; if (typeof panel !== 'undefined' && panel.contains(el)) return; const r = el.getBoundingClientRect(); if (r.width < 3 || r.height < 3 || r.height > 220) return; seen.add(el); result.push(el); }; add(currentTarget); add(root); const first = currentTarget || root; let node = first; for (let i=0; node && i<6; i++, node=node.parentElement) add(node); if (root) { const inner = root.querySelector?.( 'input,[role="combobox"],[aria-haspopup],.el-select__wrapper,.el-input__wrapper,.ant-select-selector' ); add(inner); } // 优先尝试最像“真正触发器”的节点 result.sort((a,b) => Number(looksLikeDropdown(b)) - Number(looksLikeDropdown(a))); return result; } function findMatchingOption(value, baseline = new Set()) { const options = visibleOptions(); const cleanValue = String(value).trim(); const newlyVisible = options.filter(x => !baseline.has(x)); const pools = [newlyVisible, options]; for (const pool of pools) { let hit = pool.find(x => optionText(x) === cleanValue); if (hit) return hit; // 单字值(如“无/有/是/否”)只做精确匹配,避免误点包含该字的其他文本 if (cleanValue.length > 1) { hit = pool.find(x => { const t = optionText(x); return t && t.length <= Math.max(cleanValue.length + 12, 24) && t.includes(cleanValue); }); if (hit) return hit; } } return null; } async function forceChooseDropdown(root, value, silent=false) { if (!root && !currentTarget) { if (!silent) alert('请先点击网页里的下拉框'); return false; } // v1.6:如果用户已经手动展开了下拉框,先直接点当前可见选项。 // 旧版这里会再次点击触发器,反而把已经打开的下拉框关掉。 let openHit = findAnyMatchingOption(value); if (openHit && isInsidePopup(openHit)) { await clickOption(openHit); return true; } const triggers = triggerCandidates(root); for (const trigger of triggers) { try { trigger.focus?.({preventScroll:true}); } catch {} dispatchPointerSequence(trigger); await sleep(220); const input = (root?.matches?.('input') ? root : root?.querySelector?.('input')) || (trigger.matches?.('input') ? trigger : trigger.querySelector?.('input')); if (input && !input.disabled && !input.readOnly) { const ph = input.getAttribute('placeholder') || ''; const role = input.getAttribute('role') || ''; if (/搜索|search/i.test(ph) || role === 'combobox' || looksLikeDropdown(root || trigger)) { setSearchText(input, String(value)); await sleep(220); } } let hit = findAnyMatchingOption(value); if (!hit) { await sleep(220); hit = findAnyMatchingOption(value); } if (hit) { await clickOption(hit); return true; } } if (!silent) { alert(`没有找到可选择的「${value}」选项。\n\n如果下拉框已经展开,请保持它展开后再点一次素材或“强制选择”。`); } return false; } async function chooseDropdown(root, value) { // v1.6:从用户提供的 DOM 可确认目标网站使用 Element UI 2.x。 // 对 el-select 先走 Vue 组件自身的选项处理逻辑,避免只改 input 文本却没更新 v-model。 if (elementUIRoot(root, '.el-select')) { if (chooseElementUISelectViaVue(root, value)) return true; if (await chooseElementUISelectViaDOM(root, value)) return true; } // 已经展开时不要再次点触发器,否则会把菜单关闭 let hit = findAnyMatchingOption(value); if (hit && isInsidePopup(hit)) return await clickOption(hit); const trigger = root.querySelector?.('.el-select__wrapper,.el-input,.el-input__wrapper,.ant-select-selector,input') || root; dispatchPointerSequence(trigger); await sleep(220); const input = root.matches?.('input') ? root : root.querySelector?.('input'); if (input && !input.disabled && !input.readOnly) { setSearchText(input, value); await sleep(260); } for (let attempt=0; attempt<3; attempt++) { hit = findAnyMatchingOption(value); if (hit) return await clickOption(hit); await sleep(220); } return await forceChooseDropdown(root, value, true); } /* ------------------------- 行政区 / 地址弹窗(v1.6) 根据用户提供页面可确认: .s-dialog__overlay .s-dialog[role="dialog"] .s-dialog__title = 请选择行政区 input.s-input__inner[placeholder*="搜索城市名/区县"] ------------------------- */ function findRegionDialog() { const dialogs = [...document.querySelectorAll( '.s-dialog[role="dialog"],.s-dialog__overlay [role="dialog"]' )].filter(visible); return dialogs.find(dialog => { const title = dialog.querySelector('.s-dialog__title')?.textContent?.trim() || ''; const text = (dialog.textContent || '').replace(/\s+/g, ' '); return /行政区|城市|区县/.test(title + ' ' + text); }) || null; } function regionSearchInput(dialog) { if (!dialog) return null; return dialog.querySelector( 'input.s-input__inner[placeholder*="搜索城市"],' + 'input.s-input__inner[placeholder*="区县"],' + 'input[placeholder*="搜索城市名"],' + 'input[placeholder*="区县"]' ); } function normalizeRegionText(s) { return String(s || '') .replace(/\s+/g, '') .replace(/特别行政区$/,'') .replace(/壮族自治区$/,'') .replace(/回族自治区$/,'') .replace(/维吾尔自治区$/,'') .replace(/自治区$/,'') .replace(/省$/,'') .replace(/市$/,''); } function parseRegionParts(value) { let parts = String(value ?? '') .split(new RegExp('[-/>|,,]+')) .map(x => x.trim()) .filter(Boolean); // 去掉连续重复,例如 北京-北京-海淀区 -> 北京-海淀区 const out = []; for (const part of parts) { const prev = out[out.length - 1]; if (prev && normalizeRegionText(prev) === normalizeRegionText(part)) continue; out.push(part); } return out; } function regionCandidates(dialog, wanted) { if (!dialog) return []; const clean = String(wanted || '').replace(/\s+/g,' ').trim(); if (!clean) return []; const selector = [ 'button','li','a','span','div', '[role="option"]','[role="menuitem"]','[role="treeitem"]', '[class*="item"]','[class*="city"]','[class*="region"]','[class*="area"]' ].join(','); const seen = new Set(); const candidates = []; for (const el of dialog.querySelectorAll(selector)) { if (!visible(el)) continue; if (el === regionSearchInput(dialog)) continue; const text = optionText(el); if (!text || text.length > 30) continue; const exact = text === clean; const normalizedExact = normalizeRegionText(text) === normalizeRegionText(clean); if (!exact && !normalizedExact) continue; let target = promoteClickable(el) || el; if (!dialog.contains(target) || seen.has(target)) continue; seen.add(target); const r = target.getBoundingClientRect(); if (r.width > 360 || r.height > 90) continue; const cls = String(target.className || '').toLowerCase(); let cursor = ''; try { cursor = getComputedStyle(target).cursor; } catch {} let score = exact ? 100 : 80; if (cursor === 'pointer') score += 30; if (['BUTTON','LI','A'].includes(target.tagName)) score += 20; if (/(item|city|region|area|active)/.test(cls)) score += 15; candidates.push({el: target, score}); } return candidates.sort((a,b) => b.score - a.score); } async function clickRegionText(dialog, wanted) { const found = regionCandidates(dialog, wanted); if (!found.length) return false; const target = found[0].el; try { target.scrollIntoView({block:'nearest'}); target.click(); } catch { dispatchPointerSequence(target); } await sleep(180); return true; } async function searchRegionLeaf(dialog, parts) { const input = regionSearchInput(dialog); if (!input || !parts.length) return false; // 优先搜索最末级区县/城市;搜索框本身不允许抢走 current。 const leaf = parts[parts.length - 1]; input.focus(); nativeSetInputValue(input, leaf); input.dispatchEvent(new Event('change', {bubbles:true})); await sleep(350); let candidates = regionCandidates(dialog, leaf); if (!candidates.length) { await sleep(300); candidates = regionCandidates(dialog, leaf); } if (!candidates.length) return false; // 如果同名区县有多个,优先选择祖先文本中包含上一级城市/省份的。 if (candidates.length > 1 && parts.length > 1) { const parents = parts.slice(0, -1).map(normalizeRegionText); candidates.sort((a,b) => { const textA = normalizeRegionText(a.el.parentElement?.textContent || ''); const textB = normalizeRegionText(b.el.parentElement?.textContent || ''); const scoreA = parents.filter(p => p && textA.includes(p)).length; const scoreB = parents.filter(p => p && textB.includes(p)).length; return scoreB - scoreA || b.score - a.score; }); } try { candidates[0].el.click(); } catch { dispatchPointerSequence(candidates[0].el); } await sleep(220); return true; } async function chooseRegion(root, value, silent=false) { const parts = parseRegionParts(value); if (!parts.length) return false; let dialog = findRegionDialog(); // 弹窗没开时,先点击原始地址字段。 if (!dialog) { const trigger = currentTarget || root; if (trigger) { try { trigger.click(); } catch { dispatchPointerSequence(trigger); } } await sleep(260); dialog = findRegionDialog(); } if (!dialog) { if (!silent) alert('没有识别到“请选择行政区”弹窗。'); return false; } // 第一策略:使用页面自己的“搜索城市名/区县”。 // 对“北京-海淀区”这类值,通常可直接搜索海淀区并完成选择。 if (await searchRegionLeaf(dialog, parts)) { return true; } // 搜索失败后清空搜索框,再按层级逐个点。 const search = regionSearchInput(dialog); if (search) { nativeSetInputValue(search, ''); search.dispatchEvent(new Event('change', {bubbles:true})); await sleep(180); } let clicked = 0; for (const part of parts) { dialog = findRegionDialog() || dialog; // 有些直辖市界面只出现一次“北京”,若本级找不到则允许跳过。 const ok = await clickRegionText(dialog, part); if (ok) { clicked++; await sleep(180); continue; } // 最后一层必须找到;中间层可容忍网站省略重复的省/市层级。 if (part === parts[parts.length - 1]) { if (!silent) alert(`行政区弹窗已打开,但没有找到「${part}」。`); return false; } } return clicked > 0; } function looksLikeRegionField(root) { if (!root) return false; const name = fieldName(root); const input = root.matches?.('input') ? root : root.querySelector?.('input'); const ph = input?.getAttribute?.('placeholder') || ''; const cls = String(root.className || '') + ' ' + String(input?.className || ''); return /现居住地|居住地|籍贯|生源地|行政区|地址|城市|区县/.test(name + ' ' + ph) || /(region|area|city|address)/i.test(cls); } async function chooseCascader(root, value) { const parts = String(value).split(new RegExp('[-/>]')).map(x => x.trim()).filter(Boolean); root.click(); await sleep(250); for (const part of parts) { let hit = null; for (let attempt=0; attempt<3; attempt++) { const options = visibleOptions(); hit = options.find(x => optionText(x) === part) || options.find(x => optionText(x).includes(part)); if (hit) break; await sleep(250); } if (!hit) return false; await clickOption(hit); await sleep(250); } return true; } function hasValue(root) { if (!root) return false; if (root.matches?.('.el-radio-group,.ant-radio-group,[role="radiogroup"]')) { return !!root.querySelector('input:checked,.is-checked,[aria-checked="true"]'); } if (root.tagName === 'SELECT') return root.value !== '' && root.selectedIndex >= 0; if (root.matches?.('.el-select,.ant-select,.el-cascader,[role="combobox"]')) { const selected = root.querySelector?.('.el-select__selected-item,.el-select__tags-text,.ant-select-selection-item,.el-cascader__tags'); if (selected?.textContent?.trim()) return true; const input = root.matches?.('input') ? root : root.querySelector?.('input'); return !!input?.value?.trim(); } const input = root.matches?.('input,textarea') ? root : root.querySelector?.('input,textarea'); return !!input?.value?.trim(); } async function applyValue(root, value) { if (!root && currentTarget) root = fieldRoot(currentTarget) || currentTarget; if (!root) { alert('请先点击网页字段'); return false; } value = String(value ?? ''); lastValue = value; // v1.6:行政区弹窗打开时,优先按地址处理。 // 这样弹窗搜索框获得焦点也不会把素材误填进搜索框。 if (findRegionDialog() || looksLikeRegionField(root)) { const regionParts = parseRegionParts(value); if (regionParts.length >= 1 && (findRegionDialog() || regionParts.length >= 2)) { const regionOK = await chooseRegion(root, value, true); if (regionOK) return true; } } const parsedDate = parseDateValue(value); if (parsedDate && (elementUIRoot(root, '.el-date-editor') || findCalendarPopup() || looksLikeDateField(root))) { const dateOK = await chooseDate(root, value, true); if (dateOK) return true; } if (root.matches?.('.el-radio-group,.ant-radio-group,[role="radiogroup"]')) return chooseRadio(root, value); if (root.matches?.('.el-cascader')) return await chooseCascader(root, value); if (elementUIRoot(root, '.el-select') || root.matches?.('.ant-select,[role="combobox"]')) return await chooseDropdown(root, value); if (root.tagName === 'SELECT') { const option = [...root.options].find(x => x.text.trim() === value) || [...root.options].find(x => x.text.includes(value)); if (!option) return false; root.value = option.value; root.dispatchEvent(new Event('change', {bubbles:true})); return true; } if (['是','否','有','无','不涉及'].includes(value) && chooseRadio(root, value)) return true; // v1.6:只读输入框 / “请选择” / aria-haspopup / 自定义 select 容器优先按下拉处理 if (looksLikeDropdown(root) || heuristicDropdownRoot(currentTarget || root)) { const ok = await forceChooseDropdown(root, value, true); if (ok) return true; } // 普通文本框仍按文本填写;readOnly 文本框失败后再强制下拉兜底 if (setText(root, value)) return true; return await forceChooseDropdown(root, value, true); } function getFields() { const raw = [...document.querySelectorAll( '.el-cascader,.el-select,.el-radio-group,.ant-select,.ant-radio-group,[role="radiogroup"],[role="combobox"],select,textarea,input:not([type="hidden"]),[contenteditable="true"]' )]; const result = [], seen = new Set(); for (const el of raw) { let root = fieldRoot(el); if (!root || !visible(root)) continue; if (typeof panel !== 'undefined' && panel.contains(root)) continue; if (root.tagName === 'INPUT' && ['button','submit','reset','file','hidden'].includes((root.type || '').toLowerCase())) continue; if (root.tagName === 'INPUT' && ['radio','checkbox'].includes((root.type || '').toLowerCase())) { const group = root.closest('.el-radio-group,.ant-radio-group,[role="radiogroup"]'); if (group) root = group; else continue; } if (seen.has(root)) continue; seen.add(root); result.push(root); } result.sort((a,b) => { const A = a.getBoundingClientRect(), B = b.getBoundingClientRect(); return Math.abs(A.top-B.top) > 12 ? A.top-B.top : A.left-B.left; }); return result; } async function startSequence() { if (!current) return alert('请先点击第一个需要填写的字段'); if (!data.seq.length) return alert('临时序列为空'); const fields = getFields(); const start = fields.indexOf(current); if (start < 0) return alert('没有识别到当前字段,请重新点击一下'); runState = {fields,fieldIndex:start,seqIndex:0,paused:false}; hidePause(); await continueRun(); } async function continueRun() { if (!runState) return; const s = runState; while (s.seqIndex < data.seq.length) { const item = data.seq[s.seqIndex]; if (item.type === 'skip') { s.fieldIndex++; s.seqIndex++; continue; } while (s.fieldIndex < s.fields.length && !visible(s.fields[s.fieldIndex])) s.fieldIndex++; if (data.settings.skipFilled) { while (s.fieldIndex < s.fields.length && hasValue(s.fields[s.fieldIndex])) s.fieldIndex++; } const field = s.fields[s.fieldIndex]; if (!field) { alert('后面没有更多可填写字段'); finishRun(); return; } current = field; updateCurrentIndicator(); field.scrollIntoView({behavior:'auto',block:'center'}); await sleep(120); const ok = await applyValue(field, item.value); if (!ok) { s.paused = true; showPause(item, field); return; } s.seqIndex++; s.fieldIndex++; await sleep(220); } finishRun(); alert('这一组连续填写完成'); } function finishRun() { if (runState) { const next = runState.fields[runState.fieldIndex]; if (next) { current = next; updateCurrentIndicator(); } } runState = null; hidePause(); } async function retryCurrent() { if (!runState) return; const item = data.seq[runState.seqIndex], field = runState.fields[runState.fieldIndex]; if (!item || !field) return; const ok = await applyValue(field, item.value); if (!ok) return alert('仍然没有匹配成功'); runState.paused = false; runState.seqIndex++; runState.fieldIndex++; hidePause(); await continueRun(); } async function skipFailedAndContinue() { if (!runState) return; runState.paused = false; runState.seqIndex++; runState.fieldIndex++; hidePause(); await continueRun(); } function cancelRun() { runState = null; hidePause(); } const panel = document.createElement('div'); panel.id = 'huozi-panel'; panel.innerHTML = `
${APP} v${VERSION}
当前:未选择
快速选择
素材库
序列模板
临时序列
`; const style = document.createElement('style'); style.textContent = ` #huozi-panel{position:fixed;right:14px;top:70px;width:330px;max-height:82vh;overflow:auto;z-index:2147483647;background:#fff;border:1px solid #d0d0d0;border-radius:10px;box-shadow:0 8px 28px rgba(0,0,0,.18);font:13px/1.5 "Microsoft YaHei",Arial,sans-serif;color:#222} #huozi-panel *{box-sizing:border-box}.hz-head{position:sticky;top:0;z-index:30;display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:#1677ff;color:white}.hz-head button{border:0;background:transparent;color:white;font-size:18px;cursor:pointer}.hz-ver{font-size:11px;font-weight:400;opacity:.8}#hzMain{padding:0 10px 10px}.hz-sticky{position:sticky;top:41px;z-index:25;padding:9px 0 5px;background:#fff;border-bottom:1px solid #eee}.hz-current{padding:6px 8px;margin-bottom:6px;background:#f2f7ff;border-radius:6px;color:#245b9e;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.hz-title{margin:11px 0 6px;font-weight:700}.hz-toolbar{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0}.hz-toolbar button,#hzQuick button{padding:6px 9px;border:1px solid #ccc;background:white;border-radius:6px;cursor:pointer}#hzQuick button{margin:0 4px 4px 0}.hz-toolbar .primary{color:#1677ff;border-color:#1677ff;font-weight:700}#hzEdit.active{color:white;background:#1677ff;border-color:#1677ff}.hz-category{margin:6px 0;border:1px solid #ddd;border-radius:7px;overflow:hidden}.hz-category summary{display:flex;justify-content:space-between;align-items:center;padding:7px 9px;background:#f6f7f9;cursor:pointer;font-weight:600}.hz-category-body{display:flex;flex-wrap:wrap;gap:6px;padding:7px}.hz-chip{padding:6px 9px;border:1px solid #b8c4d3;background:#f8fbff;border-radius:6px;cursor:pointer}.hz-chip:hover{background:#edf5ff}.hz-category-edit{display:none;padding:2px 7px;border:1px solid #bbb;border-radius:5px;background:white;font-size:11px;cursor:pointer}.edit-mode .hz-category-edit{display:inline-block}#hzSequence{min-height:40px;padding:6px;border:1px dashed #bbb;border-radius:7px}.hz-seq{margin:2px;padding:5px 7px;border:1px solid #e2b86b;background:#fff8e8;border-radius:5px;cursor:grab}.hz-seq.dragging{opacity:.45}.hz-template{margin:2px 4px 2px 0;padding:6px 8px;border:1px solid #adc6ff;background:#f0f5ff;color:#245b9e;border-radius:6px;cursor:pointer}.hz-empty{color:#999;font-size:12px}.hz-check{display:block;margin:9px 0;cursor:pointer}.hz-pause{display:none;margin-top:10px;padding:9px;border:1px solid #ffbb96;background:#fff7e6;border-radius:7px}.hz-pause button{margin:7px 5px 0 0;padding:5px 8px;border:1px solid #aaa;background:white;border-radius:5px;cursor:pointer}.hz-modal{display:none;position:fixed;right:355px;top:90px;width:320px;max-height:76vh;overflow:auto;padding:14px;background:white;border:1px solid #bbb;border-radius:10px;box-shadow:0 8px 28px rgba(0,0,0,.22);z-index:2147483647}.hz-modal h3{margin:0 0 12px}.hz-modal p{margin:7px 0}.hz-modal label{display:block;margin:8px 0 4px}.hz-modal input,.hz-modal textarea,.hz-modal select{width:100%;padding:7px;border:1px solid #ccc;border-radius:6px}.hz-modal textarea{min-height:75px;resize:vertical}.hz-modal-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}.hz-modal-actions button{padding:6px 9px;border:1px solid #ccc;background:white;border-radius:6px;cursor:pointer}.hz-modal-actions .save{color:#1677ff;border-color:#1677ff}.hz-modal-actions .delete{color:#d4380d;border-color:#d4380d}.hz-note{padding:8px;background:#f7f7f7;border-radius:6px;color:#555}.hz-first{padding:5px 0}.hz-first h3{margin-bottom:8px}.hz-first .hz-big{display:block;width:100%;margin:8px 0;padding:10px;border:1px solid #1677ff;background:#fff;color:#1677ff;border-radius:7px;cursor:pointer;font-weight:600} `; document.documentElement.appendChild(style); document.body.appendChild(panel); // v1.6:阻止面板内的鼠标事件冒泡到招聘网站 document。 // 很多组件用 document.mousedown / click 判断“点了弹层外部”, // 旧版点击素材时会先把已经展开的下拉框/日期框关闭。 ['pointerdown','mousedown','mouseup','click'].forEach(type => { panel.addEventListener(type, e => e.stopPropagation(), false); }); const modal = panel.querySelector('#hzModal'); const QUICK = ['是','否','有','无','不涉及']; function addToSequence(item, count=1) { for (let i=0;i { const b = document.createElement('button'); b.textContent=value; b.onmousedown = e => { if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); applyValue(current,value); }; b.onclick = e => e.preventDefault(); b.oncontextmenu = e => { e.preventDefault(); let count=1; if (e.shiftKey) count = parseInt(prompt('连续加入几次?','5')) || 1; addToSequence({name:value,value,type:'choice'},count); }; area.appendChild(b); }); } function renderCategories() { const area = panel.querySelector('#hzCategories'); area.innerHTML=''; panel.classList.toggle('edit-mode',editMode); data.categories.forEach(category => { const details = document.createElement('details'); details.className='hz-category'; details.open=!!category.open; details.ontoggle=()=>{category.open=details.open;save();}; const summary=document.createElement('summary'), title=document.createElement('span'), edit=document.createElement('button'); title.textContent=category.name; edit.className='hz-category-edit'; edit.textContent='编辑分类'; edit.onclick=e=>{e.preventDefault();e.stopPropagation();openCategoryEditor(category);}; summary.append(title,edit); const body=document.createElement('div'); body.className='hz-category-body'; if (!category.blocks.length) { const empty=document.createElement('span'); empty.className='hz-empty'; empty.textContent='暂无素材'; body.appendChild(empty); } category.blocks.forEach(block=>{ const chip=document.createElement('button'); chip.className='hz-chip'; chip.textContent=block.name; chip.title=block.value; chip.onmousedown=e=>{ if(e.button!==0)return; e.preventDefault(); e.stopPropagation(); editMode?openBlockEditor(block,category.id):applyValue(current,block.value); }; chip.onclick=e=>e.preventDefault(); chip.oncontextmenu=e=>{e.preventDefault();e.stopPropagation(); editMode?openBlockEditor(block,category.id):addToSequence({name:block.name,value:block.value,type:'normal'});}; body.appendChild(chip); }); details.append(summary,body); area.appendChild(details); }); } function closeModal(){ modal.style.display='none'; modal.innerHTML=''; } function openBlockEditor(block=null, categoryId=null) { modal.style.display='block'; modal.innerHTML=`

${block?'编辑素材':'新增素材'}

${block?'':''}
`; const name=modal.querySelector('#hzEditName'), value=modal.querySelector('#hzEditValue'), select=modal.querySelector('#hzEditCategory'); name.value=block?.name || ''; value.value=block?.value || ''; data.categories.forEach(c=>{const o=document.createElement('option');o.value=c.id;o.textContent=c.name;select.appendChild(o);}); select.value=categoryId || data.categories[0]?.id; modal.querySelector('#hzSaveBlock').onclick=()=>{ const n=name.value.trim(), v=value.value, newCatId=select.value; if(!n) return alert('素材名称不能为空'); if(block){ const oldCat=data.categories.find(c=>c.id===categoryId), newCat=data.categories.find(c=>c.id===newCatId); if(oldCat&&newCat&&oldCat!==newCat){oldCat.blocks=oldCat.blocks.filter(x=>x!==block);newCat.blocks.push(block);} block.name=n;block.value=v; }else data.categories.find(c=>c.id===newCatId)?.blocks.push({name:n,value:v}); save();closeModal();renderCategories(); }; if(block) modal.querySelector('#hzDeleteBlock').onclick=()=>{ if(!confirm(`删除「${block.name}」?`))return; const cat=data.categories.find(c=>c.id===categoryId); if(cat)cat.blocks=cat.blocks.filter(x=>x!==block); save();closeModal();renderCategories(); }; modal.querySelector('#hzCancelBlock').onclick=closeModal; } function ensureOtherCategory(){ let other=data.categories.find(c=>c.id==='other'); if(!other){other={id:'other',name:'其他',open:false,blocks:[]};data.categories.push(other);}return other; } function openCategoryEditor(category){ modal.style.display='block'; modal.innerHTML=`

编辑分类

`; modal.querySelector('#hzCatName').value=category.name; modal.querySelector('#hzSaveCat').onclick=()=>{const n=modal.querySelector('#hzCatName').value.trim();if(!n)return;category.name=n;save();closeModal();renderCategories();}; modal.querySelector('#hzDeleteCat').onclick=()=>{ if(category.id==='other')return alert('“其他”分类需要保留'); if(!confirm(`删除「${category.name}」?其中素材会移动到“其他”。`))return; ensureOtherCategory().blocks.push(...category.blocks);data.categories=data.categories.filter(c=>c!==category);save();closeModal();renderCategories(); }; modal.querySelector('#hzCancelCat').onclick=closeModal; } function renderSequence(){ const area=panel.querySelector('#hzSequence');area.innerHTML=''; if(!data.seq.length){area.innerHTML='右键素材加入;可拖动排序';return;} data.seq.forEach((item,index)=>{ const b=document.createElement('button');b.className='hz-seq';b.draggable=true;b.textContent=`${index+1}. ${item.name}`; b.title=(item.value==='是'||item.value==='否')?'点击切换 是/否;可拖拽排序':'点击删除;可拖拽排序'; b.onclick=()=>{if(item.value==='是'){item.name='否';item.value='否';}else if(item.value==='否'){item.name='是';item.value='是';}else data.seq.splice(index,1);save();renderSequence();}; b.ondragstart=e=>{dragIndex=index;b.classList.add('dragging');e.dataTransfer.effectAllowed='move';}; b.ondragend=()=>{dragIndex=null;b.classList.remove('dragging');}; b.ondragover=e=>{e.preventDefault();e.dataTransfer.dropEffect='move';}; b.ondrop=e=>{e.preventDefault();if(dragIndex===null||dragIndex===index)return;const moved=data.seq.splice(dragIndex,1)[0];data.seq.splice(index,0,moved);save();renderSequence();}; area.appendChild(b); }); } function renderTemplates(){ const area=panel.querySelector('#hzTemplates');area.innerHTML=''; if(!data.templates.length){area.innerHTML='还没有保存模板';return;} data.templates.forEach(t=>{ const b=document.createElement('button');b.className='hz-template';b.textContent=t.name;b.title='左键载入;右键删除'; b.onclick=()=>{if(data.seq.length&&!confirm(`载入「${t.name}」会替换当前临时序列,继续吗?`))return;data.seq=clone(t.seq);save();renderSequence();}; b.oncontextmenu=e=>{e.preventDefault();if(!confirm(`删除模板「${t.name}」?`))return;data.templates=data.templates.filter(x=>x!==t);save();renderTemplates();}; area.appendChild(b); }); } function showPause(item,field){ const box=panel.querySelector('#hzPause');box.style.display='block'; box.innerHTML=`连续填写已暂停
当前字段:${fieldName(field)}
当前素材:${item.name} → ${String(item.value)}
`; box.querySelector('#hzRetry').onclick=retryCurrent;box.querySelector('#hzSkipFailed').onclick=skipFailedAndContinue;box.querySelector('#hzCancelRun').onclick=cancelRun; } function hidePause(){const box=panel.querySelector('#hzPause');if(box){box.style.display='none';box.innerHTML='';}} function backupObject(){return {app:APP,version:VERSION,exportedAt:new Date().toISOString(),data:clone(data)};} function backupJSON(){return JSON.stringify(backupObject(),null,2);} function downloadText(text,filename){const blob=new Blob([text],{type:'application/json;charset=utf-8'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);} function parseBackup(text){const p=JSON.parse(text);const incoming=(p?.app===APP&&p?.data)?p.data:(p?.categories?p:null);if(!incoming||!Array.isArray(incoming.categories))throw new Error('不是有效的活字填表备份');return normalizeData(clone(incoming));} function savePreImport(){save();GM_setValue(PRE_IMPORT_KEY,JSON.stringify({time:new Date().toISOString(),data:clone(data)}));} function applyImported(imported){data=normalizeData(imported);data.initialized=true;save();editMode=false;runState=null;closeModal();refreshAll();alert('备份已恢复完成');} function exportBackup(){save();downloadText(backupJSON(),`活字填表备份-${nowText()}.json`);} function copyBackup(){save();GM_setClipboard(backupJSON(),'text');alert('完整备份已复制到剪贴板');} async function pasteBackup(){const text=prompt('请把备份 JSON 粘贴到这里:')||'';if(!text)return;try{const imported=parseBackup(text);if(!confirm('恢复备份会覆盖当前数据,继续吗?'))return;savePreImport();applyImported(imported);}catch(e){alert('导入失败:'+e.message);}} function restorePreImport(){const raw=GM_getValue(PRE_IMPORT_KEY,'');if(!raw)return alert('没有找到最近一次导入前备份');try{const b=JSON.parse(raw);if(!b?.data)throw new Error('备份格式错误');if(!confirm('恢复到最近一次导入之前的状态?'))return;data=normalizeData(clone(b.data));save();refreshAll();alert('已恢复到导入前状态');}catch(e){alert('恢复失败:'+e.message);}} function downloadBlankTemplate(){downloadText(JSON.stringify({app:APP,version:VERSION,note:'空白配置模板。可直接导入,也可在脚本界面中添加素材。',data:defaultData(true)},null,2),`活字填表-空白配置-${nowText()}.json`);} function resetAll(){if(!confirm('这会删除当前浏览器里保存的全部素材、分类、模板和设置。\n\n建议先导出备份。\n\n确定继续吗?'))return;if(!confirm('再次确认:重置后无法直接撤销。'))return;GM_setValue(PRE_IMPORT_KEY,JSON.stringify({time:new Date().toISOString(),data:clone(data)}));data=defaultData(true);save();closeModal();refreshAll();alert('已重置为空白配置');} function openDataPanel(){ modal.style.display='block'; let preTime='暂无';try{const p=JSON.parse(GM_getValue(PRE_IMPORT_KEY,'')||'null');if(p?.time)preTime=new Date(p.time).toLocaleString();}catch{} modal.innerHTML=`

数据管理

素材、模板和设置保存在脚本管理器本地,可跨不同网站复用。本脚本不上传你的填表内容。


最近一次导入/重置前备份:${preTime}

`; modal.querySelector('#hzExport').onclick=exportBackup;modal.querySelector('#hzImport').onclick=()=>panel.querySelector('#hzImportFile').click();modal.querySelector('#hzCopy').onclick=copyBackup;modal.querySelector('#hzPaste').onclick=pasteBackup;modal.querySelector('#hzRestore').onclick=restorePreImport;modal.querySelector('#hzBlank').onclick=downloadBlankTemplate;modal.querySelector('#hzReset').onclick=resetAll;modal.querySelector('#hzCloseData').onclick=closeModal; } function openHelp(){ modal.style.display='block'; modal.innerHTML=`

使用说明

左键素材:填入当前字段。v1.6 新增行政区选择:地址弹窗中的搜索框、城市和区县选项不会再抢走“当前字段”;地址素材可写成“北京-海淀区”或“北京-北京-海淀区”,脚本会优先用弹窗搜索,失败后再分级点击。日期逻辑继续沿用 v1.6。

快捷选择 是/否/有/无:先点网页里的对应选择框,再点快捷按钮;对常见自定义下拉也会尝试展开并点击真实选项。

日期素材:日期值建议写成 2024-09-012024/09/012024年9月1日。遇到常见日历会尝试自动翻到目标年月并点击日期。

强制选择:普通填写仍失败时,保持当前字段不变,用最近一次素材值再次按下拉/日期逻辑重试。

右键素材:加入临时序列。

编辑:新增、修改、删除、移动素材和分类。

连续填写:先点击网页第一个字段,再按临时序列依次填写。

安全说明:脚本不会自动点击最终投递/提交。iframe、Shadow DOM、验证码、文件上传及极端自研组件仍可能需要单独适配。

`; modal.querySelector('#hzCloseHelp').onclick=closeModal; } function firstRun(){ if(data.initialized)return; modal.style.display='block'; modal.innerHTML=`

欢迎使用「活字填表」

这是一个面向校招、网申和常见网页表单的本地快速填表工具。

脚本不预置姓名、电话、学校等个人资料,也不会上传服务器。所有素材由你自己创建,并保存在脚本管理器本地,可在不同网站之间复用。

`; modal.querySelector('#hzStartBlank').onclick=()=>{data.initialized=true;save();closeModal();refreshAll();}; modal.querySelector('#hzFirstImport').onclick=()=>panel.querySelector('#hzImportFile').click(); modal.querySelector('#hzFirstHelp').onclick=openHelp; } panel.querySelector('#hzImportFile').addEventListener('change',async e=>{ const file=e.target.files?.[0];e.target.value='';if(!file)return; try{const imported=parseBackup(await file.text());if(data.initialized&&!confirm('导入会覆盖当前全部活字填表数据。导入前会自动保存当前状态。继续吗?'))return;savePreImport();applyImported(imported);}catch(err){alert('导入失败:'+err.message);} }); panel.querySelector('#hzEdit').onclick=e=>{editMode=!editMode;e.target.classList.toggle('active',editMode);e.target.textContent=editMode?'完成':'编辑';renderCategories();}; panel.querySelector('#hzAddBlock').onclick=()=>openBlockEditor(null,data.categories[0]?.id); panel.querySelector('#hzAddCategory').onclick=()=>{const name=prompt('新分类名称');if(!name?.trim())return;data.categories.push({id:uid('cat'),name:name.trim(),open:true,blocks:[]});save();renderCategories();}; panel.querySelector('#hzForce').onmousedown=async e=>{ if(e.button!==0)return; e.preventDefault(); e.stopPropagation(); if(!lastValue)return alert('请先点一次你想填写的素材(例如“本科”或某个日期),再点“强制选择”。'); const root = current || fieldRoot(currentTarget) || currentTarget; if(parseDateValue(lastValue) && (findCalendarPopup() || looksLikeDateField(root))) { const ok = await chooseDate(root,lastValue,true); if(ok)return; } await forceChooseDropdown(root,lastValue,false); }; panel.querySelector('#hzForce').onclick=e=>e.preventDefault(); panel.querySelector('#hzSite').onclick=toggleRememberSite; panel.querySelector('#hzData').onclick=openDataPanel; panel.querySelector('#hzHelp').onclick=openHelp; panel.querySelector('#hzClose').onclick=()=>{panel.style.display='none';}; panel.querySelector('#hzSaveTemplate').onclick=()=>{if(!data.seq.length)return alert('当前临时序列为空');const name=prompt('模板名称,例如:硕士教育经历');if(!name?.trim())return;data.templates.push({id:uid('tpl'),name:name.trim(),seq:clone(data.seq)});save();renderTemplates();}; panel.querySelector('#hzSkip').onclick=()=>addToSequence({name:'跳过',value:'__SKIP__',type:'skip'}); panel.querySelector('#hzClear').onclick=()=>{data.seq=[];save();renderSequence();}; panel.querySelector('#hzFill').onclick=startSequence; panel.querySelector('#hzSkipFilled').onchange=e=>{data.settings.skipFilled=e.target.checked;save();}; panel.querySelector('#hzCollapse').onclick=()=>{const main=panel.querySelector('#hzMain');main.style.display=main.style.display==='none'?'':'none';}; function refreshAll(){ renderQuick();renderCategories();renderTemplates();renderSequence(); panel.querySelector('#hzSkipFilled').checked=!!data.settings.skipFilled; const editBtn=panel.querySelector('#hzEdit');editBtn.textContent=editMode?'完成':'编辑';editBtn.classList.toggle('active',editMode); updateCurrentIndicator(); refreshSiteButton(); } /* ------------------------- 按需显示 ------------------------- */ function showPanel() { panel.style.display = ''; refreshSiteButton(); if (!data.initialized) firstRun(); } function hidePanel() { panel.style.display = 'none'; } function togglePanel() { panel.style.display === 'none' ? showPanel() : hidePanel(); } GM_registerMenuCommand('活字填表:本次启用 / 显示', showPanel); GM_registerMenuCommand('活字填表:显示 / 隐藏面板', togglePanel); GM_registerMenuCommand( isRememberedSite() ? '活字填表:取消此网站自动启用' : '活字填表:以后自动启用此网站', toggleRememberSite ); document.addEventListener('keydown', e => { if (e.altKey && e.shiftKey && e.key.toLowerCase() === 'h') { e.preventDefault(); togglePanel(); } }); refreshAll(); if (isRememberedSite()) { showPanel(); } else { hidePanel(); } })();