// ==UserScript== // @name 班固米-条目职位自定义排序与折叠 // @namespace https://github.com/weiduhuo/scripts // @version 1.3.2-1.3 // @description 对所有类型条目的制作人员信息进行职位的自定义排序与折叠,可在[设置-隐私]页面进行相关设置 // @author weiduhuo // @match *://bgm.tv/subject/* // @match *://bgm.tv/character/* // @match *://bgm.tv/person/* // @match *://bgm.tv/settings/privacy* // @match *://bangumi.tv/subject/* // @match *://bangumi.tv/character/* // @match *://bangumi.tv/person/* // @match *://bangumi.tv/settings/privacy* // @match *://chii.in/subject/* // @match *://chii.in/character/* // @match *://chii.in/person/* // @match *://chii.in/settings/privacy* // @grant none // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; const SCRIPT_NAME = '班固米-职位排序组件'; const INTERFACE_NAME = '班固米-职位排序接口'; const CURRENT_DATA_VERSION = '1.3'; /** 禁止console.debug */ console.debug = function () {}; /** 排序延迟时间 */ const SORTING_DELAY = 50; /** 防抖延迟时间 */ const DEBOUNCE_DELAY = 500; /** 非设置模式下接口延迟时间 */ const INTERFACE_DELAY = 1000; /** URL 相对路径 */ const pathname = window.location.pathname; /** 是否对职位信息进行了折叠,忽略网页自身`sub_group`的折叠 (依此判断 `更多制作人员` 开关的必要性) */ let hasFolded = false; /** 尾部折叠图标的激活阈值相对于视口高度的系数 */ const sideTipRate = 0.25; /** * @type {number} 尾部折叠图标的激活行数阈值 */ let sideTipLineThr = null; /** * @type {Array | null} 最后一组`sub_group`的数据包 */ let lastGroup = null; /** * 图标,已在`loadStaffStyle`中通过父元素类名`staff_sorting_icon`约束所显示的范围 */ const ICON = { // 三角形顶点向右,可表展开按键 TRIANGLE_RIGHT: ` `, // 三角形顶点向下,可表折叠按键 TRIANGLE_DOWN: ` `, // 三角形顶点向上,可表折叠按键 TRIANGLE_UP: ` `, }; /** * 枚举所支持的条目类型 */ const SubjectType = { // 所支持的类型 ANIME: {en: 'anime', zh: '动画'}, BOOK: {en: 'book', zh: '书籍'}, MUSIC: {en: 'music', zh: '音乐'}, GAME: {en: 'game', zh: '游戏'}, REAL: {en: 'real', zh: '三次元'}, CHARACTER: {en: 'character', zh: '角色'}, PERSON: {en: 'person', zh: '人物'}, /** * @param {boolean} [isObj=false] - `true`时返回对象序列,`false`时返回英文序列 * @returns {{ en: string, zh: string }[] | string[]} */ getAll(isObj = false) { if (isObj) return filterEnumValues(this); else return filterEnumValues(this).map(item => item.en); }, /** @returns {string | null} 有效则返回原数值,无效则返回空 */ parse(value) { if (this.getAll().includes(value)) return value; return null; }, needPrase(value) { return value !== this.CHARACTER.en && value !== this.PERSON.en; }, }; /** * 枚举各类型条目的功能启用状态 */ const EnableState = { /** 启用全部功能 */ ALL_ENABLED: "allEnable", /** 启用部分功能,仅排序不折叠 */ PARTIAL_ENABLED: "partialEnable", /** 全部功能禁用 */ ALL_DISABLED: "allDisable", /** * @returns {Array} */ getAll() { return filterEnumValues(this); }, parse(value) { if (this.getAll().includes(value)) return value; return null; }, }; /** * 管理`localStorage`的键名与初值。 * 键值分为全局配置与各类型条目配置、简单类型与复杂类型 */ const Key = { /** 键名前缀 */ _KEY_PREF: 'BangumiStaffSorting', /** 数据版本 */ DATA_VERSION: '_dataVersion__', /** 排序接口 */ _INTERFACE: 'Interface', /** 共享注册表 */ SHARED_REGISTER: '_sharedRegister_', /** 共享注册表上锁 */ LOCK_KEY: '_lock_', /** 超过此行数的职位信息将被二次折叠*/ REFOLD_THRESHOLD_KEY: 'refoldThreshold', REFOLD_THRESHOLD_DEFAULT: 4, REFOLD_THRESHOLD_DISABLED: 0, /** 各类型条目模块的展开状态 */ BLOCK_OPEN_KEY: 'blockOpen', BLOCK_OPEN_DEFAULT: false, /** 各类型条目的功能启用状态 */ ENABLE_STATE_KEY: 'EnableState', ENABLE_STATE_DEFAULT: EnableState.ALL_ENABLED, /** 各类型条目的自定义排序与折叠 (复杂类型) */ STAFF_MAP_LIST_KEY: 'StaffMapList', /** 各类型条目的排序预匹配数据 Array (归为简单类型,目前仅音乐条目启用) */ PRE_MATCHED_DATA: 'PreMatchedData', /** 当前使用的键值的所属条目类型 (可即时切换) */ _subType: null, makeKey(key, type = null) { this.setSubType(type); if (this.isGlobalData(key)) return `${this._KEY_PREF}_${key}`; else return `${this._KEY_PREF}_${this._subType}${key}`; }, makeInterfaceKey(key, type = null) { if (this.isGlobalData(key)) return `${this._KEY_PREF}${this._INTERFACE}_${key}_`; else return `${this._KEY_PREF}${this._INTERFACE}_${type}${key}`; }, setSubType(type) { if (type && SubjectType.getAll().includes(type)) this._subType = type; }, isComplexData(key) { return [this.STAFF_MAP_LIST_KEY].includes(key); }, isGlobalData(key) { return [ this.REFOLD_THRESHOLD_KEY, this.DATA_VERSION, this.SHARED_REGISTER, this.LOCK_KEY ].includes(key); } } /** * 配置存储,提供`localStorage`的接口。 * 仅对简单数据类型进行解析、编码、缓存,复杂数据类型放权给外部 * (为便于进行防抖动绑定,由对象类型改为静态类实现) */ class Store { /** 数据缓存,仅对简单类型的键值 */ static _cache = {}; /** 需要对数据进行更新 */ static updateRequired = false; /** 定义防抖逻辑的占位 (忽略短时间内改变多对键值的极端情况) */ static debouncedSet; /** 为缺损的配置进行初始化 */ static initialize() { // 缓存初始化 Store._cache = {}; // 绑定防抖逻辑,确保 this 指向 Store Store.debouncedSet = debounce(Store._set.bind(this)); // 全局配置初始化 ['REFOLD_THRESHOLD'].forEach((key) => Store._setDefault(key)); // 局部配置初始化 SubjectType.getAll().forEach((type) => { ['BLOCK_OPEN', 'ENABLE_STATE'].forEach((key) => Store._setDefault(key, type)); }); // 检查数据版本 if (Store.get(Key.DATA_VERSION) !== CURRENT_DATA_VERSION) { Store.updateRequired = true; Store.set(Key.DATA_VERSION, CURRENT_DATA_VERSION); } } static _setDefault(_key, type = null) { if (this.get(Key[`${_key}_KEY`], type) === null) this.set(Key[`${_key}_KEY`], Key[`${_key}_DEFAULT`]); } static set(key, value, type = null, isHighFreq = false) { if (isHighFreq) this.debouncedSet(key, value, type); else this._set(key, value, type); } static _set(key, value, type = null) { Key.setSubType(type); const fullKey = Key.makeKey(key); if (!Key.isComplexData(key)) { value = JSON.stringify(value); this._cache[fullKey] = value; // 同步到缓存 } localStorage.setItem(fullKey, value); } static get(key, type = null) { Key.setSubType(type); const fullKey = Key.makeKey(key); // 简单数据类型,命中缓存 if (!Key.isComplexData() && Store._isCacheHit(fullKey)) { // console.debug(`HIT CHACHE - ${fullKey}: ${this._cache[fullKey]}`); return this._cache[fullKey]; } // 无缓存,读取并缓存 const value = localStorage.getItem(fullKey); if (Key.isComplexData(key)) return value; const parsedValue = JSON.parse(value); this._cache[fullKey] = parsedValue; return parsedValue; } static remove(key, type = null) { Key.setSubType(type); const fullKey = Key.makeKey(key); // 同时删除缓存与数据 delete this._cache[fullKey]; localStorage.removeItem(fullKey); } static _isCacheHit(fullKey) { return Object.prototype.hasOwnProperty.call(this._cache, fullKey); } } /** * `StaffMapList`的`JSON`格式化字符串。 * 最短的有效字符串为`"[]"`,其表示设置空缺。 */ const StaffMapListJSON = { /** * 解析`staffMapListJSON`字符串。 * 用于初步解析与有效性检测, * 更进一步的解析,将在`StaffMapList`中进行。 * 仅检查: * 1. 是否满足`JSON`格式 * 2. 是否为数组类型 * 3. 字符串样式的正则表达式,是否满足规定格式 * @returns {Array | null} `StaffMapList`数据或空值 */ parse(text) { let parsedData; try { parsedData = JSON.parse(text, this._reviver); } catch (e) { console.error(`${SCRIPT_NAME}:staffMapList 解析失败 - ${e}`); return null; } if (!Array.isArray(parsedData)) { console.error(`${SCRIPT_NAME}:staffMapList 类型错误 - 非数组类型`); return null; } return parsedData; }, /** 将`StaffMapList`转为`JSON`格式化字符串 */ stringify(data) { return JSON.stringify(data, this._replacer, 1); }, /** 解析`JSON`字符串中的正则表达式 */ _reviver(key, value) { if (typeof value === 'string' && value.startsWith('/')) { const regexParttern = /^\/(.+)\/([gimsuy]*)$/; const match = value.match(regexParttern); if (match) { try { return new RegExp(match[1], match[2]); } catch (e) { throw new Error(`正则表达式 "${value}" 非法 - ${e}`); } } else throw new Error(`正则表达式 "${value}" 不符合 ${regexParttern} 格式`); } return value; }, /** 将正则表达式转化为字符串,以满足`JSON`格式 */ _replacer(key, value) { if (value instanceof RegExp) return value.toString(); return value; }, } /** * 职位排序与折叠设置, * 是职位的排序列表`jobOrder`与默认折叠的职位`foldableJobs`信息的组合 */ class StaffMapList { /** * @typedef {string | RegExp} MatchJob - 匹配职位名称 * @typedef {[MatchJob | [boolean | MatchJob, ...MatchJob[]]]} StaffMapListType * 其中`boolean`表示子序列内的职位是否默认折叠,缺损值为`False`,需位于子序列的首位才有意义 * (默认配置中`,,`表示在`JSON`数组中插入`null`元素,用于输出格式化文本时标记换行) */ /** 懒加载的默认配置 */ static _defaultLazyData = { [SubjectType.ANIME.en]: () => [, "中文名", "类型", "适合年龄", /地区/, "语言", "对白", "话数", "总话数", [true, "季数"],, "放送开始", "开始", "放送星期", "放送时间", "上映年度", /上映/, "发售日", "片长", /片长/,, , "原作", "原案", "人物原案", "人物设定", "原作插图", [true, "原作协力"],, "团长", "总导演", "导演", "副导演", "执行导演", "主任导演", "联合导演", "系列监督",, "系列构成", "脚本", "编剧", [true, /脚本|内容|故事|文艺|主笔/],, "分镜", "OP・ED 分镜", "主演出", "演出", [true, "演出助理"],, , "总作画监督", [false, "作画监督"], [true, "作画监督助理"], "动作作画监督", "机械作画监督", "特效作画监督", /.*作画.*(监|导)/,, "主动画师", "主要动画师", [true, "构图"], [false, "原画"], [true, "第二原画", "补间动画"], "数码绘图", /(原画|动画|動画)(?!制|检查)/,, "动画检查", [true, /动画检查/],, , "设定", "背景设定", "道具设计", /(? [, "制作人",, "艺术家", "作词", "作曲", "编曲",, "脚本", "声乐", "乐器", "混音", "母带制作",, "插图", "原作", "出版方", "厂牌", ], }; /** @type {StaffMapListType} 主数据 */ data = []; /** @type {Array} 职位的排序列表 */ jobOrder = []; /** @type {Set} 默认折叠的职位,EnableState = "particalDisable" 时,内容为空 */ foldableJobs = new Set(); /** 所属条目类型(不可变更)*/ subType = null; /** 是否为默认数据 */ isDefault = null; /** 是否具备折叠功能 */ foldable = false; /** 默认配置格式化文本的缓存 */ _defaultTextBuffer = null; constructor(subType) { this.subType = subType; // 小心 Store._subType 被其他模块切换 } /** * 依据`EnableState`进行初始化,使其具备职位匹配的能力。 * 若仅为获取`StaffMapList`格式化字符串,则不需要执行本初始化。 * @param {boolean} [foldable=false] - 是否开启折叠功能 (默认关闭) * @param {boolean} [forced=false] - 是否开启强制模式 (默认关闭),即`EnableState`检查 */ initialize(foldable = false, forced = false) { Key.setSubType(this.subType); if (!forced && Store.get(Key.ENABLE_STATE_KEY) === EnableState.ALL_DISABLED) return; if (!this._loadData()) { this._setDefault(); this.isDefault = true; } this._resolveData(foldable); this.foldable = foldable && this.foldableJobs.size; } /** * 空缺设置,将关闭脚本的职位排序。 * 有两种独立开启途径: * 1. `EnableState = "allDisable"` * 2. `StaffMapListJSON = "[]"` */ isNull() { return this.data.length === 0; } /** 保存自定义的数据 */ saveData(jsonStr) { this.isDefault = false;; Store.set(Key.STAFF_MAP_LIST_KEY, jsonStr, this.subType); console.log(jsonStr); console.log(`${SCRIPT_NAME}:保存自定义 staffMapList 数据`); } /** 恢复默认数据的设置 */ resetData() { this.isDefault = true; Store.remove(Key.STAFF_MAP_LIST_KEY, this.subType); console.log(`${SCRIPT_NAME}:删除自定义 staffMapList 数据,恢复默认设置`); } /** 使用懒加载恢复默认配置 */ _setDefault() { if (!StaffMapList._defaultLazyData[this.subType]) this.data = []; // 该类型条目未有默认设置 else this.data = StaffMapList._defaultLazyData[this.subType](); } /** 尝试载入自定义的数据,并作初步解析 */ _loadData() { const jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, this.subType); if (!jsonStr) return null; // 键值为空,表示用户启用默认设置 let parsedData = StaffMapListJSON.parse(jsonStr); if (!parsedData) { // 通过UI进行的配置一般不可能发生 console.error( `${SCRIPT_NAME}:自定义 staffMapList 解析失败,将使用脚本默认的数据` ); return false; } /* 修复外层重复嵌套 `[]` 的形式,例如 [["", [true, ""], ""]] * 同时区分形如 [[true, "", ""]] 此类不需要降维的情形, * 忽略存在的漏洞:形如 [[true, "", [true, ""], ""]] 将无法降维 */ if ( parsedData.length === 1 && Array.isArray(parsedData[0]) && typeof parsedData[0][0] !== "boolean" ) { parsedData = parsedData[0]; } this.isDefault = false; this.data = parsedData; return true; } /** 完全解析数据,拆解为`jobOrder`与`foldableJobs` */ _resolveData(foldable) { foldable = foldable && Store.get(Key.ENABLE_STATE_KEY, this.subType) === EnableState.ALL_ENABLED; for (let item of this.data) { if (Array.isArray(item) && item.length) { // 对数组进行完全展平,提高对非标多层数组的兼容性 item = item.flat(Infinity); /* 对于标准格式,仅当 Boolean 为一级子序列的首元素时,对该子序列的全部元素生效 * 此时更广义的表述为,仅当 Boolean 为一级子序列的最左节点时,对该子序列的全部元素生效 */ if (typeof item[0] === "boolean") { // 可以使用 EnableState 仅启用排序,禁用折叠 if (item[0] && foldable) { item.forEach((value, index) => { if (index) this.foldableJobs.add(value) }); // 替代 this.foldableJobs.push(...item.slice(1)); } item.shift(); // 移除第一个元素,替代 slice(1) } this.jobOrder.push(...item); } else if (typeof item !== "undefined") { this.jobOrder.push(item); } } } /** * 将数据转化为格式化文本 (有别于`StaffMapListJSON`) * 用于设置内的显示与编辑,自定义数据与默认数据二者格式化有别 * @returns {string} 格式化文本 */ formatToText(useDefault) { let jsonStr = null; if (!useDefault) { jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, this.subType); this.isDefault = jsonStr === null; // useDefault 不能改变 isDefault } // 自定义数据 if (jsonStr) return jsonStr.slice(1, -1); // 消除首尾的 `[]` // 读取缓存的默认数据 else if (this._defaultTextBuffer) return this._defaultTextBuffer; // 将默认数据转化为格式化文本 this._setDefault(); const text = StaffMapListJSON.stringify(this.data) .replace(/(null,\n )|(\n\s+)/g, (match, g1, g2) => { if (g1) return "\n"; if (g2) return " "; return match; }) .slice(3, -2); // 消除首部 `[ \n` 与尾部 `\n]` // 使得 `[ `->`[` 同时 ` ]`->`]` /* const text = StaffMapListJSON.stringify(this.data).replace( /(null,)|(? { if (g1) return '\n'; if (g2) return ' '; if (g3) return '['; if (g4) return '],'; return match; }).slice(3, -2); */ this._defaultTextBuffer = text; return text; } } /** * 基类,职位排序的核心公共逻辑,拥有多个虚拟函数需子类实现。 * 可被拓展用于不同的场景:网页`infobox`职位信息、职位名称序列、API`infobox`职位信息 */ class BaseStaffSorter { /** @type {StaffMapList} 职位排序与折叠设置 */ staffMapList; /** @type {Object | Iterable} 原始数据,元素内需包含待匹配职位名称 */ rawData; /** @type {Set} 待匹配职位名称的集合 */ _setData; /** 排序的结果 */ sortedData; /** * 构造函数,子类可细化`rawData`的类型定义, * 并自行对其初始化,且需在其后调用`_initSetData`函数 */ constructor(staffMapList, rawData = null) { this.staffMapList = staffMapList; if (rawData) { this.rawData = rawData; // 接受其引用,不对其删改,改为操作 _setData this._initSetData(); } /** 未被匹配职位的待插入位置 */ this._afterInsert = null; /** 激活待插入位置 */ this._insertTag = false; /** 待插入信息的折叠状态 */ this._insertFold = false; } /** 依据`rawData`初始化`_setData` */ _initSetData() { if (typeof this.rawData === 'object') this._setData = new Set(Object.keys(this.rawData)); else this._setData = new Set(this.rawData); } /** * 进行匹配,可静态调用或者绑定实例对象。 * 绑定实例时,将调用`this._logRegexMatch`并修改`this._insertTag`与`this._insertFold` * @param {MatchJob} matcher - 匹配职位名称的字符串或正则表达式 * @param {Set | Map} data - 数据集合 * @returns {Array | null} 成功则返回数据序列,失败则返回空 */ static match(matcher, data) { const instance = this instanceof BaseStaffSorter ? this : null; const matchedJobs = []; // 1.正则匹配 if (matcher instanceof RegExp) { for (const job of data.keys()) if (matcher.test(job)) matchedJobs.push(job); // 替代 matchedJobs.push(...Object.keys(this.rawData).filter(key => item.test(key))); if (matchedJobs.length) { if (instance) instance._logRegexMatch(matcher, matchedJobs); return matchedJobs; } } else if (typeof matcher === 'string' && matcher) { // 2.精确匹配 if (data.has(matcher)) { matchedJobs.push(matcher); return matchedJobs; // 3.特殊关键字处理 } else if (matcher.startsWith('==') && instance) { // 激活待插入位置 instance._insertTag = true; instance._insertFold = this.staffMapList.foldableJobs.has(matcher); console.debug(`insertMatcher: "${matcher}", insetFold: ${instance._insertFold}`); } } return null; // 其余情形均忽略 (且对于意外类型不报错) } /** 进行匹配排序 */ sort() { for (const matcher of this.staffMapList.jobOrder) { // 进行匹配 const matchedJobs = BaseStaffSorter.match.call(this, matcher, this._setData); // 使用 call 绑定实例 if (!matchedJobs) continue; // 进行排序 for (const job of matchedJobs) { this._processMatchedJob(job, matcher); // 保存待插入位置 if (this._insertTag) { this._processSaveInsert(job, matcher); this._insertTag = false; } // 删除已被匹配排序的职位名称 this._setData.delete(job); } } if (this._setData.size === 0) return; // 将剩余未被匹配的职位按原顺序添加到待插入位置 this._processUnmatchedJobs(); // 进行相关记录 this._logUnmatch(Array.from(this._setData.keys())); if (this._afterInsert != null) this._logInsert(); } /** * 对该条被匹配的职位信息进行排序处理 * @param {string} job - 被匹配的职位名称 * @param {MatchJob} matcher - 匹配职位名称的字符串或正则表达式 */ _processMatchedJob(job, matcher) { throw new Error('VirtualMethod'), job, matcher } /** 保存未被匹配的职位信息的待插入位置 */ _processSaveInsert(job) { throw new Error('VirtualMethod'), job } /** 对该条未被匹配的职位信息进行处理 */ _processUnmatchedJobs() { // for (const job of this._setData.keys()) if (this._afterInsert) { job } else { job } throw new Error('VirtualMethod'); } /** * 记录被正则匹配的职位信息 * @param {RegExp} reg * @param {Array} matchedJobs */ _logRegexMatch(reg, matchedJobs) { console.log(`${SCRIPT_NAME}:使用正则表达式 ${reg} 成功匹配 {${matchedJobs}}`); } /** * 记录未被匹配的职位信息 * @param {Array} unmatchedJobs */ _logUnmatch(unmatchedJobs) { console.log(`${SCRIPT_NAME}:未被匹配到的职位 {${unmatchedJobs}}`); } /** 记录插入信息 */ _logInsert() { console.log(`${SCRIPT_NAME}:激活将未被匹配职位插入指定位置`); } } /** * 实现网页`infobox`职位信息的排序与折叠, * `sub_group`及属其所有的`sub_container`将被视为一个整体进行排序 */ class HtmlStaffSorter extends BaseStaffSorter { /** @type {Object>} 原始职位信息字典 */ rawData; /** * @param {HTMLElement} ul - `infobox` * @param {StaffMapList} staffMapList - 职位排序与折叠设置 * @param {Object>} staffDict - 职位信息字典 */ constructor(ul, staffMapList, staffDict) { super(staffMapList); this.rawData = staffDict; this._initSetData(); /** `infobox` */ this.ul = ul; } _processMatchedJob(job, matcher) { const li = this.rawData[job]; // sub_group 及属其所有的 sub_container 组成的序列 if (Array.isArray(li)) { this.ul.append(...li); lastGroup = li; // 普通职位信息 } else { if (this.staffMapList.foldable && this.staffMapList.foldableJobs.has(matcher)) { if (!hasFolded) hasFolded = true; li.classList.add('folded', 'foldable'); } this.ul.appendChild(li); } } _processSaveInsert(job) { const li = this.rawData[job]; this._afterInsert = Array.isArray(li) ? li[0] : li; } _processUnmatchedJobs() { for (const job of this._setData.keys()) { const li = this.rawData[job]; const isGroup = Array.isArray(li); if (isGroup) lastGroup = li; else if (this._insertFold) { if (!hasFolded) hasFolded = true; li.classList.add('folded', 'foldable'); } if (this._afterInsert) { if (isGroup) li.forEach(node => this.ul.insertBefore(node, this._afterInsert)); else this.ul.insertBefore(li, this._afterInsert); } else { // 未设置待插入位置,则默认插入到末尾,且默认不折叠 if (isGroup) this.ul.append(...li); else this.ul.appendChild(li); } } } _logInsert() { console.debug(`liAfterInsert: ${this._afterInsert.innerText}`); super._logInsert(); } } /** * 实现接口传递来的职位信息的排序 */ class InterfaceStaffSorter extends BaseStaffSorter { /** * @typedef {string[] | { key: string; [key: string]: any }[]} AppData * @type {AppData} 包含职位名称的原始序列 * 当序列元素的类型为对象时,规定`key`的键值存储职位名称 */ rawData; /** * @type {Map} * 为了输出排序索引,将`Set`类型升格为`Map`类型 * (基类除`_initSetData`方法外,均可完全兼容) */ _setData; /** @type {string[]} 原始序列类型的排序结果 */ sortedData; /** @type {number[]} 排序结果的索引 */ sortedIndex; /** * @param {StaffMapList} staffMapList - 职位排序与折叠设置 * @param {string[] | { key: string; [key: string]: any }[]} rawData - 原始数据 */ constructor(staffMapList, rawData) { super(staffMapList); this.rawData = rawData; this._initSetData(); this.sortedData = []; this.sortedIndex = []; } /** 依据`rawData`初始化`_setData`,并对其做合法性检验 */ _initSetData() { if (!Array.isArray(this.rawData) || !this.rawData.length) throw new Error('传入接口的数据类型应为非空数组'); this._setData = new Map(); for (const [index, item] of this.rawData.entries()) { if (typeof item === 'string') { this._setData.set(item, index); } else if (typeof item === 'object' && typeof item.key === 'string') { this._setData.set(item.key, index); } else { throw new Error(`传入接口的数据的数组元素 ${JSON.stringify(item)} 类型不符合规范`); } } } _processMatchedJob(job) { const index = this._setData.get(job); this.sortedData.push(this.rawData[index]); this.sortedIndex.push(index); } _processSaveInsert(job) { this._afterInsert = this.sortedData.length - 1; console.debug(`IndexAfterInsert: ${this._afterInsert}, job: ${job}`); } _processUnmatchedJobs() { const numatchedIndex = Array.from(this._setData.keys()).map((job) => this._setData.get(job)); const numatchedData = numatchedIndex.map((index) => this.rawData[index]); if (this._afterInsert != null) { const start = this._afterInsert; this.sortedData.splice(start, 0, ...numatchedData) this.sortedIndex.splice(start, 0, ...numatchedIndex) } else { this.sortedData.push(...numatchedData); this.sortedIndex.push(...numatchedIndex) } } _logUnmatch(unmatchedJobs) { console.debug(`unmatchedJobs: {${unmatchedJobs}}`); } _logRegexMatch() {} _logInsert() {} } /** * 实现基于`localStorage`的异步通信接口,并对传入的数据进行排序。 * 有两种工作模式: * 1. 非设置模式,后于主任务异步执行一次 (`@match`所匹配的所有页面均首先开启该模式) * 2. 设置模式,伴随事件监听器同步执行 (仅在设置页面发生) */ class SortingInterface { /** 共享注册表的键名 */ static registerKey = Key.makeInterfaceKey(Key.SHARED_REGISTER); /** 共享注册表上锁的键名 */ // static lockKey = Key.makeInterfaceKey(Key.LOCK_KEY); /** 是否为设置模式 */ static settingMode = false; /** @type {Object} 分组任务队列,以`SubjectType`为组别名 */ static _tasksByType = {}; /** 缓存应用传递的有效数据,仅在设置模式中开启 */ static _appCache = {}; /** 注册表应用计数器 */ static appCount = 0; /** 有效应用的有效更新次数计数器 */ static validAppCount = 0; /** 初始化,仅检查`sharedRegister`是否缺损或被污染 */ static initialize() { SortingInterface._tasksByType = {}; SortingInterface._appCache = {}; SortingInterface._parseRegister(); } /** 非设置模式下,单次异步执行接口任务 */ static runAsyncTask() { setTimeout(() => { SortingInterface._processRegister(); console.debug("tasksByType:", SortingInterface._tasksByType); SubjectType.getAll().forEach((type) => { SortingInterface.processTask(type, null, Store.updateRequired); }); if (SortingInterface.appCount) { console.log( `${INTERFACE_NAME}:共发现 ${SortingInterface.appCount} 个接入应用,` + `并执行 ${SortingInterface.validAppCount} 次有效更新任务` ); } }, INTERFACE_DELAY); } /** * 处理该条目类型的接口任务 * @param {StaffMapList} [staffMapList=null] * - 在设置模式下其由`StaffMapListEditor`传入,非设置模式下则自行定义 * @param {boolean} [forced=false] * - 在设置模式下或版本更新下将为强制,不对原有的排序结果状态进行检查,强制写入 */ static processTask(type, staffMapList = null, forced = false) { const tasks = SortingInterface._tasksByType[type]; if (!tasks) return; staffMapList ??= new StaffMapList(type); // 任何模式均需初始化,使其加载当前最新数据,并强制激活排序能力 staffMapList.initialize(false, true); for (const appName of tasks) { const appKey = Key.makeInterfaceKey(appName, type); let appValue = null; // 设置模式下尝试读取缓存 if (SortingInterface.settingMode && appKey in SortingInterface._appCache) { appValue = SortingInterface._appCache[appKey]; // console.debug(`Hit cache: ${appKey}`, appValue); } else { appValue = SortingInterface._parseAppValue(appKey); if (SortingInterface.settingMode) SortingInterface._appCache[appKey] = appValue; } if (!appValue) continue; // 判断有无更新必要 if (!forced && SortingInterface._parseSortedValue(appKey, appValue.version)) continue; try { // 尝试进行更新 const sorted = SortingInterface.calcSortedValue(appValue, staffMapList); localStorage.setItem(`${appKey}_sorted`, JSON.stringify(sorted)); console.log(`${INTERFACE_NAME}:${appKey}_sorted 数据更新`, sorted); SortingInterface.validAppCount++; } catch (e) { SortingInterface._appCache[appKey] = null; // 剔除数据 console.error(`${INTERFACE_NAME}:${appKey}.data: 解析失败 - ${e}`); } } } /** * 进行排序并包装结果 * @param {{ data: AppData, version: any }} appValue * @param {StaffMapList} staffMapList * @returns {{ data: AppData, index: number[], version: any }} */ static calcSortedValue(appValue, staffMapList) { if (staffMapList.isNull()) { // staffMapList 空缺设置,即原排序顺序 const size = appValue.data.length; return { data: appValue.data, index: Array.from({ length: size }, (_, i) => i), version: appValue.version, }; } const sorter = new InterfaceStaffSorter(staffMapList, appValue.data); sorter.sort(); return { data: sorter.sortedData, index: sorter.sortedIndex, version: appValue.version, }; } /** 解析原有的排序结果,判断是否有更新的必要 */ static _parseSortedValue(appKey, preVer) { const value = localStorage.getItem(`${appKey}_sorted`); if (value === null) return false; // 新的应用任务 try { const sortedValue = JSON.parse(value); if (sortedValue.version === preVer) return true; // 当且仅当版本号完全相同时 else return false; } catch { return false; } } /** * 解析应用传递的数值,在设置模式中将进行缓存,形如 * `{'data': AppData, 'version': '1.0'}` * 对`AppData`的详细解析将在`InterfaceStaffSorter`中进行 * @returns {{data: AppData, version: any} | null} */ static _parseAppValue(appKey) { const value = localStorage.getItem(appKey); if (value === null) { console.error(`${INTERFACE_NAME}:${appKey} 键值为空`); return null; } let appValue; try { appValue = JSON.parse(value); } catch (e) { console.error(`${INTERFACE_NAME}:${appKey}: ${value} 解析失败 - ${e}`); return null; } if (typeof appValue !== "object") { console.error(`${INTERFACE_NAME}:${appKey}: ${value} 非对象类型`); return null; } else if (!appValue.data || !appValue.version) { console.error( `${INTERFACE_NAME}:${appKey}: ${value} 缺失'data'或'version'的有效键值` ); return null; } return appValue; } /** * 拆解共享的应用注册表信息为分组任务队列 * 将 {A:a, B:a, C:b} => {a:[A,B], b:[C]} * 同时检查`SubjectType`是否有效 */ static _processRegister() { // SortingInterface.acquireLock(); // 上锁 const register = SortingInterface._parseRegister(); // SortingInterface.releaseLock(); // 解锁 if (!register) return; SortingInterface._tasksByType = Object.entries(register).reduce( (acc, [key, value]) => { SortingInterface.appCount++; if (!SubjectType.parse(value)) { console.error(`${INTERFACE_NAME}:${Key.SHARED_REGISTER}.${key}: ${value} 写入的条目类型无效,` + `不予处理,类型应为 {${SubjectType.getAll()}} 之一`); return acc; } if (!acc[value]) acc[value] = []; acc[value].push(key); return acc; }, {} ); } /** * 初步解析共享的应用注册表,确保其是可读可写的 * 注册表形如 {'app01': 'music', 'app02': 'anime'} */ static _parseRegister() { const value = localStorage.getItem(SortingInterface.registerKey); if (value === null) return SortingInterface._resetRegister(); let register; try { register = JSON.parse(value); } catch (e) { console.error( `${INTERFACE_NAME}:${Key.SHARED_REGISTER}: ${value} 解析失败,可能遭到污染,将进行重置 - ${e}` ); return SortingInterface._resetRegister(); } if (typeof register !== "object") { console.error( `${INTERFACE_NAME}:${Key.SHARED_REGISTER}: ${value} 非对象类型,可能遭到污染,将进行重置` ); return SortingInterface._resetRegister(); } return register; } /** 重置注册表 */ static _resetRegister() { localStorage.setItem(SortingInterface.registerKey, "{}"); console.log(`${INTERFACE_NAME}:${Key.SHARED_REGISTER} 初始化`); return null; } /* 上锁机制,暂不启用 // `lockKey = 'BangumiStaffSortingInterface__lock__'` static acquireLock() { const now = Date.now(); if (SortingInterface.getLockState(now)) return; // 如果锁被占用,使用 setTimeout 延迟检查 return new Promise((resolve) => { function checkLock() { if (SortingInterface.getLockState(now)) resolve(true); else setTimeout(checkLock, 100); } checkLock(); }); } static getLockState(now) { const lockTimestamp = localStorage.getItem(SortingInterface.lockKey); // 检查是否可以获得锁,如果 lockTimestamp 不存在或超时 if (!lockTimestamp || now - parseInt(lockTimestamp) > LOCK_TIMEOUT) { localStorage.setItem(SortingInterface.lockKey, now.toString()); return true; } else return false; } static releaseLock() { localStorage.removeItem(SortingInterface.lockKey); } */ } /** 匹配相应 URL 类型的函数入口 */ const urlPatterns = [ { type: 'subject', regex: /^\/subject\/\d+$/, handler: handlerSubject }, { type: 'character', regex: /^\/character\/\d+$/, handler: handlerSubject }, { type: 'person', regex: /^\/person\/\d+$/, handler: handlerSubject }, { type: 'settings', regex: /^\/settings\/privacy$/, handler: handlerSettings }, ]; /** 主函数入口 */ function main() { Store.initialize(); SortingInterface.initialize(); let patternType = null; for (const pattern of urlPatterns) { if (pattern.regex.test(pathname)) { patternType = pattern.type; pattern.handler(patternType); break; } } SortingInterface.runAsyncTask(); } /** 处理设置 */ function handlerSettings() { SortingInterface.settingMode = true; // 开启接口的设置模式 const ui = buildSettingUI({ id: 'staff_sorting' }); document.getElementById('columnA').appendChild(ui); loadSettingStyle(); // 支持 url.hash = ID 进行导引 if (location.hash.slice(1) === 'staff_sorting') { ui.scrollIntoView({ behavior: 'smooth' }); } } /** 处理条目 */ function handlerSubject(subType) { if (SubjectType.needPrase(subType)) subType = SubjectType.parse(getSubjectType()); if (!subType) return; // 不支持该类型条目 const ul = document.querySelector('#infobox'); const staffMapList = new StaffMapList(subType); staffMapList.initialize(true); if (!staffMapList.isNull()) { // 实行自定义的职位顺序 const staffDict = getStaffDict(ul); // 延迟执行,提高对修改 infobox 信息的其他脚本的兼容性 setTimeout(() => { const sorter = new HtmlStaffSorter(ul, staffMapList, staffDict); sorter.sort(); // 依赖 sortStaff 解析得到的数据 dealLastGroup(ul); changeExpandToToggleButton(ul); }, SORTING_DELAY); } else { // 实行网页原有的职位顺序 addFoldableTag(ul); dealLastGroup(ul); changeExpandToToggleButton(ul); console.log(`${SCRIPT_NAME}:实行网页原有的职位顺序`); } loadStaffStyle(); addRefoldToggleButton(ul); } /** * 巧妙地使用非常便捷的方法,获取当前条目的类型 * 源自 https://bangumi.tv/dev/app/2723/gadget/1242 */ function getSubjectType() { const href = document.querySelector("#navMenuNeue .focus").getAttribute("href"); return href.split("/")[1]; } /** * 获取一个对象来存储网页中的职位信息。 * 并对职位信息进行二次折叠, * 同时将`sub_group`及属其所有的`sub_container`打包为一个序列作为字典的键值 * @param {HTMLElement} ul - `infobox` * @returns {Object>} 返回职位信息字典,键值为`DOM`或者`DOM`序列 */ function getStaffDict(ul) { const staffDict = {}; const lis = ul.querySelectorAll(":scope > li"); lis.forEach((li) => { const tip = li.querySelector("span.tip"); if (!tip) return; let job = tip.innerText.trim().slice(0, -1); // 去掉最后的冒号 if (li.classList.contains("sub_group")) { // 新的小组 staffDict[job] = [li]; } else if (li.classList.contains("sub_container") && li.hasAttribute("attr-info-group")) { // 整合进组 job = li.getAttribute("attr-info-group"); if (staffDict[job]) staffDict[job].push(li); else staffDict[job] = [li]; } else { // 普通元素 staffDict[job] = li; // 为了正确计算元素高度,需使其 display li.classList.remove("folded"); refoldStaff(li, tip); // li.folded 属性已经失效无需还原 } }); return staffDict; } /** * 为网页原有的`folded`类别添加`foldable`便签,用于实现切换, * 忽略属于`sub_group`的`sub_container`, * 并对职位信息进行二次折叠 * @param {HTMLElement} ul - `infobox` */ function addFoldableTag(ul) { const lis = ul.querySelectorAll(':scope > li'); lis.forEach(li => { const flag = li.classList.contains('folded') && !li.hasAttribute("attr-info-group"); if (flag) { if (!hasFolded) hasFolded = true; // 为了正确计算元素高度,需先使其 display li.classList.remove('folded'); } const tip = li.querySelector('span.tip'); if (tip) refoldStaff(li, tip); /* 特殊用法 StaffMapListJSON = "[]" 同时 EnableState = "partialDisable" * 将实行网页原有的职位顺序,同时禁止其折叠 */ if (flag && Store.get(Key.ENABLE_STATE_KEY) !== EnableState.PARTIAL_ENABLED) li.classList.add('folded', 'foldable'); // 获取 lastGroup if (li.classList.contains("sub_group")) lastGroup = [li]; else if (li.classList.contains("sub_container") && li.hasAttribute("attr-info-group")) lastGroup.push(li); }); if (Store.get(Key.ENABLE_STATE_KEY) === EnableState.PARTIAL_ENABLED) hasFolded = false; } /** * 对超出限制行数的职位信息进行二次折叠,并添加开关。 * 实现动态不定摘要的类似于`summary`的功能。 * 过滤`别名`等不定行高的`infobox`信息 * @param {HTMLElement} li - 职位信息根节点 * @param {HTMLElement} tip - 职位名称节点 */ function refoldStaff(li, tip) { if (Store.get(Key.REFOLD_THRESHOLD_KEY) === Key.REFOLD_THRESHOLD_DISABLED) return; if (li.classList.contains('sub_container') || li.classList.contains('sub_group')) return; // 过滤不定行高的 infobox 信息 if (!JobStyle.compStyle) JobStyle.initialize(li); const lineCnt = getLineCnt(li); const refoldThr = Store.get(Key.REFOLD_THRESHOLD_KEY); if (lineCnt <= refoldThr) return; // 添加二次折叠效果 (样式将在随后通过 loadStaffStyle 动态载入) li.classList.add('refoldable', 'refolded'); // const nest = nestElementWithChildren(li, 'div', {class: 'refoldable refolded'}); /* 尝试不修改 DOM 结构仅通过添加样式达到完备的折叠效果, * 难点在于处理溢出到 li.padding-bottom 区域的信息 * 最终通过施加多层遮蔽效果实现,故不再需要内嵌一层新的 div 元素 */ // 添加头部开关状态图标 const prefIcon = createElement('i', { class: 'staff_sorting_icon' }); prefIcon.innerHTML = ICON.TRIANGLE_RIGHT; /* 尝试使用模板或直接使用JS构建实例的方法均失败... * 最终改为直接修改innerHTML */ updateSubElements(tip, prefIcon, 'prepend'); tip.classList.add('switch'); // 添加尾部折叠图标 const suffIcon = createElement('i', { class: 'staff_sorting_icon' }); const sideTip = createElement('span', {class: 'tip side'}, suffIcon); suffIcon.innerHTML = ICON.TRIANGLE_UP; li.appendChild(sideTip); // 记录被折叠的行数,由于 span{clear: right} 防止其换行,需先渲染并重新计算行数 const refoldLine = getLineCnt(li) - refoldThr; sideTipLineThr ??= getSideTipThr(); // 小于阈值的将被隐藏 if (refoldLine >= sideTipLineThr) sideTip.dataset.refoldLine = refoldLine; // else delete sideTip.dataset.refoldLine; } /** * 为二次折叠按钮绑定开关事件, * 采用`事件委托`形式绑定事件 (事件冒泡机制) * @param {HTMLElement} ul - `infobox` */ function addRefoldToggleButton(ul) { if (Store.get(Key.REFOLD_THRESHOLD_KEY) === 0) return; /* 检查点击的元素是否是开关本身 span 或其子元素 icon * 使用 .closest('.cls') 替代 classList.contains('cls') * 使得子元素也能响应点击事件 */ ul.addEventListener('click', (event) => { /** @type {HTMLElement} 被点击的目标 */ const target = event.target; // 1. 首部开关 const prefTip = target.closest('.switch'); if (prefTip && ul.contains(prefTip)){ // 职位名称或开关状态图标被点击了 const parent = prefTip.parentElement; if (parent.classList.contains('refolded')) { parent.classList.remove('refolded'); prefTip.firstChild.innerHTML = ICON.TRIANGLE_DOWN; } else { parent.classList.add('refolded'); prefTip.firstChild.innerHTML = ICON.TRIANGLE_RIGHT; } return; } // 2. 尾部开关 const suffTip = target.closest('.side'); if (!suffTip || !ul.contains(suffTip)) return; const li = suffTip.parentElement; // 滚轮将自动上移被折叠的距离,以确保折叠后的内容不会让用户迷失上下文 const rectBefore = li.getBoundingClientRect(); // 更改折叠状态 li.classList.add('refolded'); // 等待下一帧,让浏览器完成渲染 requestAnimationFrame(() => { const rectAfter = li.getBoundingClientRect(); /* 尝试通过 suffTip.dataset.refoldLine 计算高度变化 * 会与理想值有 ~0.5px 的随机偏差,故改用获取元素窗口的高度变化 */ const distance = rectAfter.top - rectBefore.top + rectAfter.height - rectBefore.height; // console.debug( `\n` + // `heightBefore: \t${rectBefore.height},\nheightAfter: \t${rectAfter.height},\n` + // `topAfter: \t${rectAfter.top},\ntopBefore: \t${rectBefore.top},\ndistance: \t${distance},\n` + // `byRefoldLine: \t${suffTip.dataset.refoldLine * JobStyle.lineHeight}` // ); /* 需考虑 li.top 的前后变化,且不要使用 scrollTo * 因为部分浏览器对于超出视口的 li 元素进行折叠时,会自主进行防迷失优化, * 此时 distance 的计算机结果将会是 0 */ window.scrollBy({ top: distance, behavior: 'instant' }); }); // 修改首部开关的图标 li.firstChild.firstChild.innerHTML = ICON.TRIANGLE_RIGHT; }); /* 在 mousedown 阶段阻止用户拖动或双击时的默认选中行为。 * 由于 span.switch 本质仍然是内容段落的一部分, * 不通过 user-select: none 这钟粗暴的方法禁止用户的一切选中行为 * 而是采用温和的方法阻止部分情形下对该区域的选中行为 */ ul.addEventListener('mousedown', (event) => { if (event.target.closest('.switch')) event.preventDefault(); }); } /** * 处理最后一组`sub_group`,若为`infobox`末尾元素,则为其添加标签。 * 以优化样式,当其非末尾元素时,添加边界以区分`sub_container > li`与普通`li` * @param {HTMLElement} ul - `infobox` */ function dealLastGroup(ul) { if (!lastGroup || ul.lastElementChild !== lastGroup[lastGroup.length - 1]) return; lastGroup.forEach((li) => { if (li.classList.contains("sub_container")) li.classList.add('last_group'); }) } /** * 获取固定行高`#infobox.li`元素显示的行数 * 经测试,职员信息除了`8px`的`padding`还有`<1px`的`border`因为不影响行数计算忽略 */ function getLineCnt(el, padding = 8, border = 0) { const height = el.getBoundingClientRect().height - padding - border; return ~~(height / JobStyle.lineHeight); } /** * 根据页面视口高度,计算尾部折叠图标的激活行数阈值 * 对于二次折叠区域较小,不予显示 */ function getSideTipThr() { const threshold = ~~(getViewportHeight() / JobStyle.lineHeight * sideTipRate); console.log(`${SCRIPT_NAME}:sideTipLineThreshold:${threshold}`); return threshold; } /** * 将原本存在的`更多制作人员+`一次性按钮,转绑新事件,并改为永久性开关 * 使用网页原有的`folded`元素类别,实现对立于`sortStaff`功能 * 添加不存在的`更多制作人员+`按钮,否则一些职位信息将永不可见 * @param {HTMLElement} ul - `infobox` */ function changeExpandToToggleButton(ul) { const buttonValue = { on: '更多制作人员 +', off: '更多制作人员 -' }; let moreLink = document.querySelector('#infobox + .infobox_expand a'); // 无法实现 :scope + if (!hasFolded) { // 无必要,不进行事件绑定与可能的添加,并将原有的开关隐藏 if (moreLink) { moreLink.style.display = 'none'; console.log(`${SCRIPT_NAME} - 将原有的 '${buttonValue.on}' 隐藏`); } return; } if (!moreLink) { moreLink = createElement('a', { href: 'javascript:void(0)' }, buttonValue.on); const expand = createElement('div', { class: 'infobox_expand' }, [moreLink]); ul.parentElement.appendChild(expand); console.log(`${SCRIPT_NAME}:添加原不存在的 '${buttonValue.on}' 按钮`); } moreLink.addEventListener('click', function (event) { event.stopImmediatePropagation(); // 阻止其他事件的触发 const foldedLis = document.querySelectorAll('.foldable'); const isHidden = moreLink.innerText == buttonValue.on; foldedLis.forEach(li => { if (isHidden) li.classList.remove('folded'); else li.classList.add('folded'); }); moreLink.innerText = isHidden ? buttonValue.off : buttonValue.on; }, { capture: true }); // 使事件处理函数在捕获阶段运行 } /** * 创建用户设置`UI`界面 * 仿照`#columnA`中的同类元素进行构建,使用原有的结构与样式

