班固米-条目职位自定义排序与折叠

对所有类型条目的制作人员信息进行职位的自定义排序与折叠,可在[设置-隐私]页面进行相关设置

目前为 2024-11-29 提交的版本。查看 最新版本

// ==UserScript==
// @name         班固米-条目职位自定义排序与折叠
// @namespace    https://github.com/weiduhuo/scripts
// @version      1.3.1-1.2
// @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
// ==/UserScript==

(function () {
  'use strict';
  const SCRIPT_NAME = '班固米-职位排序组件';
  // 排序延迟时间
  const SORTING_DELAY = 50;
  // 防抖延迟时间
  const DEBOUNCE_DELAY = 500;
  // URL 相对路径
  const PATHNAME = window.location.pathname;
  // 是否对职位信息进行了折叠,忽略网页自身`sub_group`的折叠 (依此判断 `更多制作人员` 开关的必要性)
  let hasFolded = false;
  // 职位人员信息的行距
  let jobLineHeight = null;
  // 尾部折叠图标的激活行数阈值
  const sideTipRate = 0.25;
  let sideTipLineThr = null;
  /** 
   * @type {Array.<HTMLElement> | null}
   * 最后一组`sub_group`的数据包
   */
  let lastGroup = null;

  /**
   * 图标
   */
  const ICON = {
    // 三角形顶点向右,可表展开按键
    TRIANGLE_RIGHT: `
      <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 13 13' height=' 0.7em'>
        <polygon points='0.5,0 12.5,6.5 0.5,13'  fill='currentColor' />
      </svg>
    `,
    // 三角形顶点向下,可表折叠按键
    TRIANGLE_DOWN: `
      <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 13 13' height=' 0.7em'>
        <polygon points='0,0.5 13,0.5 6.5,12.5'  fill='currentColor' />
      </svg>
    `,
    // 三角形顶点向上,可表折叠按键
    TRIANGLE_UP: `
      <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 13 13' height=' 0.7em'>
        <polygon points='0,12.5 13,12.5 6.5,0.5'  fill='currentColor' />
      </svg>
    `,
  };

  /** 
   * 枚举所支持的条目类型
   */
  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: '人物'},
    getAll(isObj = false) {
      if (isObj) return filterEnumValues(this);
      else return filterEnumValues(this).map(item => item.en);
    },
    prase(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",
    getAll() {
      return filterEnumValues(this);
    },
    prase(value) {
      if (this.getAll().includes(value)) return value;
      return null;
    },
  };

  /**
   * 管理`localStorage`的键名与初值。
   * 键值分为全局配置与各类型条目配置、简单类型与复杂类型
   */
  const Key = {
    // 键名前缀
    _KEY_PREF: 'BangumiStaffSorting',
    // 超过此行数的职位信息将被二次折叠
    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', 
    // 当前使用的键值的所属条目类型 (可即时切换)
    _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}`;
    },
    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].includes(key);
    }
  }

  /** 
   * 配置存储,提供`localStorage`的接口。
   * 仅对简单数据类型进行解析、编码、缓存,复杂数据类型放权给外部
   */
  class Store {
    // 数据缓存,仅对简单类型的键值
    static _cache = {};
    // 定义静态防抖逻辑的占位 (忽略短时间内改变多对键值的极端情况)
    static debouncedSet;

    // 为缺损的配置进行初始化
    static initialize() {
      // 全局配置初始化
      ['REFOLD_THRESHOLD'].forEach((key) => Store._setDefault(key));
      // 局部配置初始化
      SubjectType.getAll().forEach((type) => {
        ['BLOCK_OPEN', 'ENABLE_STATE'].forEach((key) => Store._setDefault(key, type));
      });
      // 缓存初始化
      this._cache = {};
      // 动态绑定防抖逻辑,确保 this 指向 Store
      this.debouncedSet = debounce(this._set.bind(this));
    }

    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 {
    /**
     * 懒加载的默认配置
     * 数据基本类型:`
        data = [Job | [boolean | Job, ...Job[]]]
        Job = string | RegExp
     * `其中`boolean`表示子序列内的职位是否默认折叠,缺损值为`False`,需位于子序列的首位才有意义
     * (默认配置中`,,`表示在`JSON`数组中插入`null`元素,用于输出格式化文本时标记换行)
     */
    static _defaultLazyData = {
      [SubjectType.ANIME.en]: () => [,
        "中文名", "类型", "适合年龄", /地区/, "语言", "对白", "话数", "总话数", [true, "季数"],,
        "放送开始", "开始", "放送星期", "放送时间", "上映年度", /上映/, "发售日", "片长", /片长/,,
        ,
        "原作", "原案", "人物原案", "人物设定", "原作插图", [true, "原作协力"],,
        "团长", "总导演", "导演", "副导演", "执行导演", "主任导演", "联合导演", "系列监督",,
        "系列构成", "脚本", "编剧", [true, /脚本|内容|故事|文艺|主笔/],,
        "分镜", "OP・ED 分镜", "主演出", "演出", [true, "演出助理"],,
        ,
        "总作画监督", [false, "作画监督"], [true, "作画监督助理"], "动作作画监督", "机械作画监督", "特效作画监督", /.*作画.*(监|导)/,,
        "主动画师", "主要动画师", [true, "构图"], [false, "原画"], [true, "第二原画", "补间动画"], "数码绘图", /(原画|动画|動画)(?!制|检查)/,,
        "动画检查", [true, /动画检查/],,
        ,
        "设定", "背景设定", "道具设计", /(?<!色彩|美术|美術)(设|設)(?!.*制)/, /Design|デザイン|Motion Graphic|モーショングラフィック/,,
        "色彩设计", [false, "色彩指定", "色指定"], [true, "上色", /(?<!角)色/],,
        "美术监督", /(美术|美術).*导演/, [false, "美术设计"], "概念美术", "视觉概念", "概念艺术",,
        [false, "背景美术"], [true, /景/], /ART WORK|美术|美術|艺术|工艺|创意|绘制/,,
        ,
        "3DCG 导演", "CG 导演", /CG.*导演/, "3DCG", [false, /2D|3D|CG|コンピュータ/], "模型监督", "绑骨监督", [/建模|模型|动作|表情|骨/],,
        "摄影监督", "副摄影监督", "后期监督", [true, "摄影", "特效", "照明", "特技", /摄影|モニター|特效|动效|合成|拍|Effect|技术/],,
        "現像", /タイトル|标题|字幕/,,
        [false, "剪辑", "编集"], [true, /(?<!音.*)剪辑/], "编辑", [true, "场记"],,
        "监修", /监修|監修/, "顾问", /顾问/,,
        ,
        "音响监督", [true, "音响", "音响制作", "音效", "拟音", /音响/], "录音", [true, "录音助理", "混声", /录音|声/],,
        "配音", "主演", "キャスティング", [true, /配音|((?<!歌)演出)/],,
        "音乐", "音乐制作", [true, "音乐制作人", "音乐助理"], [true, /音/],,
        "主题歌演出", [true, "主题歌作词", "主题歌作曲", "主题歌编曲"], "插入歌演出",,
        [true, "插入歌作词", "插入歌作曲", "插入歌编曲"], [true, "选曲"], [true, /曲|歌/],,
        ,
        "企画", [true, "企画协力"], "企划制作人", /企画|企划|出品|策划/, "监制", /监制/,,
        "执行制片人", "总制片人", "制片人", "总制片", "制片", [true, "副制片人", "联合制片人", "助理制片人", /(?<!动画|動画)制片/],,
        [true, /行政|审/, "责任编辑"], [true, /法务/], [true, "宣传", /宣传|宣伝|広報/], /市场|运营|衍生/,,
        "制作", "製作", [true, "制作著作"],,
        "动画制片人", [true, "制作管理", "制作统筹", "制作主任", "制作助理"],,
        [true, "设定制作"], [true, "计划管理", "制作进行", "制作进行协力"],,
        "制作协调", "制作协力", "制作助手", "协力", /协力|協力/, [true, /取材/], [true, "特别鸣谢", /鸣谢|Thanks/],,
        ,
        "动画制作", [true, /制作|製作/],,
        "别名", /.+名$/,,
        "发行", "官方网站", "在线播放平台", "链接", "播放电视台", "其他电视台", "配给", /配(?!音)|連載|番組|版|播放(?!结束)/,,
        "播放结束", "结束",,
        ,
        "其他", /其他/,,
        "===此处插入未被匹配的职位===",,
        "Copyright",
      ],
      // [SubjectType.BOOK.en]: () => [],
    };

    // 构造函数
    constructor(subType) {
      // 所属条目类型(不可变更)
      this.subType = subType; // 小心 Store._subType 被设置的其他模块切换
      // 数据
      this.data = [];
      // 职位的排序列表
      this.jobOrder = [];
      // 默认折叠的职位,EnableState = "particalDisable" 时,值为空
      this.foldableJobs = [];
      // 是否为默认数据
      this.isDefault = null;
      // 默认配置格式化文本的缓存
      this._defaultTextBuffer = null;
    }

    /**
     * 依据`EnableState`进行初始化,使其具备职位匹配的能力。
     * 若仅为获取`StaffMapList`格式化字符串,则不需要执行本初始化。
     */
    initialize() {
      Key.setSubType(this.subType);
      if (Store.get(Key.ENABLE_STATE_KEY) === EnableState.ALL_DISABLED)
        return;
      if (!this._loadData()) this._setDefault();
      this._resolveData();
    }

    /**
     * 空缺设置,将关闭脚本的职位排序。
     * 有两种独立开启途径:
     *   1. `EnableState = "allDisable"`
     *   2. `StaffMapListJSON = "[]"`
     */
    isNull() {
      return this.data.length === 0;
    }

    // 保存自定义的数据
    saveData(jsonStr) {
      Store.set(Key.STAFF_MAP_LIST_KEY, jsonStr, this.subType);
      console.log(jsonStr);
      console.log(`${SCRIPT_NAME}:保存自定义 staffMapList 数据`);
    }

    // 恢复默认数据的设置
    resetData() {
      Store.remove(Key.STAFF_MAP_LIST_KEY, this.subType);
      console.log(`${SCRIPT_NAME}:删除自定义 staffMapList 数据,恢复默认设置`);
    }

    // 使用懒加载恢复默认配置
    _setDefault() {
      this.isDefault = true;
      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() {
      this.jobOrder = [];
      this.foldableJobs = [];
      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] && Store.get(Key.ENABLE_STATE_KEY, this.subType) ===
                EnableState.ALL_ENABLED) {
              this.foldableJobs.push(...item.slice(1));
            }
            this.jobOrder.push(...item.slice(1));
          } else {
            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;
      // 自定义数据
      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,)|(?<!\[)(\n\s+)(?!])|(\[\s+)|(\s+],)/g,
        (match, g1, g2, g3, g4) => {
          if (g1) return '\n';
          if (g2) return ' ';
          if (g3) return '[';
          if (g4) return '],';
          return match;
        }).slice(3, -2); */
      this._defaultTextBuffer = text;
      return text;
    }
  }

  // 匹配相应 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();
    for (const pattern of urlPatterns) {
      if (pattern.regex.test(PATHNAME)) {
        pattern.handler(pattern.type);
        break;
      }
    }
  }

  // 处理设置
  function handlerSettings() {
    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.prase(getSubjectType());
    if (!subType) return; // 不支持该类型条目
    const ul = document.querySelector('#infobox');
    const staffMapList = new StaffMapList(subType);
    staffMapList.initialize();
    if (!staffMapList.isNull()) {
      // 实行自定义的职位顺序
      const staffDict = getStaffDict(ul);
      // 延迟执行,提高对修改 infobox 信息的其他脚本的兼容性
      setTimeout(() => {
        sortStaff(ul, staffMapList, staffDict);
        // 依赖 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`
   * @param {StaffMapList} staffMapList - 职位信息设置
   * @param {Object.<string, HTMLElement|Array.<HTMLElement>>} staffDict - 职位信息字典
   */
  function sortStaff(ul, staffMapList, staffDict) {
    // 未能匹配职位的待插入位置
    let liAfterIntsert = null;
    let insterTag = false;
    let insertFold = false;

    /* 无需清空原始的`staff`列表 ul.innerHTML = '';
     * appendChild() 会将元素从原先的位置移动到新的位置 */

    // 按照预定顺序添加到 DOM
    for (const item of staffMapList.jobOrder) {
      const matchingRoles = [];
      // 1.正则匹配
      if (item instanceof RegExp) {
        matchingRoles.push(...Object.keys(staffDict).filter(key => item.test(key)));
        if (matchingRoles.length) {
          console.log(`${SCRIPT_NAME}:使用正则表达式 "${item}" 成功匹配 {${matchingRoles}}`);
        } else continue;
      } else if (typeof item === 'string') {
        // 2.精确匹配
        if (item && item in staffDict) {
          matchingRoles.push(item);
        // 3.特殊关键字处理
        } else if (item.startsWith('==')) {
          // 激活待插入位置
          insterTag = true;
          insertFold = staffMapList.foldableJobs.includes(item);
        } else continue
      // 4.其余情形均忽略 (且对于意外类型不报错)
      } else continue;

      // 添加职位,并判断是否默认折叠
      matchingRoles.forEach((role) => {
        const li = staffDict[role];
        if (Array.isArray(li)) {
          // 将`sub_group`及属其所有的`sub_container`视为一个整体进行排序
          ul.append(...li);
          lastGroup = li;
        } else {
          if (typeof item === 'string' && staffMapList.foldableJobs.includes(role)
            || item instanceof RegExp && staffMapList.foldableJobs.includes(item)) {
            if (!hasFolded) hasFolded = true;
            li.classList.add('folded', 'foldable');
          }
          ul.appendChild(li);          
        }
        delete staffDict[role]; // 从字典中删除已处理的职位
        // 保存待插入位置
        if (insterTag) {
          liAfterIntsert = Array.isArray(li) ? li[0] : li;
          console.debug(liAfterIntsert.innerHTML);
          insterTag = false;
        }
      });
    }

    // 将剩余未被匹配的职位按原顺序添加到待插入位置
    const unmatchedJobs = Object.keys(staffDict);
    if (unmatchedJobs.length === 0) return;
    unmatchedJobs.forEach(role => {
      const li = staffDict[role];
      const isGroup = Array.isArray(li);
      if (isGroup) lastGroup = li;
      else if (insertFold) {
        if (!hasFolded) hasFolded = true;
        li.classList.add('folded', 'foldable');
      }
      if (liAfterIntsert) {
        if (isGroup) li.forEach(node => ul.insertBefore(node, liAfterIntsert));
        else ul.insertBefore(li, liAfterIntsert);
      } else {
        // 未设置待插入位置,则默认插入到末尾,且默认不折叠
        if (isGroup) ul.append(...li);
        else ul.appendChild(li);
      }
    });
    dealUnmatched(staffDict);
    if (liAfterIntsert) console.log(`${SCRIPT_NAME}:激活将未能匹配职位插入指定位置`);
  }

  /**
   * 获取一个对象来存储网页中的职位信息。
   * 并对职位信息进行二次折叠,
   * 同时将`sub_group`及属其所有的`sub_container`打包为一个序列作为字典的键值
   * @param {HTMLElement} ul - `infobox`
   * @returns {Object.<string, HTMLElement|Array.<HTMLElement>>} 返回职位信息字典,键值为`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 role = tip.innerText.trim().slice(0, -1); // 去掉最后的冒号
      if (li.classList.contains("sub_group")) {
        // 新的小组
        staffDict[role] = [li];
      } else if (li.classList.contains("sub_container")
        && li.hasAttribute("attr-info-group")) {
        // 整合进组
        role = li.getAttribute("attr-info-group");
        if (staffDict[role]) staffDict[role].push(li);
        else staffDict[role] = [li];
      } else {
        // 普通元素
        staffDict[role] = 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 信息
    const lineCnt = getLineCnt(li);
    const refoldThr = Store.get(Key.REFOLD_THRESHOLD_KEY);
    if (lineCnt <= refoldThr) return;
    // 添加二次折叠效果 (样式将在随后通过 loadStaffStyle 动态载入)
    const nest = nestElementWithChildren(li, 'div', {class: 'refoldable refolded'});
    // 尝试不修改 DOM 结构仅通过样式添加折叠效果,但未果,故改为内嵌一层新的 div 元素

    // 添加头部开关状态图标
    const prefIcon = createElement('i', { class: 'staff_sorting_icon' });
    prefIcon.innerHTML = ICON.TRIANGLE_RIGHT;
    /* 尝试使用<symbol><use>模板或直接使用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;
    nest.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) => {
      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 div = suffTip.parentElement; // li > div
      let [pre, cur] = [null, null];
      pre = div.getBoundingClientRect().height;
      // 更改折叠状态
      div.classList.add('refolded');
      // 修改首部开关的图标
      div.firstChild.firstChild.innerHTML = ICON.TRIANGLE_RIGHT;
      // 滚轮自动上移被折叠的距离,以确保折叠后的内容不会让用户迷失上下文
      cur = div.getBoundingClientRect().height;
      /* 尝试通过 sideTip.dataset.refoldLine 计算滚动数值
       * 但滚动结果与理想情况有 ~0.5px 的随机偏差,
       * 故改用计算元素窗口的高度变化 */
      window.scrollBy({
          top: cur - pre,
          behavior: 'instant'
      });
      // console.debug(`Pre: ${pre}\nCur: ${cur}\nDiff: ${cur - pre}`);
    });
    /* 在 mousedown 阶段阻止用户拖动或双击时的默认选中行为。
     * 由于 span.switch 本质仍然是内容段落的一部分,
     * 不通过 user-select: none 这钟粗暴的方法禁止用户的一切选中行为
     * 而是采用温和的方法阻止部分情形下对该区域的选中行为 */
    ul.addEventListener('mousedown', (event) => {
      if (event.target.closest('.switch')) event.preventDefault();
    });
  }

  /**
   * 处理未被匹配的职位信息,待扩展
   * @param {Object.<string, HTMLElement>} staffDict 
   */
  async function dealUnmatched(staffDict) {
    console.log(
      `${SCRIPT_NAME}:未能匹配到的职位`,
      Object.values(staffDict)
        .map((v) =>
          `{\n    ${(v instanceof HTMLElement
            ? v.innerText
            : v[0].innerText // 对于 [sub_group, sub_container,...] 只打印首元素
          ).trim()}\n}`
        ).join(",")
    );
  }

  /**
   * 处理最后一组`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');
    })
  }

  /**
   * 获取固定行高元素显示的行数
   * 经测试,职员信息除了`8px`的`padding`还有`<1px`的`border`因为不影响行数计算忽略
   */
  function getLineCnt(el, padding = 8, border = 0) {
    jobLineHeight ??= getLineHeight(el);
    const height = el.getBoundingClientRect().height - padding - border;
    return ~~(height / jobLineHeight);
  }

  /**
   * 根据页面视口高度,计算尾部折叠图标的激活行数阈值
   * 对于二次折叠区域较小,不予显示
   */
  function getSideTipThr() {
    const threshold = ~~(getViewportHeight() / jobLineHeight * sideTipRate);
    console.log(`${SCRIPT_NAME}:sideTipLineThreshold:${threshold}`);
    return threshold;
  }
  
  /**
   * 将原本存在的`更多制作人员+`一次性按钮,转绑新事件,并改为永久性开关
   * 使用网页原有的`folded`元素类别,实现对立于`sortStaff`功能
   * 添加不存在的`更多制作人员+`按钮,否则一些职位信息将永不可见
   * @param {HTMLElement} ul - `#infobox`
    <div class="infobox_expand">
      <a href="javascript:void(0)">更多制作人员 +</a>
      <!-- href 属性需保留,不然鼠标悬浮效果会失效 -->
    </div>
   */
  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`中的同类元素进行构建,使用原有的结构与样式
    <table class="settings">
      <tbody>
        <tr>
          <td>
            <h2 class="subtitle">条目职位排序 · 默认折叠的职位</h2>
          </td>
        </tr>
        <!-- 此处添加子模块 -->
      </tbody>
    </table>
   */
  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;
  }

  /**
   * 创建职位信息二次折叠的行高限制设置界面
    <tr>
      <td class="line_limit_block">
        <h2 class="subtitle">职位信息高度 限制</h2>
        <div class="right_inline">
          <fieldset class="num_input_cntr">...</fieldset>
          <div class="toggle">...</div>
        </div>
      </td>
    </tr>
   */
  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`等控件仍然使用原有的结构与样式
    <tr>
      <td class="subject_staff_block">
        <details open="">
          <summary>
            <h2 class="subtitle"><!-- subject type --></h2>
            <div class="right_inline">
              <p class="tip_j" style="display: inline;"><!-- message --></p>
              <div class="tri_state_selector">...</div>
            </div>
          </summary>
          <div class="staffMapList_editor">...</div>
        </details>
      </td>
    </tr>
   */
  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`编辑器,并对数据进行自主管理
    <div class="staffMapList_editor">
      <div class="markItUp">
        <textarea class="quick markItUpEditor hasEditor codeHighlight" name="staff_map_list">
          <!-- staffMapListText -->
        </textarea>
      </div>
      <div>
        <input class="inputBtn" type="submit" name="submit_context" value="保存">
        <input class="inputBtn" type="submit" name="reset_context" value="恢复默认">
        <p class="tip_j" style="display: inline;">
          <!-- message -->
        </p>
      </div>
      <!-- margin-right 为移动端预留的 mainpage 滑动空间 -->
    </div>
   */
  class StaffMapListEditor {
    static _editorCls = 'staffMapList_editor';

    constructor(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.built = false; // 标记是否已经初始化
    }

    async 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;
      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;
      // 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;
    }
    _onSubmit() {
      // 判断是否为重置后未对默认内容进行修改
      if (this.isDefault) {
        this.staffMapList.resetData();
        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) trySetText(
        this.textArea, this.editorMsgBox, modifiedData,
        '保存成功!并自动纠错', true, curCursorPos
      );
      else setMessage(this.editorMsgBox, '保存成功!');
    }
    
    /**
     * 对用户输入可能的常见语法与格式错误,进行自动纠错,以满足`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*)+(?=,)|(['‘’“”])|(?<!['"‘“])(\/[^/'"‘’“”]+\/[gimsuy]*)(?!['"’”])|([,、])/g,
        (match, g1, g2, g3, g4, g5, g6, offset) => {
          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'`而是自我搭建相关控制
    <fieldset class="num_input_cntr">
      <span class="text">行数</span>
      <input class="inputtext input_num" type="text" maxlength="2" id="refold_threshold_input">
      <div class="num_ctrs">
        <div><svg>...</svg></div>
        <div><svg>...</svg></div>
      </div>
    </fieldset>
   */
  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);
    }
  }

  /**
   * 三态滑动选择器
    <div class="tri_state_selector">
      <input type="radio" name="_subject_enable_group" value="allDisable" class="radio_input">
      <label class="radio_label"></label>
      <input type="radio" name="_subject_enable_group" value="partialEnable" class="radio_input">
      <label class="radio_label"></label>
      <input type="radio" name="_subject_enable_group" value="allEnable" class="radio_input">
      <label class="radio_label"></label>
      <div class="select_slider">
        <div class="select_indicator"></div>
      </div>
    </div>
   */
  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]} 返回`开关`与`开关容器`构成的数组
    <div class="toggle">
      <input class="toggle_input" type="checkbox" id="refold_switch">
      <label class="toggle_slider" for="refold_switch"></label>
    </div>
   */
  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 getLineHeight(el) {
    // 获取元素的计算样式
    const compStyle = window.getComputedStyle(el); // 不会返回 em 单位的数据
    const fontSize = parseFloat(compStyle.fontSize);
    let lineHeight = compStyle.lineHeight;
    console.log(`${SCRIPT_NAME}:fontSize:${fontSize}px, lineHeight:${lineHeight}`);
    // normal,估算为 1.2 倍
    if (lineHeight === 'normal') return fontSize * 1.2;
    // 百分比,如 150%
    if (lineHeight.endsWith('%')) return fontSize * parseFloat(lineHeight) / 100;
    // 像素单位,如 18px
    if (lineHeight.endsWith('px')) return parseFloat(lineHeight);
    // 倍数,如 1.5
    return fontSize * parseFloat(lineHeight);
  }

  /**
   * 获取当前页面的视口高度
   */
  function getViewportHeight() {
    return document.documentElement.clientHeight || document.body.clientHeight;
  }

  /** 
   * 在子元素的外层与父元素间嵌套一层元素
   * @returns {HTMLElement} 新建的中间层元素
   */
  function nestElementWithChildren(parent, newTagName, options) {
    const newElement = createElement(newTagName, options, Array.from(parent.childNodes));
    parent.innerHTML = '';
    parent.appendChild(newElement);
    return newElement;
  }

  /**
   * 查找某个节点在某条件下的下方最近兄弟节点
   * @param {HTMLElement} node - 当前节点
   * @param {(value: HTMLElement) => boolean} condition - 判断条件函数
   * @returns {HTMLElement|null} - 满足条件的最近兄弟节点,若不存在则返回 null
   */
  // function findNextSibling(node, condition) {
  //   let nextSibling = node.nextElementSibling;
  //   while (nextSibling) {
  //     if (condition(nextSibling)) return nextSibling;
  //     nextSibling = nextSibling.nextElementSibling;
  //   }
  //   return null;
  // }

  /**
   * 创建元素实例
   * @param {string} tagName - 类名
   * @param {object} options - 属性
   * @param {Array.<HTMLElement|string>|undefined} subElements - 子元素
   * @param {object.<string, Function>} 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>|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);
  }

  /**
   * 动态载入职位排序的样式,
   * 依据的职位信息行高`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: ${jobLineHeight}px;
      }

      /* 删除与前继元素重复的边线 */
      #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 div.refoldable {
        display: inline-block; /* 使其容纳.tip.side */
        height: auto;
        overflow: visible;
      }
      #infobox div.refolded {
        display: block;
        height: calc(
          var(--refold-threshold) * var(--job-line-height)
        );
        overflow: hidden;
        -webkit-mask-image: 
          linear-gradient(160deg, black 10%, transparent 90%), 
          linear-gradient(black, black);
                mask-image: 
          linear-gradient(160deg, black 10%, transparent 90%), 
          linear-gradient(black, black);
        -webkit-mask-size:
          100% var(--job-line-height),
          100% calc(100% - var(--job-line-height));
                mask-size:
          100% var(--job-line-height),
          100% calc(100% - var(--job-line-height));
        -webkit-mask-position:
          0 100%,
          0 0;
                mask-position:
          0 100%,
          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();

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址