Greasy Fork

Greasy Fork is available in English.

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

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

当前为 2024-12-04 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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
// ==/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<HTMLElement> | null} 最后一组`sub_group`的数据包
   */
  let lastGroup = null;

  /**
   * 图标,已在`loadStaffStyle`中通过父元素类名`staff_sorting_icon`约束所显示的范围
   */
  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: '人物'},
    /**
     * @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<string>}
     */
    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<string> (归为简单类型,目前仅音乐条目启用) */
    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, /动画检查/],,
        ,
        "设定", "背景设定", "道具设计", /(?<!色彩|美术|美術)(设|設)(?!.*制)/, /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, /制作|製作/],,
        "别名", /.+名$/,,
        "发行", "官方网站", "在线播放平台", "链接", "播放电视台", "其他电视台", "配给", /配(?!音)|連載|番組|版|播放(?!结束)/,,
        "播放结束", "结束",,
        ,
        "其他", /其他/,,
        [false, "===此处插入未被匹配的职位==="],,
        "Copyright",
      ],
      [SubjectType.MUSIC.en]: () => [,
        "制作人",,
        "艺术家", "作词", "作曲", "编曲",,
        "脚本", "声乐", "乐器", "混音", "母带制作",,
        "插图", "原作", "出版方", "厂牌",
      ],
    };

    /** @type {StaffMapListType} 主数据 */
    data = [];
    /** @type {Array<MatchJob>} 职位的排序列表 */
    jobOrder = [];
    /** @type {Set<MatchJob>} 默认折叠的职位,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,)|(?<!\[)(\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;
    }
  }

  /**
   * 基类,职位排序的核心公共逻辑,拥有多个虚拟函数需子类实现。
   * 可被拓展用于不同的场景:网页`infobox`职位信息、职位名称序列、API`infobox`职位信息
   */
  class BaseStaffSorter {
    /** @type {StaffMapList} 职位排序与折叠设置 */
    staffMapList;
    /** @type {Object<string, any> | Iterable<any>} 原始数据,元素内需包含待匹配职位名称 */
    rawData;
    /** @type {Set<string>} 待匹配职位名称的集合 */
    _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<string> | Map<string, any>} data - 数据集合
     * @returns {Array<string> | 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<string>} matchedJobs
     */
    _logRegexMatch(reg, matchedJobs) {
      console.log(`${SCRIPT_NAME}:使用正则表达式 ${reg} 成功匹配 {${matchedJobs}}`);
    }
    /**
     * 记录未被匹配的职位信息
     * @param {Array<string>} unmatchedJobs
     */
    _logUnmatch(unmatchedJobs) {
      console.log(`${SCRIPT_NAME}:未被匹配到的职位 {${unmatchedJobs}}`);
    }
    /** 记录插入信息 */
    _logInsert() {
      console.log(`${SCRIPT_NAME}:激活将未被匹配职位插入指定位置`);
    }
  }

  /**
   * 实现网页`infobox`职位信息的排序与折叠,
   * `sub_group`及属其所有的`sub_container`将被视为一个整体进行排序
   */
  class HtmlStaffSorter extends BaseStaffSorter {
    /** @type {Object<string, HTMLElement | Array<HTMLElement>>} 原始职位信息字典 */
    rawData;

    /**
     * @param {HTMLElement} ul - `infobox`
     * @param {StaffMapList} staffMapList - 职位排序与折叠设置
     * @param {Object<string, HTMLElement | Array<HTMLElement>>} 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<string, number>}
     * 为了输出排序索引,将`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<string, string[]>} 分组任务队列,以`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<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 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;
    /* 尝试使用<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;
    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`
    <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.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*)+(?=,)|(['‘’“”])|(?<!['"‘“])(\/[^/'"‘’“”]+\/[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 getViewportHeight() {
    return document.documentElement.clientHeight || document.body.clientHeight;
  }

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

})();