条目职位排序 · 默认折叠的职位

*/ function buildSettingUI(mainStyle) { const mainTitle = createElement('tr', null, [ createElement('td', null, [ createElement('h2', { class: 'subtitle' }, '条目职位排序 · 默认折叠的职位') ]) ]); const lineLimitBlock = buildLineLimitBlock(); const subjectBlocks = SubjectType.getAll(true).map(sub => buildSubjectBlock(sub)); const ui = createElement('div', mainStyle, [ createElement('table', { class: 'settings' }, [ createElement('tbody', null, [ mainTitle, lineLimitBlock, ...subjectBlocks ]) ]) ]); return ui; } /** * 创建职位信息二次折叠的行高限制设置界面

职位信息高度 限制

...
...
*/ function buildLineLimitBlock() { const subTitle = createElement('h2', { class: 'subtitle' }, '职位信息高度 限制'); // 搭建滑动开关 const [toggle, toggleCntr] = buildToggleSlider('refold_switch'); // 搭建整数步进输入器 const intInput = new IntInputStepper('refold_threshold_input', '行数'); intInput.build(); // 搭建外部框架 const block = createElement('tr', null, [ createElement('td', { class: 'line_limit_block' }, [ subTitle, createElement('div', {class: 'right_inline'}, [ intInput.root, toggleCntr ]) ]) ]); // 初始化 (此处无需关心Key._subType) toggle.checked = Store.get(Key.REFOLD_THRESHOLD_KEY) !== Key.REFOLD_THRESHOLD_DISABLED; intInput.num = Store.get(Key.REFOLD_THRESHOLD_KEY); if (!toggle.checked) intInput.display = false; // 绑定事件 function setRefloadThreshold(num) { // 与缓存进行对比,防止无效写入 if (num === Store.get(Key.REFOLD_THRESHOLD_KEY)) return; Store.set(Key.REFOLD_THRESHOLD_KEY, num, null, true); } toggle.addEventListener('click', () => { if (toggle.checked) { intInput.display = true; setRefloadThreshold(intInput.num); // 使用 DOM 中可能的暂存数据 } else { intInput.display = false; setRefloadThreshold(Key.REFOLD_THRESHOLD_DISABLED); } }); intInput.onNumChange = setRefloadThreshold; return block; } /** * 创建`staffMapList`文本内容编辑界面 * 对于`textarea`,`button`等控件仍然使用原有的结构与样式

...
...
*/ function buildSubjectBlock(subTypeObj) { const subType = subTypeObj.en; // 搭建标题 const subTitle = createElement('h2', { class: 'subtitle' }); // 搭建滑动开关 const selector = new TriStateSlider(`${subTypeObj.en}_subject_enable`); const selectorMsgBox = createElement('p', { class: 'tip_j' }); const selectorField = createElement('div', {class: 'right_inline hidden'}, [ selectorMsgBox, selector.root ]); selector.build(); // 定义编辑器,暂不构建 const editor = new StaffMapListEditor(subTypeObj.en); // 搭建展开容器 const detail = createElement('details', null, [ createElement('summary', null, [ subTitle, selectorField ]), editor.root ]) // 搭建外部结构 const block = createElement('tr', null, [ createElement('td', {class: 'subject_staff_block'}, detail) ]); // 初始化 subTitle.textContent = `${subTypeObj.zh}条目`; detail.open = Store.get(Key.BLOCK_OPEN_KEY, subType); selector.state = Store.get(Key.ENABLE_STATE_KEY, subType); setSelectorMsgBox(selector.state); blockOnOpen(); // 绑定事件 selector.onStateChange = (newState) => { setSelectorMsgBox(newState); Store.set(Key.ENABLE_STATE_KEY, newState, subType, true) }; detail.addEventListener('toggle', blockOnOpen); // 无需上下文环境 return block; function setSelectorMsgBox(state) { switch (state) { case EnableState.ALL_DISABLED: setMessage(selectorMsgBox, '禁用设置,但仍可编辑保存'); break; case EnableState.PARTIAL_ENABLED: setMessage(selectorMsgBox, '仅启用排序,禁用折叠'); break; case EnableState.ALL_ENABLED: setMessage(selectorMsgBox, '启用自定义 / 默认设置'); break; } } function blockOnOpen() { if (detail.open) { if (!editor.built) editor.build(); // 在第一次展开时构建 selectorField.classList.remove('hidden'); } else { selectorField.classList.add('hidden'); } Store.set(Key.BLOCK_OPEN_KEY, detail.open, subType, true); } } /** * `staffMapList`编辑器,并对数据进行自主管理

*/ class StaffMapListEditor { static _editorCls = 'staffMapList_editor'; constructor(subType) { this.subType = subType; this.staffMapList = new StaffMapList(subType); this.root = createElement('div', { class: StaffMapListEditor._editorCls }); this.textArea = null; // 输入文本框 this.resetBtn = null; // 提交按钮 this.submitBtn = null; // 重置按钮 this.editorMsgBox = null; // 简易提示框 this.isDefault = null; // 标记是否为默认数据 this.hasInputed = null; // 文本框内容是否被改变且未被保存 this.built = false; // 标记是否已经初始化 } build() { if (this.built) return; // 防止重复构建 // 构建元素结构 this.textArea = createElement('textarea', { class: 'quick markItUpEditor hasEditor codeHighlight', name: 'staff_map_list' }); this.submitBtn = createElement('input', { class: 'inputBtn', type: 'submit', name: 'submit_context', value: '保存' }); this.resetBtn = createElement('input', { class: 'inputBtn', type: 'submit', name: 'reset_context', value: '恢复默认' }); this.editorMsgBox = createElement('p', { class: 'tip_j'}); this.root.append( createElement('div', { class: 'markItUp' }, this.textArea), createElement('div', null, [this.submitBtn, this.resetBtn, this.editorMsgBox]) ); // 初始化状态 const text = this.staffMapList.formatToText(false); this.textArea.value = text; this.isDefault = this.staffMapList.isDefault; this.hasInputed = false; if (text.trim() === "") setMessage(this.editorMsgBox, '现为设置空缺', 0); // 网页实行原有的职位顺序与折叠 else if (this.isDefault) setMessage(this.editorMsgBox, '现为默认设置', 0); // 初始化时,提醒用户已为默认设置 else setMessage(this.editorMsgBox, '现为自定义设置', 0); // 绑定事件 this.textArea.addEventListener('input', this._onInput.bind(this)); this.resetBtn.addEventListener('click', this._onReset.bind(this)); this.submitBtn.addEventListener('click', this._onSubmit.bind(this)); this.built = true; } _onInput() { if (this.isDefault) this.isDefault = false; if (!this.hasInputed) this.hasInputed = true; // console.debug("IS INPUTTING"); } async _onReset() { if (this.isDefault) return setMessage(this.editorMsgBox, '已为默认内容'); await trySetText( this.textArea, this.editorMsgBox, this.staffMapList.formatToText(true), '已恢复默认内容', false ); // 需进行同步等待,由于 setText 可能会触发 input 事件 this.isDefault = true; this.hasInputed = false; } async _onSubmit() { // 判断是否为重置后未对默认内容进行修改 if (this.isDefault) { if (this.staffMapList.isDefault) { setMessage(this.editorMsgBox, '已为默认设置'); } else { // 由自定义改为默认设置 this.staffMapList.resetData(); setMessage(this.editorMsgBox, '保存成功!恢复默认设置'); // 对该条目类型的接口任务进行处理 SortingInterface.processTask(this.subType, this.staffMapList, true); } this.hasInputed = false; return; } if (!this.hasInputed) { setMessage(this.editorMsgBox, '未作修改'); return; } const [modifiedData, isModified, curCursorPos] = StaffMapListEditor.modifyText(this.textArea); // 强制将用户输入的文本外层嵌套 `[]`,若为重复嵌套可在 loadMapList 中识别并去除 const savedDate = `[${modifiedData}]`; const parsedData = StaffMapListJSON.parse(savedDate); // 数据解析失败 if (!parsedData) return setMessage(this.editorMsgBox, '保存失败!格式存在错误'); // 保存数据 this.staffMapList.saveData(savedDate); // 页面显示 if (modifiedData.trim() === "") setMessage(this.editorMsgBox, '保存成功!空缺设置'); else if (isModified) { await trySetText( this.textArea, this.editorMsgBox, modifiedData, '保存成功!并自动纠错', true, curCursorPos ); } else setMessage(this.editorMsgBox, '保存成功!'); // 对该条目类型的接口任务进行处理 SortingInterface.processTask(this.subType, this.staffMapList, true); this.hasInputed = false; } /** * 对用户输入可能的常见语法与格式错误,进行自动纠错,以满足`JSON`格式 * 并计算文本修改后,光标的适宜位置 * 已基本兼容`JavaScript`格式的文本数据,实现格式转化 * `group2`与`group4`致使正则表达式中不允许出现`/'"`三种字符 */ static modifyText(textArea) { const preCursorPos = getTextAreaPos(textArea).cursorPos; let curCursorPos = preCursorPos; let flags = new Array(6).fill(false); const rslt = textArea.value.replace( /(,\s*)+(?=]|$)|(?<=\[|^)(\s*,)+|(,\s*)+(?=,)|(['‘’“”])|(? { isTriggered(0, '删除序列末尾元素后的 `,` 逗号', g1); isTriggered(1, '删除序列首位元素前的 `,` 逗号', g2); isTriggered(2, '删除连续重复的 `,` 逗号', g3); isTriggered(3, '将非半角单引号的引号替换', g4); isTriggered(4, '将正则表达式以双引号包裹', g5); isTriggered(5, '将全角逗号顿号变为半角逗号', g6); if (booleanOr(g1, g2, g3)) { let diff = preCursorPos - offset; if (diff > 0) curCursorPos -= Math.min(diff, match.length); return ''; } if (g4) return '"'; if (g5) { if (offset < preCursorPos && preCursorPos < offset + match.length) curCursorPos += 1; else if (preCursorPos >= offset + match.length) curCursorPos += 2; return `"${match}"`; } if (g6) return ','; return match; }); return [rslt, booleanOr(...flags), curCursorPos]; function isTriggered(index, msg, ...groups) { if (!flags[index] && booleanOr(...groups)) { console.log(`${SCRIPT_NAME}:触发自动纠错 - ${msg}`); flags[index] = true; } } function booleanOr(...values) { return values.reduce((acc, val) => acc || val, false); } } } /** * 整数步进输入器, * 不使用`input.type: 'number'`而是自我搭建相关控制
行数
...
...
*/ class IntInputStepper { static default = Key.REFOLD_THRESHOLD_DEFAULT; // 所用样式的类名 static _fieldCls = 'num_input_cntr'; static _inputCls = 'inputtext input_num'; static _ctrsCls = 'num_ctrs'; /** * @type {(newNum: int) => void | null} * 回调函数,当数据变化时被调用 */ onNumChange = null; constructor(id, labelName, initNum = IntInputStepper.default) { this.root = createElement('fieldset', { class: IntInputStepper._fieldCls }); this.numInput = null; this.incBtn = null; this.decBtn = null; this.id = id; this.labelName = labelName; this.initNum = initNum; this.minNum = {int: 1, str: '1'}; this.maxDigits = 2; } set num(num) { if(!num) num = IntInputStepper.default; this.numInput.value = String(num); } get num() { return Number(this.numInput.value); } /** @param {boolean} flag */ set display(flag) { this.root.style.display = flag ? 'flex' : 'none'; } build() { // 构建元素结构 this.numInput = createElement('input', { class: IntInputStepper._inputCls, type: 'text', maxlength: this.maxDigits, id: this.id }); this.incBtn = createElement('div', { name: 'inc_btn' }); this.decBtn = createElement('div', { name: 'dec_btn' }); this.incBtn.innerHTML = ICON.TRIANGLE_UP; this.decBtn.innerHTML = ICON.TRIANGLE_DOWN; this.root.append( createElement('span', { class: 'text' }, this.labelName), this.numInput, createElement('div', { class: IntInputStepper._ctrsCls }, [this.incBtn, this.decBtn]) ); // 初始化状态并绑定事件 this.num = this.initNum; this.numInput.addEventListener('input', this._onInput.bind(this)); this.numInput.addEventListener('keydown', this._onKeyDown.bind(this)); this.incBtn.addEventListener('click', this._onInc.bind(this)); this.decBtn.addEventListener('click', this._onDec.bind(this)); } /** 限制输入为正整数 */ _onInput() { let value = this.numInput.value.replace(/[^0-9]/g, ''); if (value === '' || parseInt(value) === 0) value = this.minNum.str; this.numInput.value = value; if (this.onNumChange) this.onNumChange(this.num); } /** 限制键盘输入行为,禁止非数字键输入 */ _onKeyDown(event) { if (!/^[0-9]$/.test(event.key) && event.key !== 'Backspace' && event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') event.preventDefault(); if (event.key === 'ArrowUp') this._onInc(); else if (event.key === 'ArrowDown') this._onDec(); } /** 步增,可按钮或键盘触发 */ _onInc() { let value = this.num; this.num = value + 1; if (this.onNumChange) this.onNumChange(this.num); } /** 步减,可按钮或键盘触发 */ _onDec() { let value = this.num; if (value > this.minNum.int) this.num = value - 1; if (this.onNumChange) this.onNumChange(this.num); } } /** * 三态滑动选择器
*/ class TriStateSlider { /** 可选状态 */ static states = [ EnableState.ALL_DISABLED, // 1 EnableState.PARTIAL_ENABLED, // 2 EnableState.ALL_ENABLED // 3 ]; static default = Key.ENABLE_STATE_DEFAULT; // 所用样式的类名 static _selectorCls = 'tri_state_selector'; static _radioCls = 'radio_input'; static _labelCls = 'radio_label'; static _sliderCls = 'select_slider'; static _indicatorCls = 'select_indicator'; /** * @type {(newState: string) => void | null} * 回调函数,当状态变化时被调用 */ onStateChange = null; constructor(idPref, initState = TriStateSlider.default) { this.root = createElement('div', { class: TriStateSlider._selectorCls }); this.radios = {}; this.idPref = idPref; this.initState = initState; this._stateHis = {pre: null, pre2: null}; this._initStateHis(); } set state(state) { if (!state || !TriStateSlider.states.includes(state)) state = TriStateSlider.default; this.initState = state; this._initStateHis(); this.radios[state].checked = true; } get state() { for (const [state, radio] of Object.entries(this.radios)) { if (radio.checked) return state; } return this.initState; } /** * 构造`DOM`树,并绑定事件 */ build() { // 构建单选格,radio 本体将通过样式隐藏 TriStateSlider.states.forEach((state) => { const radioId = `${this.idPref}_${state}`; const radio = createElement('input', { type: 'radio', name: `${this.idPref}_group`, id: radioId, value: state, class: TriStateSlider._radioCls }); const label = createElement('label', { htmlFor: radioId, class: TriStateSlider._labelCls }); this.radios[state] = radio; this.root.append(radio, label); }); // 构建滑动外观 this.root.append( createElement('div', { class: TriStateSlider._sliderCls }, createElement('div', { class: TriStateSlider._indicatorCls }) )); // 初始化状态并绑定事件 this.radios[this.initState].checked = true; // 1) 箭头函数每次事件触发时,都会创建一个新的匿名函数,影响性能 // this.selector.addEventListener('click', (event) => this._onClick(event)); // 2) 事件监听器的回调函数本身会改变 this,使得它从指向类的实例对象,变为指向事件触发的元素 // this.selector.addEventListener('click', this._onClick); // 3) 使用绑定后的函数 this.root.addEventListener('click', this._onClick.bind(this)); } _initStateHis() { this._stateHis.pre = this.initState; // 设定历史状态,使得无需在 _onClick 为重复点击初始状态单独处理 this._stateHis.pre2 = this.initState === TriStateSlider.states[1] ? TriStateSlider.states[2] : TriStateSlider.states[1]; // ((1,3) 2)->(2 3) } /** * 采用事件委托的形式处理点击事件, * 将原本的`radio`操作体验处理为`ToggleSlider`手感 */ _onClick(event) { if (!event.target.classList.contains('radio_input')) return; let curState = event.target.value; // 现在与过去互异,正常不处理;现在与过去的过去互异,模拟 Toggle if (curState === this._stateHis.pre && curState !== this._stateHis.pre2) { this.radios[this._stateHis.pre2].checked = true; curState = this._stateHis.pre2; } this._stateHis.pre2 = this._stateHis.pre; this._stateHis.pre = curState; // 使用回调函数通知外部 if (this.onStateChange) this.onStateChange(curState); } } /** * 创建一个滑动开关 * @param {string} sliderId - 开关的`ID` * @returns {[HTMLElement, HTMLElement]} 返回`开关`与`开关容器`构成的数组
*/ function buildToggleSlider(sliderId) { const toggle = createElement('input', { class: 'toggle_input', type: 'checkbox', id: sliderId }); const toggleCntr = createElement('div', { class: 'toggle' }, [toggle, createElement('label', { class: 'toggle_slider', htmlFor: sliderId })] ); return [toggle, toggleCntr]; } /** * 优先尝试使用`execCommand`方法改写文本框,使得改写前的用户历史记录不被浏览器清除 * (虽然`execCommand`方法已被弃用...但仍然是实现该功能最便捷的途径) */ async function trySetText(textArea, msgBox, text, msg, isRestore, setCursorPos = null, transTime = 100) { let {scrollVert, cursorPos} = getTextAreaPos(textArea); try { setMessage(msgBox); await clearAndSetTextarea(textArea, text, transTime); setMessage(msgBox, `${msg},可快捷键撤销`, 0); } catch { textArea.value = ''; await new Promise(resolve => setTimeout(resolve, transTime)); textArea.value = text; setMessage(msgBox, msg, 0); console.log(`${SCRIPT_NAME}:浏览器不支持 execCommand 方法,改为直接重置文本框,将无法通过快捷键撤销重置`) } if (isRestore) { setCursorPos ??= cursorPos; // 可以使用外部计算获取的光标位置 restorePos(); } /** * 恢复滚动位置和光标位置 */ function restorePos() { const currentTextLen = textArea.value.length; if (setCursorPos > currentTextLen) setCursorPos = currentTextLen; textArea.scrollTop = Math.min(scrollVert, textArea.scrollHeight); // textArea.scrollLeft = Math.min(scrollHoriz, textArea.scrollWidth - textArea.clientWidth); textArea.setSelectionRange(setCursorPos, setCursorPos); } } /** * 获取文本框的滚动位置和光标位置 */ function getTextAreaPos(textArea) { return { scrollVert: textArea.scrollTop, scrollHoriz: textArea.scrollLeft, cursorPos: textArea.selectionStart }; } async function clearAndSetTextarea(textarea, newText, timeout = 100) { textarea.focus(); // 全选文本框内容并删除 textarea.setSelectionRange(0, textarea.value.length); document.execCommand('delete'); // 延迟一段时间后,插入新的内容 await new Promise(resolve => setTimeout(resolve, timeout)); document.execCommand('insertText', false, newText); } async function setMessage(container, message, timeout = 100) { container.style.display = 'none'; if (!message) return; // 无信息输入,则隐藏 // 隐藏一段时间后,展现新内容 if (timeout) await new Promise(resolve => setTimeout(resolve, timeout)); container.textContent = message; container.style.display = 'inline'; } /** * 获取当前页面的视口高度 */ function getViewportHeight() { return document.documentElement.clientHeight || document.body.clientHeight; } /** * 创建元素实例 * @param {string} tagName - 类名 * @param {object | undefined} options - 属性 * @param {Array | undefined} subElements - 子元素 * @param {Object | undefined} eventHandlers - 绑定的事件 */ function createElement(tagName, options, subElements, eventHandlers) { const element = document.createElement(tagName); if (options) { for (let opt in options) { if (opt === 'class') element.className = options[opt]; else if (['maxlength'].includes(opt)) element.setAttribute(opt, options[opt]); else if (opt === 'dataset' || opt === 'style') { for (let key in options[opt]) { element[opt][key] = options[opt][key]; } } else element[opt] = options[opt]; } } if (subElements) updateSubElements(element, subElements); if (eventHandlers) { for (let e in eventHandlers) { element.addEventListener(e, eventHandlers[e]); } } return element; } /** * 更新子元素的内容 * @param {HTMLElement} parent - 父元素 * @param {Array | HTMLElement | string | undefined} subElements - 要插入的子元素 * @param {'append' | 'prepend' | 'replace'} [actionType='append'] - 操作类型,可以是以下之一: * `prepend` - 将元素插入到父元素的首位 * `append` - 将元素插入到父元素的末尾 * `replace` - 清空父元素内容并插入元素 */ function updateSubElements(parent, subElements, actionType = 'append') { if (actionType === 'replace') parent.innerHTML = ''; if (!subElements) return parent; if (!Array.isArray(subElements)) subElements = [subElements]; subElements = subElements.map(e => typeof e === 'string' ? document.createTextNode(e) : e); switch (actionType) { case "append": case "replace": parent.append(...subElements); break; case "prepend": parent.prepend(...subElements); break; default: throw new Error(`'${actionType}' is invalid action type of updateElements!`); } return parent; } /** * 使用闭包定义防抖动函数模板。 * 若为立即执行,将先执行首次触发,再延迟执行最后一次触发 * @param {Function} func - 回调函数 * @param {boolean} [immediate=false] - 是否先立即执行 */ function debounce(func, immediate = false, delay = DEBOUNCE_DELAY) { let timer = null; return function (...args) { const context = this; // 保存调用时的上下文 const callNow = immediate && !timer; if (timer) clearTimeout(timer); // 设置新的定时器 timer = setTimeout(() => { timer = null; if (!immediate) func.apply(context, args); // 延时执行 }, delay); if (callNow) func.apply(context, args); // 立即执行 }; } /** * 过滤对象中的方法,只返回对象的枚举值 * @param {Object} obj - 需要过滤的对象 * @param {(value: any) => boolean} [filterFn = value => typeof value !== 'function'] - 可选的过滤函数 * @returns {Array} 过滤后的枚举值数组 */ function filterEnumValues(obj, filterFn = value => typeof value !== 'function') { return Object.values(obj).filter(filterFn); } /** * `infobox.li`职位人员信息的计算样式 */ const JobStyle = { compStyle: null, // fontSize: null, // num lineHeight: null, // num borderBottom: null, // px paddingBottom: null, // px initialize(el) { this.compStyle = window.getComputedStyle(el); // 通常不会返回 em % normal 类别的数据 // this.fontSize = parseFloat(this.compStyle.fontSize); this.lineHeight = parseFloat(this.compStyle.lineHeight); this.borderBottom = this.compStyle.borderBottomWidth; this.paddingBottom = this.compStyle.paddingBottom; console.log( `${SCRIPT_NAME}:lineHeight:${this.lineHeight}px, ` + `borderBottom:${this.borderBottom}, paddingBottom:${this.paddingBottom}` ); }, } /** * 动态载入职位排序的样式, * 依据的职位信息行高`jobLineHeight`与设置的限制行数`maxRefoldLines` */ function loadStaffStyle() { const style = createElement('style', {class: 'staff_sorting'}); // 使用CSS变量,以便未来拓展监听窗口布局变化 style.innerHTML = ` :root { --refold-threshold: ${Store.get(Key.REFOLD_THRESHOLD_KEY)}; --job-line-height: ${JobStyle.lineHeight}px; /* 18px */ --job-border-bottom: ${JobStyle.borderBottom}; /* 0.64px */ --job-padding-bottom: ${JobStyle.paddingBottom}; /* 4px */ } /* 删除与前继元素重复的边线 */ #infobox li.sub_container li.sub_section:first-child, #infobox li.sub_group, html[data-theme='dark'] ul#infobox li.sub_group { border-top: none; !important } /* 优化小组样式 */ #infobox li:not(.last_group)[attr-info-group] { border-bottom: none; } #infobox li:not(.last_group)[attr-info-group] > ul { border-bottom: 3px solid #fafafa; } html[data-theme='dark'] #infobox li:not(.last_group)[attr-info-group] > ul { border-bottom: 3px solid #3d3d3f; } /* 防止图标可能污染爬取 infobox 数据的脚本 */ .staff_sorting_icon { display: none; } #infobox .staff_sorting_icon { display: inline; } /* 职位信息二次折叠 */ #infobox li.refoldable { display: inline-block; /* 使其容纳.tip.side */ height: auto; overflow: visible; } #infobox li.refolded { display: block; overflow: hidden; height: calc(var(--refold-threshold) * var(--job-line-height)); /* 由下至上进行遮蔽 */ -webkit-mask-image: linear-gradient(black, black), /* 显现 border-bottom */ linear-gradient(transparent, transparent), /* 隐藏溢出到 padding-bottom 区域的信息 */ linear-gradient(160deg, black 10%, transparent 90%), /* 修饰最后一行人员信息 */ linear-gradient(black, black); /* 显现其余的人员信息 */ mask-image: linear-gradient(black, black), linear-gradient(transparent, transparent), linear-gradient(160deg, black 10%, transparent 90%), linear-gradient(black, black); -webkit-mask-size: 100% var(--job-border-bottom), 100% var(--job-padding-bottom), 100% var(--job-line-height), 100% calc(100% - var(--job-line-height) - var(--job-padding-bottom) - var(--job-border-bottom)); mask-size: 100% var(--job-border-bottom), 100% var(--job-padding-bottom), 100% var(--job-line-height), 100% calc(100% - var(--job-line-height) - var(--job-padding-bottom) - var(--job-border-bottom)); -webkit-mask-position: 0 100%, 0 calc(100% - var(--job-border-bottom)), 0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom)), 0 0; mask-position: 0 100%, 0 calc(100% - var(--job-border-bottom)), 0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom)), 0 0; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-composite: source-over; mask-composite: add; } #infobox .tip.switch, #infobox .tip.side { cursor: pointer; } #infobox .tip.switch:hover { color: #000; } html[data-theme='dark'] #infobox .tip.switch:hover { color: #FFF; } #infobox .tip.switch:hover i, #infobox .tip.side:hover i { color: #2ea6ff; } #infobox .tip.side { display: none; float: right; /* 将其推到尾行右侧 */ clear: right; /* 如果尾行放不下,则换到新行 */ margin: 0 5px; } #infobox .tip.side[data-refold-line] { display: inline-block; } `; document.head.appendChild(style); } /** 载入设置界面的样式 */ function loadSettingStyle() { const style = createElement('style', {class: 'staff_sorting'}); // 使用CSS变量提高对代码的复用性 style.innerHTML = ` :root { --tri-state-selector-size: 22px; --tri-state-selector-step: 19px; } /* 设置界面的样式 */ #staff_sorting > .settings { margin-left: 5px; } #staff_sorting .right_inline { height: 22px; float: right; display: flex; align-items: center; } #staff_sorting .right_inline.hidden { display: none; } .line_limit_block h2 { font-size: 16px; display: inline-block; } /* 各类型条目的职位设置模块 */ .subject_staff_block h2, .subject_staff_block summary::marker { font-size: 16px; display: inline-block; cursor: pointer; } .subject_staff_block .staffMapList_editor { padding-right: 10%; margin-bottom: 5px; } .subject_staff_block textarea { font-size: 15px; line-height: 21px; } .subject_staff_block .inputBtn { margin-right: 5px; } .subject_staff_block .tip_j { display: none; margin: 0 5px; } .subject_staff_block .right_inline .tip_j { display: none; margin-right: 15px; } /* 滑动开关 */ .toggle { position: relative; width: 44px; height: 22px; display: block; float: right; } .toggle_input { display: none; } .toggle_slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #eaeaea; border-radius: 22px; box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.2); transition: background-color 0.2s ease-in; } html[data-theme="dark"] .toggle_slider { background-color: #9a9a9a; } .toggle_slider::before { content: ""; position: absolute; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; border-radius: 50%; box-shadow: 0 2px 3px rgba(0, 0, 0, 0.3); transition: transform 0.2s ease-in; } .toggle_input:checked + .toggle_slider { background-color: #72b6e3; } html[data-theme="dark"] .toggle_input:checked + .toggle_slider { background-color: #3072dc; } .toggle_input:checked + .toggle_slider::before { transform: translateX(22px); } /* 数字输入框与控制器 */ .num_input_cntr { display: flex; float: left; align-items: center; gap: 5px; margin-right: 30px; } .num_input_cntr .text { font-size: 14px; margin-right: 2px; } .inputtext.input_num { width: 30px; height: 12px; text-align: center; font-size: 15px; } .num_ctrs { display: flex; flex-direction: column; background-color: white; border: 1px solid #d9d9d9; border-radius: 4px; box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); gap: 0; } html[data-theme="dark"] .num_ctrs { background-color: black; border: 1px solid #757575; } .num_ctrs div { display: flex; text-align: center; width: 12px; height: 7px; padding: 2px; cursor: pointer; } .num_ctrs div:first-child { border-radius: 3px 3px 0 0; } .num_ctrs div:last-child { border-radius: 0 0 3px 3px; } .num_ctrs div svg { width: 100%; height: 100%; } .num_ctrs div:active { background-color: #2ea6ff; } /* 三态滑动选择器 */ .tri_state_selector { position: relative; width: calc( var(--tri-state-selector-size) + var(--tri-state-selector-step) * 2 ); height: var(--tri-state-selector-size); display: inline-block; } .radio_input { position: absolute; opacity: 0; z-index: 2; } .select_slider { position: relative; width: 100%; height: 100%; background-color: #eaeaea; border-radius: var(--tri-state-selector-size); box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2); z-index: 1; overflow: hidden; transition: background-color 0.2s ease-in; } html[data-theme="dark"] .select_slider { background-color: #9a9a9a; } .select_indicator { position: absolute; width: calc(var(--tri-state-selector-size) - 4px); height: calc(var(--tri-state-selector-size) - 4px); top: 2px; left: 2px; background-color: white; border-radius: 50%; box-shadow: 0 2px 3px rgba(0, 0, 0, 0.3); z-index: 1; transition: transform 0.2s ease-in; } .radio_label { position: absolute; width: var(--tri-state-selector-step); height: 100%; top: 0; cursor: pointer; z-index: 3; } label.radio_label:nth-of-type(1) { left: 0; } label.radio_label:nth-of-type(2) { left: var(--tri-state-selector-step); } label.radio_label:nth-of-type(3) { width: var(--tri-state-selector-size); left: calc(var(--tri-state-selector-step) * 2); } input.radio_input:nth-of-type(2):checked ~ .select_slider { background-color: #f47a88; } input.radio_input:nth-of-type(3):checked ~ .select_slider { background-color: #72b6e3; } html[data-theme="dark"] input.radio_input:nth-of-type(2):checked ~ .select_slider { background-color: #ff668a; } html[data-theme="dark"] input.radio_input:nth-of-type(3):checked ~ .select_slider { background-color: #3072dc; } input.radio_input:nth-of-type(1):checked ~ .select_slider .select_indicator { transform: translateX(0); } input.radio_input:nth-of-type(2):checked ~ .select_slider .select_indicator { transform: translateX(var(--tri-state-selector-step)); } input.radio_input:nth-of-type(3):checked ~ .select_slider .select_indicator { transform: translateX(calc(var(--tri-state-selector-step) * 2)); } .select_slider::after { content: ""; position: absolute; width: calc(var(--tri-state-selector-size) + var(--tri-state-selector-step)); height: var(--tri-state-selector-size); left: var(--tri-state-selector-step); border-radius: calc(var(--tri-state-selector-size) / 2); box-shadow: 0 0 3px rgba(0, 0, 0, 0.1), inset 0 0 6px rgba(0, 0, 0, 0.3); transition: transform 0.2s ease-in-out; } input.radio_input:nth-of-type(1):checked ~ .select_slider::after { transform: translateX(calc(0px - var(--tri-state-selector-step))); } `; document.head.appendChild(style); } main(); })();