🏠 Home 

Bangumi-Custom Staff Sorting and Collapsing

Customize the sorting and collapsing of staff roles in all types of subjects


Install this script?
  1. // ==UserScript==
  2. // @name Bangumi-Custom Staff Sorting and Collapsing
  3. // @name:zh-CN 班固米-条目职位自定义排序与折叠
  4. // @version 1.4.4-2.0
  5. // @description Customize the sorting and collapsing of staff roles in all types of subjects
  6. // @author weiduhuo
  7. // @namespace https://github.com/weiduhuo/scripts
  8. // @match *://bgm.tv/subject/*
  9. // @match *://bgm.tv/character/*
  10. // @match *://bgm.tv/person/*
  11. // @match *://bgm.tv/settings/privacy*
  12. // @match *://bangumi.tv/subject/*
  13. // @match *://bangumi.tv/character/*
  14. // @match *://bangumi.tv/person/*
  15. // @match *://bangumi.tv/settings/privacy*
  16. // @match *://chii.in/subject/*
  17. // @match *://chii.in/character/*
  18. // @match *://chii.in/person/*
  19. // @match *://chii.in/settings/privacy*
  20. // @grant none
  21. // @license MIT
  22. // @description:zh-CN 对所有类型条目的制作人员信息进行职位的自定义排序与折叠,可在[设置-隐私]页面进行相关设置
  23. // ==/UserScript==
  24. (function () {
  25. 'use strict';
  26. const SCRIPT_NAME = '班固米-职位排序组件';
  27. const CURRENT_DATA_VERSION = '2.0';
  28. const DEBUG = false;
  29. /** 自定义`console` (避免重写妨碍其他脚本正常使用`console`) */
  30. const C = {
  31. log: console.log,
  32. error: console.error,
  33. // DEBUG模式下,阻断console的相关操作
  34. debug: DEBUG ? console.debug : function () {},
  35. time: DEBUG ? console.time : function () {},
  36. timeEnd: DEBUG ? console.timeEnd : function () {},
  37. }
  38. /** 排序延迟时间 */
  39. const SORTING_DELAY = 0; // 数值0仍然有意义,将延迟到所有同步脚本之后执行
  40. /** 防抖延迟时间 */
  41. const DEBOUNCE_DELAY = 500;
  42. /** 是否对职位信息进行了折叠,忽略网页自身`sub_group`的折叠 (依此判断 `更多制作人员` 开关的必要性) */
  43. let hasFolded = false;
  44. /** 尾部折叠图标的激活阈值相对于视口高度的系数 */
  45. const sideTipRate = 0.25;
  46. /** @type {number} 尾部折叠图标的激活行数阈值 */
  47. let sideTipLineThr = null;
  48. /** @type {Array<HTMLElement> | null} 最后一组`sub_group`的数据包 */
  49. let lastGroup = null;
  50. /** `infobox`以被折叠元素结尾 */
  51. let endWithFold = false;
  52. /**
  53. * 图标,已在`loadStaffStyle`中通过父元素类名`staff_sorting_icon`约束所显示的范围
  54. */
  55. const ICON = {
  56. /** 三角形顶点向右,可表展开按键 */
  57. TRIANGLE_RIGHT: `
  58. <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 13 13' height='0.7em'>
  59. <polygon points='0.5,0 12.5,6.5 0.5,13' fill='currentColor' />
  60. </svg>
  61. `,
  62. /** 三角形顶点向下,可表折叠按键 */
  63. TRIANGLE_DOWN: `
  64. <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 13 13' height='0.7em'>
  65. <polygon points='0,0.5 13,0.5 6.5,12.5' fill='currentColor' />
  66. </svg>
  67. `,
  68. /** 三角形顶点向上,可表折叠按键 */
  69. TRIANGLE_UP: `
  70. <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 13 13' height='0.7em'>
  71. <polygon points='0,12.5 13,12.5 6.5,0.5' fill='currentColor' />
  72. </svg>
  73. `,
  74. /** 加号,可表展开按键 */
  75. PLUS: `
  76. <svg xmlns='http://www.w3.org/2000/svg' viewbox='0 0 10 10' width='100%' height='100%'>
  77. <polygon points='1,4 9,4 9,6 1,6' fill='currentColor' />
  78. <polygon points='4,1 6,1 6,9 4,9' fill='currentColor' />
  79. </svg>
  80. `,
  81. };
  82. /**
  83. * 枚举所支持的条目类型
  84. */
  85. const SubjectType = {
  86. // 所支持的类型
  87. ANIME: {en: 'anime', zh: '动画'},
  88. BOOK: {en: 'book', zh: '书籍'},
  89. MUSIC: {en: 'music', zh: '音乐'},
  90. GAME: {en: 'game', zh: '游戏'},
  91. REAL: {en: 'real', zh: '三次元'},
  92. CHARACTER: {en: 'character', zh: '角色'},
  93. PERSON: {en: 'person', zh: '人物'},
  94. /**
  95. * @param {boolean} [isObj=false] - `true`时返回对象序列,`false`时返回英文序列
  96. * @returns {{ en: string, zh: string }[] | string[]}
  97. */
  98. getAll(isObj = false) {
  99. if (isObj) return utils.filterEnumValues(this);
  100. else return utils.filterEnumValues(this).map(item => item.en);
  101. },
  102. /** @returns {string | null} 有效则返回原数值,无效则返回空 */
  103. parse(value) {
  104. if (this.getAll().includes(value)) return value;
  105. return null;
  106. },
  107. needPrase(value) {
  108. return value !== this.CHARACTER.en && value !== this.PERSON.en;
  109. },
  110. };
  111. /**
  112. * 枚举各类型条目的排序折叠功能启用状态
  113. */
  114. const SortEnableState = {
  115. /** 全部功能禁用 */
  116. ALL_DISABLED: 'allDisable',
  117. /** 启用部分功能,仅排序不折叠 */
  118. PARTIAL_ENABLED: 'partialEnable',
  119. /** 启用全部功能 */
  120. ALL_ENABLED: 'allEnable',
  121. /** @returns {Array<string>} */
  122. getAll() {
  123. return utils.filterEnumValues(this);
  124. },
  125. };
  126. /**
  127. * 枚举二次折叠的开关的可选位置 (相对于职位名称)
  128. */
  129. const RefoldSwitchPosition = {
  130. NONE: 'none',
  131. LEFT: 'left',
  132. RIGHT: 'right',
  133. /** @returns {Array<string>} */
  134. getAll() {
  135. return utils.filterEnumValues(this);
  136. },
  137. }
  138. /**
  139. * 管理`localStorage`的键名与初值。
  140. * 键值分为全局配置与各类型条目配置、简单类型与复杂类型
  141. */
  142. const Key = {
  143. /** 键名前缀 */
  144. KEY_PREF: 'BangumiStaffSorting',
  145. /** 数据版本 */
  146. DATA_VERSION: '_dataVersion__',
  147. /** 超过此行数的职位信息将被二次折叠*/
  148. REFOLD_THRESHOLD_KEY: '_refoldThreshold__',
  149. REFOLD_THRESHOLD_OLD_KEY: 'refoldThreshold',
  150. REFOLD_THRESHOLD_DEFAULT: 4,
  151. REFOLD_THRESHOLD_DISABLED: 0,
  152. /** 是否继承历史的匹配记录 */
  153. INHERIT_PREVIOUS_MATCHES_KEY: '_inheritPreMatches__',
  154. INHERIT_PREVIOUS_MATCHES_DEFAULT: true,
  155. /** 二次折叠的开关位置 */
  156. REFOLD_SWITCH_POSITION_KEY: '_refoldSwitchPosition__',
  157. REFOLD_SWITCH_POSITION_DEFAULT: RefoldSwitchPosition.RIGHT,
  158. /** 各类型条目模块的展开状态 */
  159. BLOCK_OPEN_KEY: 'BlockOpen',
  160. BLOCK_OPEN_OLD_KEY: 'blockOpen', // dataVerion < 1.4
  161. BLOCK_OPEN_DEFAULT: false,
  162. /** 各类型条目的功能启用状态 */
  163. ENABLE_STATE_KEY: 'EnableState',
  164. ENABLE_STATE_DEFAULT: SortEnableState.ALL_ENABLED,
  165. /** 各类型条目的自定义排序与折叠 (复杂类型,参见 {@link StaffMapListType}) */
  166. STAFF_MAP_LIST_KEY: 'StaffMapList',
  167. /** 职位排序索引的分组映射 (复杂类型,参见 {@link jobOrderMapType}) */
  168. JOB_ORDER_MAP_KEY: 'JobOrderMap',
  169. /** 当前使用的键值的所属条目类型 (可即时切换) */
  170. _subType: null,
  171. makeKey(key, type = null) {
  172. this.setSubType(type);
  173. if (this.isGlobalData(key)) return `${this.KEY_PREF}_${key}`;
  174. else return `${this.KEY_PREF}_${this._subType}${key}`;
  175. },
  176. setSubType(type) {
  177. if (type) this._subType = type;
  178. },
  179. isComplexData(key) {
  180. return [
  181. this.STAFF_MAP_LIST_KEY, this.JOB_ORDER_MAP_KEY,
  182. ].includes(key);
  183. },
  184. isGlobalData(key) {
  185. return [
  186. this.DATA_VERSION, this.INHERIT_PREVIOUS_MATCHES_KEY,
  187. this.REFOLD_THRESHOLD_KEY, this.REFOLD_THRESHOLD_OLD_KEY,
  188. this.REFOLD_SWITCH_POSITION_KEY,
  189. ].includes(key);
  190. }
  191. }
  192. /**
  193. * 配置存储,提供`localStorage`的接口。
  194. * 仅对简单数据类型进行解析、编码、缓存,复杂数据类型放权给外部
  195. * (为便于进行防抖动绑定,由对象类型改为静态类实现)
  196. */
  197. class Store {
  198. /** 数据缓存,仅对简单类型的键值 */
  199. static _cache = {};
  200. /** 需要对数据进行更新 */
  201. static updateRequired = false;
  202. /** 定义防抖逻辑的占位 (忽略短时间内改变多对键值的极端情况) */
  203. static debouncedSet;
  204. /** 为缺损的配置进行初始化 */
  205. static initialize() {
  206. // 缓存初始化
  207. Store._cache = {};
  208. // 绑定防抖逻辑,确保 this 指向 Store
  209. Store.debouncedSet = debounce(Store._set.bind(this));
  210. // 全局配置初始化
  211. ['REFOLD_THRESHOLD', 'INHERIT_PREVIOUS_MATCHES', 'REFOLD_SWITCH_POSITION'].forEach(
  212. (key) => Store._setDefault(key)
  213. );
  214. // 局部配置初始化
  215. SubjectType.getAll().forEach((type) => {
  216. ['BLOCK_OPEN', 'ENABLE_STATE'].forEach(
  217. (key) => Store._setDefault(key, type)
  218. );
  219. });
  220. // 检查数据版本
  221. if (Store.get(Key.DATA_VERSION) !== CURRENT_DATA_VERSION) {
  222. Store.updateRequired = true;
  223. Store.preProcessConfigUpdate();
  224. }
  225. }
  226. /** 版本变更,重要配置更新,主任务前执行 */
  227. static preProcessConfigUpdate() {
  228. // 键名变更,重要数据进行继承
  229. if (Store.get(Key.DATA_VERSION) < '1.4') { // 已涵盖 null
  230. // 变更了全局配置的键名格式
  231. const preRefoldThreshold = Store.get(Key.REFOLD_THRESHOLD_OLD_KEY);
  232. if (preRefoldThreshold !== null) {
  233. Store.set(Key.REFOLD_THRESHOLD_KEY, preRefoldThreshold);
  234. }
  235. }
  236. }
  237. /** 版本变更,次要配置更新,主任务后执行 */
  238. static postProcessConfigUpdate() {
  239. if (!Store.updateRequired) return;
  240. const preDataVer = Store.get(Key.DATA_VERSION);
  241. C.log(`${SCRIPT_NAME}:数据版本 ${preDataVer} > ${CURRENT_DATA_VERSION} 更新中...`);
  242. // [固定]对默认设置的解析缓存进行更新
  243. SubjectType.getAll().forEach((type) => {
  244. const jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, type);
  245. if (jsonStr) return; // 跳过自定义设置
  246. const staffMapList = new StaffMapList(type);
  247. staffMapList.initialize(null, true);
  248. if (Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY)) staffMapList.inheritPreMatches();
  249. staffMapList.saveResolvedData();
  250. });
  251. // [暂时]删除旧有的键值对,低价值数据直接丢弃
  252. if (preDataVer < '1.4') {
  253. // 已完成继承
  254. Store.remove(Key.REFOLD_THRESHOLD_OLD_KEY);
  255. // 模块的展开状态键名变更
  256. SubjectType.getAll().forEach((type) => {
  257. Store.remove(Key.BLOCK_OPEN_OLD_KEY, type);
  258. });
  259. // 旧有的异步通信接口
  260. Store.removeByPrefix(`${Key.KEY_PREF}Interface`);
  261. }
  262. // 更新全部完毕后再修改版本号,防止被提前终止
  263. Store.set(Key.DATA_VERSION, CURRENT_DATA_VERSION);
  264. C.log(`${SCRIPT_NAME}:数据更新成功`);
  265. }
  266. static removeByPrefix(pref) {
  267. // 将键名缓存到数组中,避免遍历过程中修改 localStorage 导致索引混乱
  268. const keysToRemove = [];
  269. for (let i = 0; i < localStorage.length; i++) {
  270. const key = localStorage.key(i);
  271. if (key && key.startsWith(pref)) keysToRemove.push(key);
  272. }
  273. // 遍历并删除匹配的键
  274. keysToRemove.forEach((key) => localStorage.removeItem(key));
  275. }
  276. static _setDefault(_key, type = null) {
  277. if (this.get(Key[`${_key}_KEY`], type) === null)
  278. this.set(Key[`${_key}_KEY`], Key[`${_key}_DEFAULT`]);
  279. }
  280. static set(key, value, type = null, isHighFreq = false) {
  281. if (isHighFreq) this.debouncedSet(key, value, type);
  282. else this._set(key, value, type);
  283. }
  284. static _set(key, value, type = null) {
  285. Key.setSubType(type);
  286. const fullKey = Key.makeKey(key);
  287. if (!Key.isComplexData(key)) {
  288. this._cache[fullKey] = value; // 同步到缓存 (注意缓存的需是原始数据)
  289. value = JSON.stringify(value);
  290. }
  291. localStorage.setItem(fullKey, value);
  292. }
  293. static get(key, type = null) {
  294. Key.setSubType(type);
  295. const fullKey = Key.makeKey(key);
  296. // 简单数据类型,命中缓存
  297. if (!Key.isComplexData() && Store._isCacheHit(fullKey)) {
  298. // C.debug(`HIT CHACHE - ${fullKey}: ${this._cache[fullKey]}`);
  299. return this._cache[fullKey];
  300. }
  301. // 无缓存,读取并缓存
  302. const value = localStorage.getItem(fullKey);
  303. if (Key.isComplexData(key)) return value;
  304. const parsedValue = JSON.parse(value);
  305. this._cache[fullKey] = parsedValue;
  306. return parsedValue;
  307. }
  308. static remove(key, type = null) {
  309. Key.setSubType(type);
  310. const fullKey = Key.makeKey(key);
  311. // 同时删除缓存与数据
  312. delete this._cache[fullKey];
  313. localStorage.removeItem(fullKey);
  314. }
  315. static _isCacheHit(fullKey) {
  316. return Object.prototype.hasOwnProperty.call(this._cache, fullKey);
  317. }
  318. }
  319. /**
  320. * 包含`RegExp`元素的`JSON`格式化字符串。
  321. * 1. 用于`StaffMapList`解析、编码,其最短的有效字符串为`"[]"`,示设置空缺。
  322. * 2. 用于`JobOrderMap`编码,由于其只在规定区域内还有`RegExp`元素,
  323. * 全局使用`reviver`解析性能较低,故单独处理
  324. */
  325. const RegexInJSON = {
  326. regexPattern: /^\/(.+)\/([gimsuy]*)$/,
  327. /**
  328. * 解析`StaffMapList`字符串。
  329. * 用于初步解析与有效性检测,
  330. * 更进一步的解析,将在`StaffMapList`中进行。
  331. * 仅检查:
  332. * 1. 是否满足`JSON`格式
  333. * 2. 是否为数组类型
  334. * 3. 字符串样式的正则表达式,是否满足规定格式
  335. * @returns {Array | null} 空值表失败
  336. */
  337. parse(text) {
  338. let parsedData;
  339. try {
  340. parsedData = JSON.parse(text, this._reviver);
  341. } catch (e) {
  342. C.error(`${SCRIPT_NAME}:StaffMapList 解析失败 - ${e}`);
  343. return null;
  344. }
  345. if (!utils.isArray(parsedData)) {
  346. C.error(`${SCRIPT_NAME}:StaffMapList 类型错误 - 非数组类型`);
  347. return null;
  348. }
  349. return parsedData;
  350. },
  351. /**
  352. * 将`RegexInList`转为`JSON`格式化字符串
  353. * @param {string | number | undefined} space
  354. */
  355. stringify(data, space) {
  356. return JSON.stringify(data, this._replacer, space);
  357. },
  358. /** 解析`JSON`字符串中的正则表达式 */
  359. _reviver(key, value) {
  360. if (utils.isString(value) && value.startsWith('/')) {
  361. const match = value.match(RegexInJSON.regexPattern);
  362. if (!match) {
  363. throw new Error(`正则表达式 "${value}" 不符合 ${RegexInJSON.regexPattern} 格式`);
  364. } try {
  365. return new RegExp(match[1], match[2]);
  366. } catch (e) {
  367. throw new Error(`正则表达式 "${value}" 非法 - ${e}`);
  368. }
  369. }
  370. return value;
  371. },
  372. /** 将正则表达式转化为字符串,以满足`JSON`格式 */
  373. _replacer(key, value) {
  374. if (utils.isRegExp(value)) return value.toString();
  375. return value;
  376. },
  377. }
  378. /**
  379. * 职位排序与折叠设置,是职位排序与默认折叠的职位二者信息的复合 (此为原罪)。
  380. * 其有两种表现形式:
  381. * 1. `StaffMapList`,即`this.data`,面向用户,结构简明直接,供用户自定义编辑。
  382. * 2. `JobOrderMap`,面向程序,来自对`StaffMapList`的解析与历史匹配记录。
  383. * 由`this.{exactOrderMap, regexOrderList, insertUnmatched, insertSubGroup}`组成
  384. */
  385. class StaffMapList {
  386. /**
  387. * @typedef {string | RegExp | number} MatchJob
  388. * - 匹配职位名称,其中`number`表示紧邻其后`RegExp`的匹配优先级 (解析时实则为弱关联,不需严格相邻)
  389. * @typedef {Array<MatchJob | [boolean | MatchJob, ...MatchJob[]]>} StaffMapListType
  390. * - 其中`boolean`表示子序列内的职位是否默认折叠,缺损值为`false`,需位于子序列的首位才有意义
  391. * (默认配置中`,,`表示在`JSON`数组中插入`null`元素,用于输出格式化文本时标记换行)
  392. *
  393. * @typedef {Object} jobOrderMapType
  394. * - 是一张分组映射表,用于映射匹配职位的序列号,初值为`StaffMapList`的解析数据。
  395. * 其中序列号`number`的大小表示该匹配职位的次序,奇偶表示该匹配职位是否默认折叠。
  396. * (由原先的`Map<MatchJob, number>`整表改进而为分组表,极大减少了解析与维护开销)
  397. * @property {Object<string, number>} exact - 精确匹配,
  398. * 以及用于缓存正则匹配、插入未被匹配的历史记录,并于任务结束后写入`Store`
  399. * @property {Array<[RegExp, number]>} regex - 正则匹配,
  400. * 基于需遍历使用、对象类型的键不得为字符串以外的类型,采用键值对数组
  401. * @property {Object<string, number>} insert - 插入匹配,
  402. * 目前有插入未被匹配的职位信息、插入二级职位引导信息 (后者的优先级更高,且高于其他匹配)
  403. */
  404. /** 未被匹配职位信息的插入前缀`'=='` */
  405. static _insertUnmatchedPref = '==';
  406. /** 二级职位引导信息的插入前缀`'>>'` */
  407. static _insertSubGroupPref = '>>';
  408. /** 正则匹配优先级缺损值 */
  409. static _defaultRegexPriority = 100;
  410. /** 懒加载的默认配置 */
  411. static _defaultLazyData = {
  412. [SubjectType.ANIME.en]: () => [,
  413. "===【注释】正则匹配优先级:缺损-100, dd-优先于缺损, 20d-`宣`相关, 30d-`3D`相关, 40d-`设`相关, 50d-`画`相关, 60d-`音`相关, 70d-`色`相关, 80d-`制`相关, 9dd-兜底 ===",,
  414. ,
  415. "中文名", /^(罗马|拼音|索引)名$/, [true, "英文名"],,
  416. "类型", "适合年龄", /地区/, "语言", "对白", /话数/, [true, "季数"], /(片|时)长/,,
  417. "放送开始", /开始|放送|播出/, 90, /放送星期/, "放送时间", /(上|公)映/, /发售/,,
  418. ,
  419. "团长", "超监督", "总监督", "总导演",,
  420. "导演", "联合导演", "副导演", [true, /^((OP|ED).*)?(执行|主任).*导演/, /导演助/],,
  421. "系列监督", "原作", "原著", [true, /连载|連載/], "原作插图", [true, "原作协力", /原作/],,
  422. "原案", /(人物|角色).*原案/, 400, /(人物|角色).*(设|設)/, [true, 983, /人物|角色/],,
  423. "系列构成", "剧本统筹", /系列|构成|大纲/,,
  424. 201, /(脚|剧)本|编剧|故事|主笔|文(艺|芸|案)|脚色/, 990, /内容/, [true, 601, /对白|台词/],,
  425. 20, /分镜|台本/, "OP・ED 分镜", "氛围稿", /氛围|Image ?Board|イメージボード/i,,
  426. /^(主|总)演出$/, "演出", "演出制作", /(片(头|尾)|OP|ED)演出/, [true, "演出助理", /^演出助/],,
  427. ,
  428. /^(主要?动画师|总作画)$/, "总作画监督", /(作|原)画总监/, [true, "总作画监督助理"], /机械导演/,,
  429. [false, "作画监督"], 501, /(机械|动作|特效)?(作|原|操)画.*(导演|监)|作监$/,,
  430. [true, "作画监督助理", 500, /作画监督(助|.+佐|协力)/],,
  431. 989, /原案/, 405, /(设|設)定/, , [true, 989, /机械/],,
  432. "道具设计", 507, /(设|設)计|Design|デザイン/i, /字(符|体)|icon|アイコン|logo|ロゴ/i,,
  433. 507, /构图|Layout/i, [true, 502, /操画/], [false, "原画", "原画师"],,
  434. [true, "第二原画", /(作|原)画监修/, 508, /原画|修型/],,
  435. /作画特殊?效果?/, 987, /作画|绘制/, /数码绘图/, [true, "扫描", "描线"],,
  436. [false, 500, /动画?.*检查?/], [true, "补间动画"], 508, /(动|動)画/,,
  437. ,
  438. /色彩(导演|监督|总监)/, /色彩.*设(计|定)/, [true, 90, /色彩设计.+佐/],,
  439. "色彩演出", /色彩((脚|剧)本|故事版)|Color ?Script|カラースクリプト/i,,
  440. [false, /色彩?指定/, 700, /(色|仕).?(检|検|校)/], [true, "上色", 701, /色彩|仕上/, 984, /色/],,
  441. ,
  442. /(美|艺)(术|術).*(导演|(監|监)督|总监|主管|括)/, [true, 90, /美(术|術)(監|监)督(助|.+佐)/],,
  443. "主美", 406, /概念(美|艺)术|视觉概念/, /美术设(计|定)/, /美(术|術)(board|板|ボード)/,,
  444. /(场|布)景设计/, [false, "背景美术", /^美(术|術)$/, 506, /(场|布|绘|制)景/, 983, /景/],,
  445. [false, 507, /(美|艺)(术|術)|アート|ART WORK/i],,
  446. [true, /美(术|術)(辅|补)佐/], [true, /原图整理/], [/工艺|创意/],,
  447. ,
  448. 301, /(CGI?|3D|三维|立体|电脑).*(导演|(監|监)督|总监|主管|统|ディレクター)/, /^(3D ?CGI?|三维)$/,,
  449. /(CGI?|3D).*演出/, /(3D|三维) ?(layout|LO|构图)/i, /(CGI?|3D).*(设计|(美|艺)术|アート)/,,
  450. /(建模|(模|造)(型|形)|モデリング)(导演|监督|总监|主管|ディレクター)/, [true, /(建模|(模|造)(型|形))/],,
  451. /绑(骨|定)(监督|总监)/, [true, /绑|骨/, 303, /材质|贴图|テクスチャー/],,
  452. [true, /数字背景/, 988, /地形|建筑|资(源|产)/, /管线|Pipeline/i, 10, /LookDev|^UE/, "工程师"],,
  453. /(动作|#戏|战斗.*)(导演|监督|主管)/, /动作设计/, [true, 504, /动作|表情/], /帧|动作?捕捉?/,,
  454. /(CGI?|3D).*原画/, /(CGI?|3D|三维).*(动画|アニメーション)/,,
  455. /(CGI?|3D|三维)特效/, 303, /(CGI?|3D|三维|电脑图形).*制作/,,
  456. [true, 302, /(CGI?|3D|三维|电脑图形).*(制作人|プロデュース)/, /(CGI?|3D|电脑图形).*制片人/],,
  457. [true, 980, /CGI?|3D|三维|立体|电脑|コンピュータ/],,
  458. /Motion Graphic|モーショングラフィック/i,,
  459. ,
  460. 300, /2DCG.*导演/, /2D(w| ?works|ワークス)/i, /UI|图形界面/, 980, /2D|二维/,,
  461. ,
  462. "摄影监督", "副摄影监督", 607, /(中|后)(期|制).*(导演|监督|总监|管|统)/,,
  463. [false, /張り込み|ハリコミ/, /摄(影|制)/], [true, /线拍/],,
  464. [true, 608, /(中|后)期/, /(摄|撮)|合成|照明|光(源|照)|灯光|映像/, 302, /渲染|解算/],,
  465. /((效|効)|特技)(导演|(監|监)督|总监|主管)/, [true, 609, /(效|効)|特技|Effect|监视器|モニター/i],,
  466. /(V|C)?FX/, [true, 980, /视觉/], [true, 987, /技(术|術)/],,
  467. ,
  468. [false, "剪辑", "编集"], [true, 609, /剪辑|(编|編)集/, /联机/], [true, "场记"],,
  469. /标题|字幕|タイトル/, /(冲|洗)印|現像|デジタルラボ|介质/, "转录",,
  470. ,
  471. /(音(响|频)|声音).*(导演|监督|总监|主管)/, "音响", [true, /音响/], "音效", [true, 600, /音效|拟音/],,
  472. [false, "录音"], [true, "录音助理", 600, /录|録/], [true, /声音/, 604, /音频|(整|调)音|母带|混|声|調整/],,
  473. /(配音|演员).*(导演|监)/, "主演", "配音", /^(cast(ing)?|キャスティング)$/i,,
  474. [true, 603, /配音|出演|演(员|出)|キャスティング/], [true, /(配音|演员|角色).*(管|统)|选角/],,
  475. ,
  476. "音乐", /音乐(导演|监督|总监)/, [true, "音乐制作人", "音乐制作", "音乐助理"],,
  477. [true, /音乐/, 606, /音|乐/, 605, /奏/],,
  478. "主题歌演出", [true, "主题歌作词", "主题歌作曲", "主题歌编曲"],,
  479. "插入歌演出", [true, "插入歌作词", "插入歌作曲", "插入歌编曲"],,
  480. /片头曲(.*演(出|唱))?$/, [true, /片头曲/], /片尾曲(.*演(出|唱))?$/, [true, /片尾曲/],,
  481. [true, 603, /作(词|詞)/, 603, /歌|曲/, /song/i], [true, /(选|選)曲/],,
  482. ,
  483. "企画", [true, "企画协力"], "企划制作人", 202, /企(画|划)|策划|战略/,,
  484. 980, /出品/, [true, "总监制", 989, /监制/],,
  485. 800, /总制片|(统|統|概)括/, "制片人", "制片", "执行制片人", [true, 808, /制片|プロデューサー/],,
  486. ,
  487. [true, 980, /主编/, 989, /编辑|ライター/, /排版|数据|翻译|清书/, 980, /审|检验/],,
  488. [true, 981, /发行/, 200, /宣(传|伝|发)?|推广|广告|広報|パンフレット/],,
  489. [true, 402, /市场|运营|营销|销售|セールス|商(务|业)|(商|产)品|パッケージ|衍生|周边|授权|ライセンス|品牌|IP/],,
  490. [true, 987, /人事|法务|维权|(财|税)务|后(勤|盾)|支援|^助理/, /(水滴|深空)攻坚/, 989, /支持/],,
  491. 509, /取材|考(据|证|証)/, [true, 987, /校/],,
  492. ,
  493. "动画制片人",,
  494. [true, 809, /(制|製)作(管理|主任|担当|デスク|总|総|.?监|人)/, "担当制作"],,
  495. [true, 989, /统|統|管理/, "计划管理", 801, /制作助./, 980, /プロダクション|マネージャー|经理|PM/],,
  496. [true, 403, /(设|設)定(制作|管理)/], [true, "制作进行", "制作进行协力", "制作协调"],,
  497. "动画制作", 989, /制作/, /定格动画|(ねんど|パペット)アニメ/,,
  498. 980, /制作(协|協)力/, 990, /(协|協)力?/, 30, /手语|发音/, 987, /(监|監)修|顾问|指导/,,
  499. [false, /同人|题字/, /谢|Thanks/i],,
  500. ,
  501. "别名", /.+名$/,,
  502. /官方网站|官网|公式|HP/, [true, 90, /(官方网站|官网|公式|HP).*(备份|制作)/], /推特|Twitter|^X$/i, "微博",,
  503. "发行", "开发", "播放电视台", "其他电视台", /bilibili/i, 609, /网络|播放|配(给|給|送|信)?|番|版/,,
  504. "播放结束", /结束/,,
  505. /JAN(码|番号)|imdb/i, "链接", "价格",,
  506. ,
  507. "其他", /其他/, [true, "备注"],,
  508. [false, "===此处插入未被匹配的职位==="],,
  509. 10, /许可证|备案号/, 980, /官方(支持|伙伴)?/,,
  510. "制作", "製作", "製作协力", /製作委員会|(制作)?著作|出品方?$|版权/, "Copyright",
  511. ],
  512. [SubjectType.BOOK.en]: () => [,
  513. "中文名", /名/,,
  514. [false, "===此处插入未被匹配的职位==="],,
  515. ">>>此处插入二级职位引导>>>",,
  516. "ISBN",
  517. ],
  518. [SubjectType.MUSIC.en]: () => [,
  519. "制作人",,
  520. "艺术家", "作词", "作曲", "编曲",,
  521. "脚本", "声乐", "乐器", "混音", "母带制作",,
  522. "插图", "原作", "出版方", "厂牌",
  523. ],
  524. };
  525. /** @type {StaffMapListType} 主数据 */
  526. data = [];
  527. /** @type {Object<string, number>} 精确匹配的映射表,同时用于缓存匹配记录 */
  528. exactOrderMap = {};
  529. /**
  530. * @type {Array<[string | RegExp, number, number]>}
  531. * 正则匹配的映射序列,子序列为正则表达式、序列号、匹配优先级
  532. * (允许正则表达式适时活化)
  533. */
  534. regexOrderList = [];
  535. /** 是否为默认数据 */
  536. isDefault = null;
  537. /** 重新解析数据 */
  538. newResolvedData = false;
  539. /** 新记录的匹配计数器 */
  540. newlyMatchedCount = 0;
  541. /** 是否开启折叠功能 */
  542. foldable = false;
  543. /** 是否执行了折叠功能 */
  544. hasFolded = false;
  545. /** 未被匹配职位信息的插入位置 */
  546. insertUnmatched = Infinity;
  547. /** 二级职位引导信息的插入位置 (一旦启用,即为最高优先级) */
  548. insertSubGroup = null;
  549. /** 默认配置格式化文本的缓存 */
  550. _defaultTextBuffer = null;
  551. constructor(subType) {
  552. /** 所属条目类型(不可变更)*/
  553. this.subType = subType; // 小心 Key._subType 被其他模块切换
  554. /** 序列号计数器 */
  555. this.index = 0;
  556. /** 排序功能启用状态 */
  557. this.sortEnableState = Store.get(Key.ENABLE_STATE_KEY, this.subType);
  558. /** `_resolveData`中当前正则匹配的优先级 */
  559. this._priority = StaffMapList._defaultRegexPriority;
  560. }
  561. /**
  562. * 依据`EnableState`进行初始化,使其具备职位匹配的能力。
  563. * 若仅为获取`StaffMapList`格式化字符串,则不需要执行本初始化。
  564. * @param {any[] | null | undefined} [parsedData=null]
  565. * - 由设置界面传入的被初步解析的数据
  566. * @param {boolean} [forcedEnable=false]
  567. * - 是否开启强制模式 (默认关闭),强制模式下关闭`EnableState`检查并禁用折叠
  568. */
  569. initialize(parsedData = undefined, forcedEnable = false) {
  570. // 非强制模式下,禁止了排序功能
  571. if (!forcedEnable && this.sortEnableState === SortEnableState.ALL_DISABLED) return;
  572. // 是否开启折叠功能
  573. this.foldable = !forcedEnable && this.sortEnableState === SortEnableState.ALL_ENABLED;
  574. this._init();
  575. // 尝试优先获取存储的解析数据
  576. if (parsedData === undefined) {
  577. if (this._loadResolvedData()) return;
  578. else this._init(); // 需再次初始化
  579. }
  580. // 在设置界面保存设置时进行强制重新解析,或者在条目界面进行初始化解析
  581. if (!this._loadData(parsedData)) {
  582. this._setDefault();
  583. this.isDefault = true;
  584. }
  585. // 解析数据
  586. this._resolveData();
  587. this.data = null; // 释放原始数据的存储空间
  588. this.newResolvedData = true;
  589. // 替换 Infinity (替换为当前的尾值,来免除对 Infinity 在 JSON 序列化时需单独处理)
  590. if (!utils.isFinity(this.insertUnmatched)) {
  591. // 替换默认值
  592. this.insertUnmatched = this.index << 1;
  593. // 若启用了插入二级引导,且设定为尾值,则替换。以保证二级引导插入末尾的优先级
  594. if (utils.isNumber(this.insertSubGroup) && this.insertUnmatched - this.insertSubGroup === 2) {
  595. [this.insertSubGroup, this.insertUnmatched] = [this.insertUnmatched, this.insertSubGroup];
  596. }
  597. }
  598. }
  599. /** 数据参数初始化 */
  600. _init() {
  601. this.index = 0;
  602. this.exactOrderMap = {};
  603. this.regexOrderList = [];
  604. this.insertUnmatched = Infinity;
  605. this.insertSubGroup = null;
  606. this._defaultTextBuffer = null;
  607. this._priority = StaffMapList._defaultRegexPriority;
  608. }
  609. /**
  610. * 表示未经`initialize`,或是空缺设置,将关闭脚本的职位排序。
  611. * 空缺设置有两种独立开启途径:
  612. * 1. `EnableState = "allDisable"`
  613. * 2. `StaffMapListJSON = "[]"`
  614. */
  615. isNull() { return this.index === 0; }
  616. /**
  617. * 判断职位是否默认折叠
  618. * @param {string} job
  619. */
  620. isFoldedJob(job) {
  621. if (!this.foldable) return false;
  622. const index = this.exactOrderMap[job];
  623. const r###lt = (index & 1) === 1; // 奇数代表默认折叠
  624. if (!this.hasFolded && r###lt) this.hasFolded = true;
  625. return r###lt;
  626. }
  627. /** 保存自定义的数据 */
  628. saveData(jsonStr, parsedData) {
  629. this.isDefault = false;
  630. Store.set(Key.STAFF_MAP_LIST_KEY, jsonStr, this.subType);
  631. C.log(jsonStr);
  632. C.log(`${SCRIPT_NAME}:保存自定义 ${this.subType}StaffMapList 数据`);
  633. this.initialize(parsedData, true);
  634. if (Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY)) this.inheritPreMatches();
  635. this.saveResolvedData();
  636. }
  637. /** 恢复默认数据的设置 */
  638. resetData() {
  639. this.isDefault = true;
  640. Store.remove(Key.STAFF_MAP_LIST_KEY, this.subType);
  641. C.log(`${SCRIPT_NAME}:恢复默认 ${this.subType}StaffMapList 数据`);
  642. this.initialize(null, true); // 传入null表强制重新解析
  643. if (Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY)) this.inheritPreMatches();
  644. this.saveResolvedData();
  645. }
  646. /** 继承历史的匹配记录 */
  647. inheritPreMatches() {
  648. this.newResolvedData = true;
  649. this.newlyMatchedCount = 0;
  650. // 获取历史的匹配记录
  651. const jsonStr = Store.get(Key.JOB_ORDER_MAP_KEY, this.subType);
  652. if (!jsonStr) return;
  653. let preMatches;
  654. try {
  655. /** @type {jobOrderMapType} */
  656. const parsedMap = JSON.parse(jsonStr);
  657. preMatches = parsedMap.exact;
  658. } catch { return; }
  659. // 不进行排序,仅遍历匹配
  660. const sorter = new BaseStaffSorter(this, preMatches);
  661. sorter.scan();
  662. sorter.printRegexMatchLog();
  663. sorter.printUnmatchLog();
  664. if (this.newlyMatchedCount) {
  665. C.log(`${SCRIPT_NAME}:从 ${this.subType} 历史匹配记录中继承 ${this.newlyMatchedCount} 项匹配`);
  666. }
  667. }
  668. /**
  669. * 组合并保存解析数据`jobOrderMap`
  670. * @param {boolean} [inSubject=false] - 是否为条目界面操作
  671. */
  672. saveResolvedData(inSubject = false) {
  673. // 空缺设置
  674. if (this.isNull()) {
  675. if (this.sortEnableState !== SortEnableState.ALL_DISABLED) {
  676. Store.remove(Key.JOB_ORDER_MAP_KEY, this.subType);
  677. } return;
  678. }
  679. // 解析数据未更新
  680. if (!this.newlyMatchedCount && !this.newResolvedData) return;
  681. // 组合解析数据
  682. this._sortExactOrderMap(inSubject || !Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY));
  683. const jobOrderMap = {
  684. 'exact': this.exactOrderMap,
  685. 'regex': this.regexOrderList,
  686. 'insert': { [StaffMapList._insertUnmatchedPref]: this.insertUnmatched }
  687. };
  688. if (utils.isNumber(this.insertSubGroup)) {
  689. jobOrderMap.insert[StaffMapList._insertSubGroupPref] = this.insertSubGroup;
  690. }
  691. // JSON 序列化后进行存储
  692. const jsonStr = RegexInJSON.stringify(jobOrderMap);
  693. Store.set(Key.JOB_ORDER_MAP_KEY, jsonStr, this.subType);
  694. C.log(`${SCRIPT_NAME}:保存 ${this.subType}JobOrderMap 解析缓存`);
  695. }
  696. /** 恢复默认配置 */
  697. _setDefault() {
  698. // 该类型条目未有默认设置
  699. if (!StaffMapList._defaultLazyData[this.subType]) this.data = [];
  700. // 懒加载默认设置
  701. else this.data = StaffMapList._defaultLazyData[this.subType]();
  702. }
  703. /**
  704. * 尝试载入自定义的数据,并作初步解析
  705. * @param {any[] | null} [parsedData=null]
  706. */
  707. _loadData(parsedData = null) {
  708. const jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, this.subType);
  709. if (!jsonStr) return null; // 键值为空,表示用户启用默认设置
  710. if (!parsedData) parsedData = RegexInJSON.parse(jsonStr);
  711. if (!parsedData) {
  712. // 通过UI进行的配置一般不可能发生
  713. C.error(
  714. `${SCRIPT_NAME}:自定义 ${this.subType}StaffMapList 解析失败,将使用脚本默认的数据`
  715. );
  716. return false;
  717. }
  718. /* 修复外层重复嵌套 `[]` 的形式,例如 [["", [true, ""], ""]]
  719. * 同时区分形如 [[true, "", ""]] 此类不需要降维的情形,
  720. * 忽略存在的漏洞:形如 [[true, "", [true, ""], ""]] 将无法降维 */
  721. if (
  722. parsedData.length === 1 &&
  723. utils.isArray(parsedData[0]) &&
  724. !utils.isBoolean(parsedData[0][0])
  725. ) {
  726. parsedData = parsedData[0];
  727. }
  728. this.isDefault = false;
  729. this.data = parsedData;
  730. return true;
  731. }
  732. /** 完全解析数据,拆解为`jobOrderMap` */
  733. _resolveData() {
  734. for (let item of this.data) {
  735. let isFolded = false;
  736. if (utils.isArray(item)) {
  737. if (!item.length) continue;
  738. // 对数组进行完全展平,提高对非标多维数组的兼容性
  739. item = item.flat(Infinity);
  740. /* 对于标准格式,仅当 Boolean 为一级子序列的首元素时,对该子序列的全部元素生效
  741. * 此时更广义的表述为,仅当 Boolean 为一级子序列的最左节点时,对该子序列的全部元素生效 */
  742. if (utils.isBoolean(item[0])) {
  743. // 始终解析折叠信息,是否最终执行通过 foldable 进行限制
  744. if (item[0]) isFolded = true;
  745. item.shift(); // 移除第一个元素,替代 slice(1)
  746. }
  747. this._processMatchers(isFolded, ...item);
  748. } else {
  749. this._processMatchers(isFolded, item);
  750. }
  751. }
  752. this.regexOrderList.sort((a, b) => a[2] - b[2]); // 对正则匹配进行优先级排序
  753. if(!this.isNull()) C.debug(`经过解析初始化 ${this.subType}StaffMapList`);
  754. }
  755. /** 尝试载入存储的解析数据`jobOrderMap` */
  756. _loadResolvedData() {
  757. const jsonStr = Store.get(Key.JOB_ORDER_MAP_KEY, this.subType);
  758. if (!jsonStr) return null; // 键值为空,表示存储数据为空
  759. try {
  760. /** @type {jobOrderMapType} */
  761. const parsedMap = JSON.parse(jsonStr);
  762. this.exactOrderMap = parsedMap.exact;
  763. this.regexOrderList = parsedMap.regex;
  764. // 活化正则表达式 (改为适时活化)
  765. // for (const item of this.regexOrderList) {
  766. // const match = item[0].match(RegexInJSON.regexPattern);
  767. // item[0] = RegExp(match[1], match[2]);
  768. // }
  769. this.insertUnmatched = parsedMap.insert[StaffMapList._insertUnmatchedPref];
  770. this.insertSubGroup = parsedMap.insert[StaffMapList._insertSubGroupPref];
  771. if (!utils.isNumber(this.insertUnmatched)) {
  772. throw new Error(`插入匹配的序列号 ${this.insertUnmatched} 无效`); // 必需的数据
  773. }
  774. } catch (e) {
  775. C.error(
  776. `${SCRIPT_NAME}:${this.type}JobOrderMap 缓存数据损坏,将对 staffMapList 重新进行解析 - ${e}`
  777. );
  778. return false;
  779. }
  780. this.regexOrderList ??= []; // 允许缺损
  781. this.index = Infinity; // 无实义,仅表完成初始化,作用于 isNull
  782. C.debug(`经过读取缓存初始化 ${this.subType}StaffMapList`);
  783. return true;
  784. }
  785. /**
  786. * 处理一组匹配,用于`_resolveData`,
  787. * 使得`jobOrderMap.number`同时表示序列号与折叠状态
  788. * @param {boolean} isFolded
  789. * @param {...MatchJob} matchers
  790. */
  791. _processMatchers(isFolded, ...matchers) {
  792. for (const matcher of matchers) {
  793. const matcherType = StaffMapList.getMatcherType(matcher);
  794. if (matcherType === 0 || matcher === '') continue;
  795. // 获取正则匹配的优先级
  796. if (matcherType === 3) {
  797. this._priority = matcher;
  798. continue;
  799. };
  800. // 生成序列号
  801. let _index = this.index++ << 1;
  802. if (isFolded) _index++; // 默认折叠则为奇数,相否则为偶数
  803. // 构建正则表达式引用列表
  804. if (matcherType === 2) {
  805. this.regexOrderList.push([matcher, _index, this._priority]);
  806. this._priority = StaffMapList._defaultRegexPriority;
  807. continue;
  808. }
  809. // 仅剩余 matcherType === 1
  810. const prefixType = StaffMapList.getPrefixType(matcher);
  811. switch (prefixType) {
  812. // 精确匹配,构建哈希表
  813. case 0:
  814. this.exactOrderMap[matcher] = _index;
  815. break;
  816. // 插入匹配,未被匹配职位信息
  817. case 1:
  818. this.insertUnmatched = _index;
  819. C.debug(`insertUnmatched: "${matcher}", index: ${_index}}`);
  820. break;
  821. // 插入匹配,二级职位引导信息
  822. case 2:
  823. _index = _index & ~1; // 消除折叠性
  824. this.insertSubGroup = _index;
  825. C.debug(`insertSubGroup: "${matcher}", index: ${_index}}`);
  826. break;
  827. }
  828. }
  829. }
  830. /**
  831. * 对精确映射表按序列号进行排序
  832. * @param {boolean} [useMerge=false] - 是否使用归并排序
  833. */
  834. _sortExactOrderMap(useMerge = false) {
  835. if (this.newlyMatchedCount === 0) return;
  836. let arr = Object.entries(this.exactOrderMap);
  837. const compareFn = (a, b) => a[1] - b[1];
  838. if (useMerge) {
  839. // 1. 对无序部分排序后,再进行归并排序
  840. const sortedLength = arr.length - this.newlyMatchedCount;
  841. const unsortedPart = arr
  842. .slice(sortedLength)
  843. // .filter((value) => value[1] !== this.insertUnmatched) // 过滤未被匹配的职位
  844. .sort(compareFn);
  845. arr = utils.mergeSorted(arr, unsortedPart, sortedLength, this.newlyMatchedCount, compareFn);
  846. } else {
  847. // 2. 整体排序 (在继承匹配记录时,无序部分占比高)
  848. arr.sort(compareFn);
  849. }
  850. this.exactOrderMap = Object.fromEntries(arr);
  851. }
  852. /**
  853. * 将数据转化为格式化文本 (有别于`StaffMapListJSON`)
  854. * 用于设置内的显示与编辑,自定义数据与默认数据二者格式化有别
  855. * @returns {string} 格式化文本
  856. */
  857. formatToText(useDefault) {
  858. let jsonStr = null;
  859. if (!useDefault) {
  860. jsonStr = Store.get(Key.STAFF_MAP_LIST_KEY, this.subType);
  861. this.isDefault = jsonStr === null; // useDefault 不能改变 isDefault
  862. }
  863. // 自定义数据
  864. if (jsonStr) return jsonStr.slice(1, -1); // 消除首尾的 `[]`
  865. // 读取缓存的默认数据
  866. else if (this._defaultTextBuffer) return this._defaultTextBuffer;
  867. // 将默认数据转化为格式化文本
  868. this._setDefault();
  869. const text = RegexInJSON.stringify(this.data, 1)
  870. .replace(/(null,\n )|(\n\s+)/g, (match, g1, g2) => {
  871. if (g1) return '\n';
  872. if (g2) return ' ';
  873. return match;
  874. })
  875. .slice(3, -2); // 消除首部 `[ \n` 与尾部 `\n]`
  876. // 使得 `[ `->`[` 同时 ` ]`->`]`
  877. /* const text = RegexInJSON.stringify(this.data, 1).replace(
  878. /(null,)|(?<!\[)(\n\s+)(?!])|(\[\s+)|(\s+],)/g,
  879. (match, g1, g2, g3, g4) => {
  880. if (g1) return '\n';
  881. if (g2) return ' ';
  882. if (g3) return '[';
  883. if (g4) return '],';
  884. return match;
  885. }).slice(3, -2); */
  886. this._defaultTextBuffer = text;
  887. this.data = null; // 释放原始数据的存储空间
  888. return text;
  889. }
  890. /**
  891. * 获取该插入匹配的前缀类型
  892. * @param {string} matcher
  893. * @returns {0 | 1 | 2} 0-无,1-插入未被匹配,2-插入二级引导
  894. */
  895. static getPrefixType(matcher) {
  896. if (matcher.startsWith(StaffMapList._insertUnmatchedPref))
  897. return 1;
  898. if (matcher.startsWith(StaffMapList._insertSubGroupPref))
  899. return 2;
  900. return 0;
  901. }
  902. /**
  903. * 获取该匹配的类型
  904. * @param {MatchJob | any} matcher
  905. * @returns {0 | 1 | 2 | 3} 0-无效类型,1-字符串,2-正则式,3-数字
  906. */
  907. static getMatcherType(matcher) {
  908. return utils.isString(matcher) ? 1 : utils.isRegExp(matcher) ? 2 : utils.isNumber(matcher) ? 3 : 0;
  909. }
  910. }
  911. /**
  912. * 基类,职位排序的核心逻辑。
  913. * 可被拓展用于不同的场景:网页`infobox`职位信息、职位名称序列、API`infobox`职位信息。
  914. *
  915. * 经算法重构,并修改`StaffMapList`的解析数据的结构,
  916. * 总复杂度由原本的 `O(k+m + nk·T)` 变为 `O(k+m+n + nk·t + klogk)`,
  917. * 借助缓存匹配记录,最高可达到 `O(k+m+n + klogk)`,其中:
  918. * - `k`为`li`元素的个数
  919. * - `m`为精确匹配规则的数量
  920. * - `n`为正则匹配规则的数量
  921. * - `t/T`为正则表达式平均匹配时间
  922. *
  923. * 其中的匹配逻辑`nk·T`实现为`for(n+m){for(k not M){T}}`,`nk·t`实现为`for(k not M){for(n){t}}`,
  924. * 因此对总复杂度的影响`T >> t`,同时借助该循环方式的改变,实现了精确匹配绝对优先于正则匹配
  925. */
  926. class BaseStaffSorter {
  927. /**
  928. * @type {Object<string, any> | Iterable<string>}
  929. * 原始数据,元素内需包含待匹配职位名称
  930. */
  931. rawData;
  932. /** @type {StaffMapList} 职位排序与折叠设置 */
  933. staffMapList;
  934. /** @type {Array<string>} 职位名称的排序结果 */
  935. sortedJobs;
  936. /** @type {Map<RegExp, Array<string>>} 被正则匹配的职位名称 */
  937. regexMatchedJobs;
  938. /** @type {Array<string>} 未被匹配的职位名称 */
  939. unmatchedJobs;
  940. /**
  941. * 构造函数,子类可细化`rawData`的类型定义,
  942. * 并自行对其初始化,且需在其后调用`_initSetData`函数
  943. */
  944. constructor(staffMapList, rawData = null) {
  945. this.staffMapList = staffMapList;
  946. this.regexMatchedJobs = new Map();
  947. this.unmatchedJobs = [];
  948. if (rawData) {
  949. this.rawData = rawData;
  950. this._initSortedJobs();
  951. }
  952. }
  953. /** 初始化排序结果 */
  954. _initSortedJobs() {
  955. if (utils.isArray(this.rawData) || utils.isSet(this.rawData)) {
  956. this.sortedJobs = Array.from(this.rawData);
  957. } else {
  958. this.sortedJobs = Object.keys(this.rawData);
  959. }
  960. }
  961. /**
  962. * 获取匹配后的序列号,
  963. * 被正则匹配与未被匹配的职位,将所得的序列号写入解析缓存`jobOrderMap`
  964. * @param {string} job - 数据集合
  965. * @return {number} 返回用于`Array.sort`的序列号
  966. */
  967. getMatchIndex(job) {
  968. let index;
  969. // 1.精确匹配,或命中缓存 (优先)
  970. index = this.staffMapList.exactOrderMap[job];
  971. if (index !== undefined) {
  972. return index;
  973. }
  974. // 新的匹配记录,需写入`Store`
  975. this.staffMapList.newlyMatchedCount++;
  976. // 2.正则匹配
  977. for (const item of this.staffMapList.regexOrderList) {
  978. if (utils.isString(item[0])) {
  979. // 懒活化正则表达式
  980. const match = item[0].match(RegexInJSON.regexPattern);
  981. item[0] = RegExp(match[1], match[2]);
  982. }
  983. if (!item[0].test(job)) continue;
  984. index = item[1];
  985. this.staffMapList.exactOrderMap[job] = index;
  986. this._addRegexMatchLog(item[0], job);
  987. return index;
  988. }
  989. // 3.未被匹配
  990. index = this.staffMapList.insertUnmatched;
  991. // this._addUnmatchLog(job); // 推迟记录操作,以确保无论未被匹配职位是否已被缓存的,均被记录
  992. this.staffMapList.exactOrderMap[job] = index;
  993. return index;
  994. }
  995. /** 进行匹配排序 */
  996. sort() {
  997. this.sortedJobs.sort((a, b) => this.getMatchIndex(a) - this.getMatchIndex(b));
  998. }
  999. /** 进行匹配遍历,并记录未被匹配的职位 */
  1000. scan() {
  1001. const insertUnmatched = this.staffMapList.insertUnmatched;
  1002. for (const job of this.sortedJobs) {
  1003. const index = this.getMatchIndex(job, false);
  1004. if (index === insertUnmatched) this._addUnmatchLog(job);
  1005. }
  1006. }
  1007. /**
  1008. * 记录一条被正则匹配的职位信息
  1009. * @param {RegExp} regex
  1010. * @param {string} job
  1011. */
  1012. _addRegexMatchLog(regex, job) {
  1013. // C.log(`${SCRIPT_NAME}:使用正则表达式 ${regex} 成功匹配 "${job}"`);
  1014. if (this.regexMatchedJobs.has(regex)) {
  1015. this.regexMatchedJobs.get(regex).push(job);
  1016. } else {
  1017. this.regexMatchedJobs.set(regex, [job]);
  1018. }
  1019. }
  1020. printRegexMatchLog() {
  1021. if (!this.regexMatchedJobs.size) return;
  1022. C.log(`${SCRIPT_NAME}:${this.staffMapList.subType} 中被正则匹配到的职位`,
  1023. [...this.regexMatchedJobs]
  1024. .map(([regex, jobs]) => [regex, jobs.join(',')])
  1025. );
  1026. }
  1027. /**
  1028. * 记录一条未被匹配的职位信息
  1029. * @param {string} job
  1030. */
  1031. _addUnmatchLog(job) {
  1032. this.unmatchedJobs.push(job);
  1033. }
  1034. printUnmatchLog() {
  1035. if (!this.unmatchedJobs.length) return;
  1036. C.log(`${SCRIPT_NAME}:${this.staffMapList.subType} 中未被匹配到的职位`, this.unmatchedJobs);
  1037. }
  1038. }
  1039. /**
  1040. * 实现网页`infobox`职位信息的排序与折叠,
  1041. * `sub_group`及属其所有的`sub_container`将被视为一个整体进行排序
  1042. */
  1043. class InfoboxStaffSorter extends BaseStaffSorter {
  1044. /**
  1045. * @type {Object<string, HTMLElement | Array<HTMLElement>>}
  1046. * 原始职位信息字典 (细化基类的数据格式)
  1047. */
  1048. rawData;
  1049. /**
  1050. * @param {HTMLElement} ul - `infobox`
  1051. * @param {StaffMapList} staffMapList - 职位排序与折叠设置
  1052. * @param {Object<string, HTMLElement | Array<HTMLElement>>} infoboxDict - 职位信息字典
  1053. * @param {string} subjectId - 条目`ID`
  1054. */
  1055. constructor(ul, staffMapList, infoboxDict, subjectId) {
  1056. super(staffMapList);
  1057. this.rawData = infoboxDict;
  1058. this._initSortedJobs();
  1059. /** `infobox` */
  1060. this.ul = ul;
  1061. /** `infobox`临时片段 */
  1062. this.fragment = document.createDocumentFragment();
  1063. /** 当前条目的`ID` */
  1064. this.subjectId = subjectId;
  1065. /** 启用插入二级职位引导信息 */
  1066. this.needInsertSubGroup = utils.isNumber(this.staffMapList.insertSubGroup);
  1067. }
  1068. /**
  1069. * 获取匹配后的序列号,
  1070. * 在原有的基础上增加优先插入二级职位引导的功能
  1071. */
  1072. getMatchIndex(job) {
  1073. if (this.needInsertSubGroup && utils.isArray(this.rawData[job])) {
  1074. return this.staffMapList.insertSubGroup;
  1075. }
  1076. return super.getMatchIndex(job);
  1077. }
  1078. sort() {
  1079. // 进行匹配排序
  1080. super.sort();
  1081. // 执行排序结果
  1082. let foldedJobs = [];
  1083. let preUnfolded = null;
  1084. for (const job of this.sortedJobs) {
  1085. const li = this.rawData[job];
  1086. // 1. sub_group 及属其所有的 sub_container 组成的序列
  1087. if (utils.isArray(li)) {
  1088. if (foldedJobs.length) dealFoldsList(foldedJobs, li[0], preUnfolded, false);
  1089. preUnfolded = null;
  1090. this.fragment.append(...li);
  1091. lastGroup = li;
  1092. continue;
  1093. }
  1094. // 2. 普通职位信息
  1095. if (this.staffMapList.isFoldedJob(job)) {
  1096. li.classList.add('folded', 'foldable');
  1097. // 加入队列
  1098. foldedJobs.push(job);
  1099. } else {
  1100. // 未被折叠
  1101. if (foldedJobs.length) {
  1102. dealFoldsList(foldedJobs, li, preUnfolded, li.classList.contains('refoldable'));
  1103. }
  1104. preUnfolded = li;
  1105. }
  1106. this.fragment.appendChild(li);
  1107. }
  1108. // 尾部元素折叠的情形
  1109. dealEndWithFold(this.ul, foldedJobs, preUnfolded);
  1110. // 一次性更新 DOM
  1111. this.ul.append(...this.fragment.childNodes);
  1112. }
  1113. }
  1114. /** 主函数入口 */
  1115. async function main() {
  1116. const urlPatterns = [
  1117. /^\/(subject)\/(\d+)$/,
  1118. /^\/(character)\/(\d+)$/,
  1119. /^\/(person)\/(\d+)$/,
  1120. /^\/(settings)\/privacy$/,
  1121. ];
  1122. Store.initialize();
  1123. for (const pattern of urlPatterns) {
  1124. const match = window.location.pathname.match(pattern);
  1125. if (!match) continue;
  1126. const [, patternType, subId] = match;
  1127. if (patternType === 'settings') handlerSettings();
  1128. else await handlerSubject(patternType, subId); // 传入条目类型与条目ID
  1129. break;
  1130. }
  1131. Store.postProcessConfigUpdate();
  1132. }
  1133. /** 处理设置 */
  1134. function handlerSettings() {
  1135. loadSettingStyle();
  1136. const ui = buildSettingUI({ id: 'staff_sorting' });
  1137. document.getElementById('columnA').appendChild(ui);
  1138. // 支持 url.hash = ID 进行导引
  1139. if (location.hash.slice(1) === 'staff_sorting') {
  1140. ui.scrollIntoView({ behavior: 'smooth' });
  1141. }
  1142. }
  1143. /** 处理条目 */
  1144. async function handlerSubject(subType, subId) {
  1145. if (SubjectType.needPrase(subType))
  1146. subType = SubjectType.parse(getSubjectType());
  1147. if (!subType) return; // 不支持该类型条目
  1148. loadStaffStyle();
  1149. const ul = document.querySelector('#infobox');
  1150. C.time(`StaffMapList`);
  1151. const staffMapList = new StaffMapList(subType);
  1152. staffMapList.initialize();
  1153. C.timeEnd(`StaffMapList`);
  1154. let sorter = null;
  1155. // 延迟执行,提高对其他修改 infobox 信息的脚本的兼容性
  1156. await delay(SORTING_DELAY);
  1157. if (!staffMapList.isNull()) {
  1158. // 1.实行自定义的职位顺序
  1159. const infoboxDict = getInfoboxDict(ul);
  1160. C.time(`StaffSorting`);
  1161. sorter = new InfoboxStaffSorter(ul, staffMapList, infoboxDict, subId);
  1162. sorter.sort();
  1163. C.timeEnd(`StaffSorting`);
  1164. hasFolded = staffMapList.hasFolded;
  1165. } else {
  1166. // 2.实行网页原有的职位顺序
  1167. scanInfobox(ul);
  1168. C.log(`${SCRIPT_NAME}:实行网页原有的职位顺序`);
  1169. }
  1170. JobStyle.setStaffStyleProperty();
  1171. addFoldButtonListener(ul);
  1172. changeExpandToDoubleButton(ul);
  1173. dealLastGroup(ul);
  1174. if (sorter) {
  1175. // 打印匹配记录
  1176. sorter.scan(); // 扫描未被匹配的职位
  1177. sorter.printRegexMatchLog();
  1178. sorter.printUnmatchLog();
  1179. // 保存匹配记录
  1180. staffMapList.saveResolvedData(true);
  1181. }
  1182. }
  1183. /**
  1184. * 巧妙地使用非常便捷的方法,获取当前条目的类型
  1185. * 源自 https://bangumi.tv/dev/app/2723/gadget/1242
  1186. */
  1187. function getSubjectType() {
  1188. const href = document.querySelector('#navMenuNeue .focus').getAttribute('href');
  1189. return href.split('/')[1];
  1190. }
  1191. /**
  1192. * 从`span.tip`元素中获取职位名称
  1193. * @param {HTMLElement} tip
  1194. * @returns {string}
  1195. */
  1196. function getJobFromTip(tip) {
  1197. return tip.innerText.trim().slice(0, -1); // 去掉最后的冒号
  1198. }
  1199. /**
  1200. * 获取一个对象来存储`infobox`中的职位信息。
  1201. * 并对职位信息进行二次折叠,
  1202. * 同时将`sub_group`及属其所有的`sub_container`打包为一个序列作为字典的键值
  1203. * @param {HTMLElement} ul - `infobox`
  1204. * @returns {Object<string, HTMLElement | Array<HTMLElement>>} 返回职位信息字典,键值为`DOM`或者`DOM`序列
  1205. */
  1206. function getInfoboxDict(ul) {
  1207. const staffDict = {};
  1208. const lis = ul.querySelectorAll(':scope > li');
  1209. lis.forEach((li) => {
  1210. const tip = li.querySelector('span.tip');
  1211. if (!tip) return;
  1212. let job = getJobFromTip(tip);
  1213. if (li.classList.contains('sub_group')) {
  1214. // 新的小组
  1215. staffDict[job] = [li];
  1216. } else if (li.classList.contains('sub_container')
  1217. && li.hasAttribute('attr-info-group')) {
  1218. // 整合进组
  1219. job = li.getAttribute('attr-info-group');
  1220. if (staffDict[job]) staffDict[job].push(li);
  1221. else staffDict[job] = [li];
  1222. } else {
  1223. // 普通元素
  1224. staffDict[job] = li;
  1225. // 为了正确计算元素高度,需使其 display
  1226. li.classList.remove('folded');
  1227. refoldJob(li, tip);
  1228. // li.folded 属性已经失效无需还原
  1229. }
  1230. });
  1231. return staffDict;
  1232. }
  1233. /**
  1234. * 遍历`infobox中的职位信息,
  1235. * 为网页原有的`folded`类别添加`foldable`便签,用于实现切换,
  1236. * 忽略属于`sub_group`的`sub_container`,
  1237. * 并对职位信息进行二次折叠
  1238. * @param {HTMLElement} ul - `infobox`
  1239. */
  1240. function scanInfobox(ul) {
  1241. const lis = ul.querySelectorAll(':scope > li');
  1242. let foldedJobs = [];
  1243. let preUnfolded = null;
  1244. lis.forEach(li => {
  1245. // 获取 lastGroup
  1246. if (li.classList.contains('sub_group')) {
  1247. if (foldedJobs.length) dealFoldsList(foldedJobs, li, preUnfolded, false);
  1248. preUnfolded = null;
  1249. lastGroup = [li];
  1250. } else if (li.classList.contains('sub_container') && li.hasAttribute('attr-info-group')) {
  1251. lastGroup.push(li);
  1252. }
  1253. const tip = li.querySelector('span.tip');
  1254. if (!tip) return;
  1255. const job = getJobFromTip(tip);
  1256. const flag = li.classList.contains('folded') && !li.hasAttribute('attr-info-group');
  1257. // 为了正确计算元素高度,需先使其 display
  1258. if (flag) li.classList.remove('folded');
  1259. refoldJob(li, tip);
  1260. /* 特殊用法:当 StaffMapList = "[]" 空缺设置,同时 SortEnableState = "partialDisable"
  1261. * 将实行网页原有的职位顺序,同时禁止其折叠 */
  1262. if (Store.get(Key.ENABLE_STATE_KEY) === SortEnableState.PARTIAL_ENABLED) return;
  1263. if (flag) {
  1264. // 被折叠
  1265. if (!hasFolded) hasFolded = true;
  1266. li.classList.add('folded', 'foldable');
  1267. // 加入队列
  1268. foldedJobs.push(job);
  1269. } else {
  1270. // 未被折叠
  1271. if (foldedJobs.length) {
  1272. dealFoldsList(foldedJobs, li, preUnfolded, li.classList.contains('refoldable'));
  1273. }
  1274. preUnfolded = li;
  1275. }
  1276. });
  1277. // 尾部元素折叠的情形
  1278. dealEndWithFold(ul, foldedJobs, preUnfolded);
  1279. }
  1280. /**
  1281. * 处理有效的折叠队列。
  1282. * 为未被折叠的当前元素或先前元素,添加该折叠队列的控制按钮。
  1283. * @param {Array<string>} foldedJobs - 被折叠的职位名称序列
  1284. * @param {HTMLElement} curUnfolded - 当前未被折叠的元素
  1285. * @param {HTMLElement | null} preUnfolded - 先前的第一个未被折叠的元素
  1286. * @param {boolean} position - `true`为先前元素添加,`false`为当前元素添加
  1287. */
  1288. function dealFoldsList(foldedJobs, curUnfolded, preUnfolded, position) {
  1289. const aTitle = '+ ' + foldedJobs.join(',');
  1290. if (position && preUnfolded) {
  1291. addFoldsListButton(preUnfolded, aTitle, 'bottom');
  1292. } else {
  1293. addFoldsListButton(curUnfolded, aTitle, 'top');
  1294. }
  1295. foldedJobs.length = 0; // 使用 = [] 方法将无法引用返回
  1296. }
  1297. /**
  1298. * 处理`infobox`以被折叠元素序列结尾的情景
  1299. * @param {HTMLElement} ul - `infobox`
  1300. * @param {Array<string>} foldedJobs - 被折叠的职位名称序列
  1301. * @param {HTMLElement} preUnfolded - 先前的第一个未被折叠的元素
  1302. */
  1303. function dealEndWithFold(ul, foldedJobs, preUnfolded) {
  1304. if (foldedJobs.length && preUnfolded) {
  1305. const aTitle = '+ ' + foldedJobs.join(',');
  1306. addFoldsListButton(preUnfolded, aTitle, 'bottom', 'end_with_fold');
  1307. ul.classList.add('padding_bottom');
  1308. endWithFold = true;
  1309. }
  1310. }
  1311. /**
  1312. * 添加折叠队列的控制按钮
  1313. * @param {HTMLElement} li - 被添加按钮的元素
  1314. * @param {string} aTitle - 超链接的标题,即被折叠的职位名称序列
  1315. * @param {'top' | 'bottom'} position - 按钮的方位
  1316. * @param {string | undefined} tip - 特殊类名标记
  1317. */
  1318. function addFoldsListButton(li, aTitle, position, tip) {
  1319. const span = createElement('span', { class: `folds_list expand_${position}` });
  1320. if (tip) span.classList.add(tip);
  1321. const plusIcon = createElement('a', { class: 'staff_sorting_icon' });
  1322. plusIcon.innerHTML = ICON.PLUS;
  1323. plusIcon.title = aTitle;
  1324. span.appendChild(plusIcon);
  1325. li.appendChild(span);
  1326. }
  1327. /**
  1328. * 对超出限制行数的职位信息进行二次折叠,并添加开关。
  1329. * 实现动态不定摘要的类似于`summary`的功能。
  1330. * 过滤`别名`等不定行高的`infobox`信息
  1331. * @param {HTMLElement} li - 职位信息根节点
  1332. * @param {HTMLElement} tip - 职位名称节点
  1333. */
  1334. function refoldJob(li, tip) {
  1335. if (Store.get(Key.REFOLD_THRESHOLD_KEY) === Key.REFOLD_THRESHOLD_DISABLED) return;
  1336. if (li.classList.contains('sub_container') || li.classList.contains('sub_group')) return;
  1337. if (!JobStyle.compStyle) JobStyle.initialize(li);
  1338. const lineCnt = getLineCnt(li);
  1339. const refoldThr = Store.get(Key.REFOLD_THRESHOLD_KEY);
  1340. if (lineCnt <= refoldThr) return;
  1341. // 添加头部开关状态图标
  1342. const prefIcon = createElement('i', { class: 'staff_sorting_icon' });
  1343. prefIcon.innerHTML = ICON.TRIANGLE_RIGHT;
  1344. /* 尝试使用<symbol><use>模板或直接使用JS构建实例的方法均失败...
  1345. * 最终改为直接修改innerHTML */
  1346. const switchPos = Store.get(Key.REFOLD_SWITCH_POSITION_KEY);
  1347. if (switchPos == RefoldSwitchPosition.RIGHT) {
  1348. updat###bElements(tip, prefIcon, 'append');
  1349. } else if (switchPos == RefoldSwitchPosition.LEFT) {
  1350. updat###bElements(tip, prefIcon, 'prepend');
  1351. }
  1352. tip.classList.add('switch');
  1353. // 添加尾部折叠图标
  1354. const suffIcon = createElement('i', { class: 'staff_sorting_icon' });
  1355. const sideTip = createElement('span', {class: 'tip side'}, suffIcon);
  1356. suffIcon.innerHTML = ICON.TRIANGLE_UP;
  1357. li.appendChild(sideTip);
  1358. // 记录被折叠的行数,由于 span{clear: right} 防止其换行,需先渲染并重新计算行数
  1359. const refoldLine = getLineCnt(li) - refoldThr;
  1360. // C.debug(getJobFromTip(tip), refoldLine);
  1361. sideTipLineThr ??= getSideTipThr(); // 小于阈值的将被隐藏
  1362. if (refoldLine >= sideTipLineThr) sideTip.dataset.refoldLine = refoldLine;
  1363. // else delete sideTip.dataset.refoldLine;
  1364. // 添加二次折叠效果 (样式将在随后通过 loadStaffStyle 动态载入)
  1365. li.classList.add('refoldable', 'refolded');
  1366. // const nest = nestElementWithChildren(li, 'div', {class: 'refoldable refolded'});
  1367. /* 尝试不修改 DOM 结构仅通过添加样式达到完备的折叠效果,
  1368. * 难点在于处理溢出到 li.padding-bottom 区域的信息
  1369. * 最终通过施加多层遮蔽效果实现,故不再需要内嵌一层新的 div 元素 */
  1370. }
  1371. /**
  1372. * 为多个折叠类型按钮绑定开关事件,
  1373. * 包含一次连续折叠、二次折叠。
  1374. * 采用`事件委托`形式绑定事件 (事件冒泡机制)
  1375. * @param {HTMLElement} ul - `infobox`
  1376. */
  1377. function addFoldButtonListener(ul) {
  1378. /* 检查点击的元素是否是开关本身 span 或其子元素 icon
  1379. * 使用 .closest('.cls') 替代 classList.contains('cls')
  1380. * 使得子元素也能响应点击事件 */
  1381. ul.addEventListener('click', (event) => {
  1382. /** @type {HTMLElement} 被点击的目标 */
  1383. const target = event.target;
  1384. let button;
  1385. // 1.一次连续折叠
  1386. button = target.closest('.folds_list');
  1387. if (button && ul.contains(button)) {
  1388. // 1.1一次连续折叠的前部开关
  1389. if (button.classList.contains('expand_top')) return onFoldsList(button, 'previous');
  1390. // 1.2一次连续折叠的后部开关
  1391. if (button.classList.contains('expand_bottom')) {
  1392. if (button.classList.contains('end_with_fold')) {
  1393. ul.classList.remove('padding_bottom');
  1394. }
  1395. return onFoldsList(button, 'next');
  1396. }
  1397. return;
  1398. }
  1399. // 2.二次折叠
  1400. if (Store.get(Key.REFOLD_THRESHOLD_KEY) === 0) return;
  1401. // 2.1首部二次折叠开关
  1402. button = target.closest('.switch');
  1403. if (button && ul.contains(button)) return onPrefRefold(button);
  1404. // 2.2尾部二次折叠开关
  1405. button = target.closest('.side');
  1406. if (button && ul.contains(button)) return onSuffRefold(button);
  1407. });
  1408. /**
  1409. * 一次连续折叠的前部开关
  1410. * @param {HTMLElement} button
  1411. * @param {'previous' | 'next'} direction
  1412. */
  1413. function onFoldsList(button, direction) {
  1414. const li = button.parentElement;
  1415. const foldsList = getSiblings(li, direction, isFoldable);
  1416. foldsList.forEach(removeFolded);
  1417. button.classList.add('hidden');
  1418. }
  1419. function isFoldable(li) { return li.classList.contains('foldable'); }
  1420. function removeFolded(li) { li.classList.remove('folded'); }
  1421. /**
  1422. * 首部折叠二次开关
  1423. * @param {HTMLElement} prefTip
  1424. */
  1425. function onPrefRefold(prefTip) {
  1426. // 职位名称或开关状态图标被点击了
  1427. const li = prefTip.parentElement;
  1428. if (li.classList.contains('refolded')) {
  1429. li.classList.remove('refolded');
  1430. prefTip.querySelector('i').innerHTML = ICON.TRIANGLE_DOWN;
  1431. } else {
  1432. li.classList.add('refolded');
  1433. prefTip.querySelector('i').innerHTML = ICON.TRIANGLE_RIGHT;
  1434. }
  1435. }
  1436. /**
  1437. * 尾部折叠二次开关
  1438. * @param {HTMLElement} prefTip
  1439. */
  1440. function onSuffRefold(suffTip) {
  1441. const li = suffTip.parentElement;
  1442. // 滚轮将自动上移被折叠的距离,以确保折叠后的内容不会让用户迷失上下文
  1443. const rectBefore = li.getBoundingClientRect();
  1444. // 更改折叠状态
  1445. li.classList.add('refolded');
  1446. // 等待下一帧,让浏览器完成渲染
  1447. requestAnimationFrame(() => {
  1448. const rectAfter = li.getBoundingClientRect();
  1449. /* 尝试通过 suffTip.dataset.refoldLine 计算高度变化
  1450. * 会与理想值有 ~0.5px 的随机偏差,故改用获取元素窗口的高度变化 */
  1451. const distance = rectAfter.top - rectBefore.top + rectAfter.height - rectBefore.height;
  1452. // C.debug( `\n` +
  1453. // `heightBefore: \t${rectBefore.height},\nheightAfter: \t${rectAfter.height},\n` +
  1454. // `topAfter: \t${rectAfter.top},\ntopBefore: \t${rectBefore.top},\ndistance: \t${distance},\n` +
  1455. // `byRefoldLine: \t${suffTip.dataset.refoldLine * JobStyle.lineHeight}`
  1456. // );
  1457. /* 需考虑 li.top 的前后变化,且不要使用 scrollTo
  1458. * 因为部分浏览器对于超出视口的 li 元素进行折叠时,会自主进行防迷失优化,
  1459. * 此时 distance 的计算机结果将会是 0 */
  1460. window.scrollBy({ top: distance, behavior: 'instant' });
  1461. });
  1462. // 修改首部开关的图标
  1463. li.firstChild.querySelector('i').innerHTML = ICON.TRIANGLE_RIGHT;
  1464. }
  1465. /* 在 mousedown 阶段阻止用户拖动或双击时的默认选中行为。
  1466. * 由于 span.switch 本质仍然是内容段落的一部分,
  1467. * 不通过 user-select: none 这钟粗暴的方法禁止用户的一切选中行为
  1468. * 而是采用温和的方法阻止部分情形下对该区域的选中行为 */
  1469. ul.addEventListener('mousedown', (event) => {
  1470. if (event.target.closest('.switch')) event.preventDefault();
  1471. });
  1472. }
  1473. /**
  1474. * 处理最后一组`sub_group`,若为`infobox`末尾元素,则为其添加标签。
  1475. * 以优化样式,当其非末尾元素时,添加边界以区分`sub_container > li`与普通`li`
  1476. * @param {HTMLElement} ul - `infobox`
  1477. */
  1478. function dealLastGroup(ul) {
  1479. if (!lastGroup || ul.lastElementChild !== lastGroup[lastGroup.length - 1]) return;
  1480. lastGroup.forEach((li) => {
  1481. if (li.classList.contains('sub_container'))
  1482. li.classList.add('last_group');
  1483. })
  1484. }
  1485. /**
  1486. * 获取固定行高`#infobox.li`元素显示的行数
  1487. * 经测试,职员信息除了`8px`的`padding`还有`<1px`的`border`因为不影响行数计算忽略
  1488. */
  1489. function getLineCnt(el, padding = 8, border = 0) {
  1490. const height = el.getBoundingClientRect().height - padding - border;
  1491. return ~~(height / JobStyle.lineHeight);
  1492. }
  1493. /**
  1494. * 根据页面视口高度,计算尾部折叠图标的激活行数阈值
  1495. * 对于二次折叠区域较小,不予显示
  1496. */
  1497. function getSideTipThr() {
  1498. const threshold = ~~(getViewportHeight() / JobStyle.lineHeight * sideTipRate);
  1499. C.log(`${SCRIPT_NAME}:`, {'sideTipLineThreshold': threshold});
  1500. return threshold;
  1501. }
  1502. /**
  1503. * 将原本存在的`更多制作人员`一次性按钮,转绑新事件,并改为永久性左右双开关。
  1504. * 使用网页原有的`folded`元素类别,实现对立于`StaffSorter`功能。
  1505. * 通过`hasFolded`判断,若需要则进行添加,不需要则删除。
  1506. * 双按钮中:
  1507. * 1. 左开关拥有切换功能,用于常规场景;
  1508. * 2. 右开关不能进行切换,用于辅助连续折叠功能。
  1509. * @param {HTMLElement} ul - `infobox`
  1510. <div class="infobox_expand">
  1511. <a>更多制作人员 +</a>
  1512. <a>更多制作人员 -</a>
  1513. </div>
  1514. */
  1515. function changeExpandToDoubleButton(ul) {
  1516. const buttonName = '更多制作人员';
  1517. const buttonValue = { on: `${buttonName} +`, off: `${buttonName} -` };
  1518. let buttonCntr = document.querySelector('#infobox + .infobox_expand'); // 无法实现 :scope +
  1519. let expandLink;
  1520. if (!hasFolded) {
  1521. // 无必要,不进行事件绑定与可能的添加,并将原有的开关隐藏
  1522. if (buttonCntr) {
  1523. buttonCntr.style.display = 'none';
  1524. C.log(`${SCRIPT_NAME} - 将原有的 '${buttonName}' 隐藏`);
  1525. } return;
  1526. }
  1527. // 检查原展开按钮
  1528. if (!buttonCntr) {
  1529. expandLink = createElement('a', null, buttonValue.on);
  1530. buttonCntr = createElement('div', { class: 'infobox_expand' }, expandLink);
  1531. ul.parentElement.appendChild(buttonCntr);
  1532. C.log(`${SCRIPT_NAME}:添加原不存在的 '${buttonName}' 按钮`);
  1533. } else {
  1534. expandLink = buttonCntr.firstChild;
  1535. expandLink.removeAttribute('href');
  1536. }
  1537. // 添加折叠按钮
  1538. const collapseLink = createElement('a', null, buttonValue.off);
  1539. buttonCntr.appendChild(collapseLink);
  1540. // 展开事件监听 (可能继承自网页原有的按钮)
  1541. expandLink.addEventListener('click', function (event) {
  1542. event.stopImmediatePropagation(); // 阻止其他事件的触发
  1543. const foldedLis = ul.querySelectorAll('.foldable');
  1544. foldedLis.forEach(li => li.classList.remove('folded'));
  1545. // 隐藏连续折叠前后的展开按钮
  1546. ul.querySelectorAll('span.folds_list').forEach(button => button.classList.add('hidden'));
  1547. if (endWithFold) ul.classList.remove('padding_bottom');
  1548. // 切换开关位置
  1549. if (expandLink === buttonCntr.firstChild) buttonCntr.appendChild(expandLink);
  1550. }, { capture: true }); // 使事件处理函数在捕获阶段运行
  1551. // 折叠事件监听
  1552. collapseLink.addEventListener('click', () => {
  1553. const foldedLis = ul.querySelectorAll('.foldable');
  1554. foldedLis.forEach(li => li.classList.add('folded'));
  1555. // 显示连续折叠前后的展开按钮
  1556. ul.querySelectorAll('span.folds_list').forEach(button => button.classList.remove('hidden'));
  1557. if (endWithFold) ul.classList.add('padding_bottom');
  1558. // 切换开关位置
  1559. if (collapseLink === buttonCntr.firstChild) buttonCntr.appendChild(collapseLink);
  1560. });
  1561. }
  1562. /**
  1563. * 创建用户设置`UI`界面
  1564. * 仿照`#columnA`中的同类元素进行构建,使用原有的结构与样式
  1565. <table class="settings">
  1566. <tbody>
  1567. <tr>
  1568. <td>
  1569. <h2 class="subtitle">条目职位排序 · 默认折叠</h2>
  1570. <div class="right_inline"><p class="tip_j">默认设置版本号</p></div>
  1571. </td>
  1572. </tr>
  1573. <!-- 此处添加子模块 -->
  1574. </tbody>
  1575. </table>
  1576. */
  1577. function buildSettingUI(mainStyle) {
  1578. const dataVersion = createElement('p', { class: 'tip_j' }, `数据 v${CURRENT_DATA_VERSION}`);
  1579. const mainTitle = createElement('tr', null, [
  1580. createElement('td', { class: 'maintitle' }, [
  1581. createElement('h2', { class: 'subtitle' }, '条目职位排序 · 默认折叠'),
  1582. createElement('div', {class: 'right_inline'}, dataVersion)
  1583. ])
  1584. ]);
  1585. const lineLimitBlock = buildLineLimitBlock();
  1586. const inheritPreMatchBlock = buildInheritPreMatchBlock();
  1587. const refoldSwitchPosBlock = buildRefoldSwitchPosBlock();
  1588. const subjectBlocks = SubjectType.getAll(true).map(sub => buildSubjectBlock(sub));
  1589. const ui = createElement('div', mainStyle, [
  1590. createElement('table', { class: 'settings' }, [
  1591. createElement('tbody', null, [
  1592. mainTitle,
  1593. lineLimitBlock,
  1594. refoldSwitchPosBlock,
  1595. inheritPreMatchBlock,
  1596. ...subjectBlocks,
  1597. ])
  1598. ])
  1599. ]);
  1600. // 隐藏功能,双击版本号触发版本数据强制刷新 (主要作为调试工具)
  1601. dataVersion.addEventListener('dblclick', () => {
  1602. Store.remove(Key.DATA_VERSION);
  1603. location.reload();
  1604. });
  1605. return ui;
  1606. }
  1607. /**
  1608. * 创建职位信息二次折叠的行高限制设置界面
  1609. <tr class="line_limit_block">
  1610. <td>
  1611. <h2 class="subtitle">职位信息高度 限制</h2>
  1612. <div class="right_inline">
  1613. <fieldset class="num_input_cntr">...</fieldset>
  1614. <div class="toggle">...</div>
  1615. </div>
  1616. </td>
  1617. </tr>
  1618. */
  1619. function buildLineLimitBlock() {
  1620. const subTitle = createElement('h2', { class: 'subtitle' }, '职位信息高度 限制');
  1621. // 搭建滑动开关
  1622. const [toggle, toggleCntr] = buildToggleSlider('refold_switch');
  1623. // 搭建整数步进输入器
  1624. const intInput = new IntInputStepper('refold_threshold_input', '行数');
  1625. intInput.build();
  1626. // 搭建外部框架
  1627. const block = createElement('tr', { class: 'line_limit_block' }, [
  1628. createElement('td', null, [
  1629. subTitle,
  1630. createElement('div', { class: 'right_inline' }, [
  1631. intInput.root, toggleCntr
  1632. ])
  1633. ])
  1634. ]);
  1635. // 初始化 (此处无需关心Key._subType)
  1636. toggle.checked = Store.get(Key.REFOLD_THRESHOLD_KEY) !== Key.REFOLD_THRESHOLD_DISABLED;
  1637. intInput.num = Store.get(Key.REFOLD_THRESHOLD_KEY);
  1638. if (!toggle.checked) {
  1639. intInput.display = false;
  1640. block.classList.add('turn_off');
  1641. }
  1642. // 绑定事件
  1643. function setRefloadThreshold(num) {
  1644. // 与缓存进行对比,防止无效写入
  1645. if (num === Store.get(Key.REFOLD_THRESHOLD_KEY)) return;
  1646. Store.set(Key.REFOLD_THRESHOLD_KEY, num, null, true);
  1647. }
  1648. toggle.addEventListener('click', () => {
  1649. if (toggle.checked) {
  1650. intInput.display = true;
  1651. block.classList.remove('turn_off');
  1652. setRefloadThreshold(intInput.num); // 使用 DOM 中可能的暂存数据
  1653. } else {
  1654. intInput.display = false;
  1655. block.classList.add('turn_off');
  1656. setRefloadThreshold(Key.REFOLD_THRESHOLD_DISABLED);
  1657. }
  1658. });
  1659. intInput.onNumChange = setRefloadThreshold;
  1660. return block;
  1661. }
  1662. /**
  1663. * 创建二次折叠的开关位置设置界面
  1664. <tr class="refold_switch_pos_block">
  1665. <td>
  1666. <h2 class="subtitle">· 折叠开关图标的位置</h2>
  1667. <div class="right_inline">
  1668. <p class="tip_j"><!-- message --></p>
  1669. <div class="tri_state_selector">...</div>
  1670. </div>
  1671. </td>
  1672. </tr>
  1673. */
  1674. function buildRefoldSwitchPosBlock() {
  1675. const subTitle = createElement('h2', { class: 'subtitle' }, '· 折叠开关图标的位置');
  1676. // 搭建滑动开关
  1677. const selector = new TriStateSlider('refold_switch_position', RefoldSwitchPosition.getAll());
  1678. const selectorMsgBox = createElement('p', { class: 'tip_j' });
  1679. const selectorField = createElement('div', {class: 'right_inline'}, [
  1680. selectorMsgBox, selector.root
  1681. ]);
  1682. selector.build();
  1683. // 搭建外部框架
  1684. const block = createElement('tr', { class: 'refold_switch_pos_block' }, [
  1685. createElement('td', null, [
  1686. subTitle, selectorField
  1687. ])
  1688. ]);
  1689. // 初始化并绑定事件
  1690. selector.state = Store.get(Key.REFOLD_SWITCH_POSITION_KEY);
  1691. setSelectorMsgBox(selector.state);
  1692. selector.onStateChange = (newState) => {
  1693. setSelectorMsgBox(newState);
  1694. Store.set(Key.REFOLD_SWITCH_POSITION_KEY, newState, null, true);
  1695. };
  1696. return block;
  1697. function setSelectorMsgBox(state) {
  1698. switch (state) {
  1699. case RefoldSwitchPosition.NONE:
  1700. setMessage(selectorMsgBox, '隐藏开关图标'); break;
  1701. case RefoldSwitchPosition.LEFT:
  1702. setMessage(selectorMsgBox, '位于职位名称左边'); break;
  1703. case RefoldSwitchPosition.RIGHT:
  1704. setMessage(selectorMsgBox, '位于职位名称右边'); break;
  1705. }
  1706. }
  1707. }
  1708. /**
  1709. * 创建继承历史匹配记录设置界面
  1710. <tr class="inherit_prematch_block">
  1711. <td>
  1712. <h2 class="subtitle">继承历史匹配记录</h2>
  1713. <div class="right_inline">
  1714. <p class="tip_j"><!-- message --></p>
  1715. <div class="toggle">...</div>
  1716. </div>
  1717. </td>
  1718. </tr>
  1719. */
  1720. function buildInheritPreMatchBlock() {
  1721. const subTitle = createElement('h2', { class: 'subtitle' }, '继承历史匹配记录');
  1722. // 搭建滑动开关
  1723. const [toggle, toggleCntr] = buildToggleSlider('inherit_switch');
  1724. const toggleMsgBox = createElement('p', { class: 'tip_j' });
  1725. // 搭建外部框架
  1726. const block = createElement('tr', { class: 'inherit_prematch_block' }, [
  1727. createElement('td', null, [
  1728. subTitle,
  1729. createElement('div', {class: 'right_inline'}, [
  1730. toggleMsgBox, toggleCntr
  1731. ])
  1732. ])
  1733. ]);
  1734. // 初始化并绑定事件
  1735. toggle.checked = Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY);
  1736. toggleOnClick();
  1737. toggle.addEventListener('click', toggleOnClick);
  1738. return block;
  1739. function toggleOnClick() {
  1740. if (toggle.checked) {
  1741. setMessage(toggleMsgBox, '设置变更时将继承');
  1742. Store.set(Key.INHERIT_PREVIOUS_MATCHES_KEY, true, null, true);
  1743. } else {
  1744. setMessage(toggleMsgBox, '设置变更时不继承');
  1745. Store.set(Key.INHERIT_PREVIOUS_MATCHES_KEY, false, null, true);
  1746. }
  1747. }
  1748. }
  1749. /**
  1750. * 创建`staffMapList`文本内容编辑界面
  1751. * 对于`textarea`,`button`等控件仍然使用原有的结构与样式
  1752. <tr class="subject_staff_block">
  1753. <td>
  1754. <details open="">
  1755. <summary>
  1756. <h2 class="subtitle"><!-- subject type --></h2>
  1757. <div class="right_inline">
  1758. <p class="tip_j"><!-- message --></p>
  1759. <div class="tri_state_selector">...</div>
  1760. </div>
  1761. </summary>
  1762. <div class="staffMapList_editor">...</div>
  1763. </details>
  1764. </td>
  1765. </tr>
  1766. */
  1767. function buildSubjectBlock(subTypeObj) {
  1768. const subType = subTypeObj.en;
  1769. // 设置信息,editor 与 matchLog 公有
  1770. const staffMapList = new StaffMapList(subType);
  1771. // 搭建标题
  1772. const subTitle = createElement('h2', { class: 'subtitle' });
  1773. // 搭建滑动开关
  1774. const selector = new TriStateSlider(`${subType}_subject_enable`, SortEnableState.getAll());
  1775. const selectorMsgBox = createElement('p', { class: 'tip_j' });
  1776. const selectorField = createElement('div', {class: 'right_inline hidden'}, [
  1777. selectorMsgBox, selector.root
  1778. ]);
  1779. selector.build();
  1780. // 定义编辑器,暂不构建
  1781. const editor = new StaffMapListEditor(subType, staffMapList);
  1782. // 搭建展开容器
  1783. const detail = createElement('details', null, [
  1784. createElement('summary', null, [
  1785. subTitle, selectorField
  1786. ]),
  1787. editor.root,
  1788. ])
  1789. // 搭建外部结构
  1790. const block = createElement('tr', {class: 'subject_staff_block'}, [
  1791. createElement('td', null, detail)
  1792. ]);
  1793. // 初始化
  1794. subTitle.textContent = `${subTypeObj.zh}条目`;
  1795. detail.open = Store.get(Key.BLOCK_OPEN_KEY, subType);
  1796. selector.state = Store.get(Key.ENABLE_STATE_KEY, subType);
  1797. setSelectorMsgBox(selector.state);
  1798. blockOnOpen();
  1799. // 绑定事件
  1800. selector.onStateChange = (newState) => {
  1801. setSelectorMsgBox(newState);
  1802. Store.set(Key.ENABLE_STATE_KEY, newState, subType, true);
  1803. };
  1804. detail.addEventListener('toggle', blockOnOpen); // 无需上下文环境
  1805. return block;
  1806. function setSelectorMsgBox(state) {
  1807. switch (state) {
  1808. case SortEnableState.ALL_DISABLED:
  1809. setMessage(selectorMsgBox, '禁用设置,但仍可编辑保存'); break;
  1810. case SortEnableState.PARTIAL_ENABLED:
  1811. setMessage(selectorMsgBox, '仅启用排序,禁用折叠'); break;
  1812. case SortEnableState.ALL_ENABLED:
  1813. setMessage(selectorMsgBox, '启用自定义 / 默认设置'); break;
  1814. }
  1815. }
  1816. function blockOnOpen() {
  1817. if (detail.open) {
  1818. // 在第一次展开时构建
  1819. if (!editor.built) editor.build();
  1820. selectorField.classList.remove('hidden');
  1821. } else {
  1822. selectorField.classList.add('hidden');
  1823. }
  1824. Store.set(Key.BLOCK_OPEN_KEY, detail.open, subType, true);
  1825. }
  1826. }
  1827. /**
  1828. * `staffMapList`编辑器,并对数据进行自主管理
  1829. <div class="staffMapList_editor">
  1830. <div class="markItUp">
  1831. <textarea class="quick markItUpEditor hasEditor codeHighlight" name="staff_map_list">
  1832. <!-- staffMapListText -->
  1833. </textarea>
  1834. </div>
  1835. <div>
  1836. <input class="inputBtn" type="submit" name="submit_context" value="保存">
  1837. <input class="inputBtn" type="submit" name="reset_context" value="恢复默认">
  1838. <p class="tip_j" style="display: inline;">
  1839. <!-- message -->
  1840. </p>
  1841. </div>
  1842. <!-- margin-right 为移动端预留的 mainpage 滑动空间 -->
  1843. </div>
  1844. */
  1845. class StaffMapListEditor {
  1846. static _editorCls = 'staffMapList_editor';
  1847. /**
  1848. * @param {string} subType - 条目类型
  1849. * @param {StaffMapList} staffMapList
  1850. */
  1851. constructor(subType, staffMapList) {
  1852. this.subType = subType;
  1853. this.staffMapList = staffMapList;
  1854. this.root = createElement('div', { class: StaffMapListEditor._editorCls });
  1855. this.textArea = null; // 输入文本框
  1856. this.resetBtn = null; // 提交按钮
  1857. this.submitBtn = null; // 重置按钮
  1858. this.editorMsgBox = null; // 简易提示框
  1859. this.isDefault = null; // 标记是否为默认数据
  1860. this.hasInputed = null; // 文本框内容是否被改变且未被保存
  1861. this.preInheritState = Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY);
  1862. this.built = false; // 标记是否已经初始化
  1863. }
  1864. build() {
  1865. if (this.built) return; // 防止重复构建
  1866. // 构建元素结构
  1867. this.textArea = createElement('textarea', {
  1868. class: 'quick markItUpEditor hasEditor codeHighlight', name: 'staff_map_list', id: `${this.subType}_staff_map_list`
  1869. });
  1870. this.submitBtn = createElement('input', {
  1871. class: 'inputBtn', type: 'submit', name: 'submit_context', value: '保存'
  1872. });
  1873. this.resetBtn = createElement('input', {
  1874. class: 'inputBtn', type: 'submit', name: 'reset_context', value: '恢复默认'
  1875. });
  1876. this.editorMsgBox = createElement('p', { class: 'tip_j'});
  1877. this.root.append(
  1878. createElement('div', { class: 'markItUp' }, this.textArea),
  1879. createElement('div', null, [this.submitBtn, this.resetBtn, this.editorMsgBox])
  1880. );
  1881. // 初始化状态
  1882. const text = this.staffMapList.formatToText(false);
  1883. this.textArea.value = text;
  1884. this.isDefault = this.staffMapList.isDefault;
  1885. this.hasInputed = false;
  1886. if (text.trim() === '') setMessage(this.editorMsgBox, '现为设置空缺', 0); // 网页实行原有的职位顺序与折叠
  1887. else if (this.isDefault) setMessage(this.editorMsgBox, '现为默认设置', 0); // 初始化时,提醒用户已为默认设置
  1888. else setMessage(this.editorMsgBox, '现为自定义设置', 0);
  1889. // 绑定事件
  1890. this.textArea.addEventListener('input', this._onInput.bind(this));
  1891. this.resetBtn.addEventListener('click', this._onReset.bind(this));
  1892. this.submitBtn.addEventListener('click', this._onSubmit.bind(this));
  1893. this.built = true;
  1894. }
  1895. _onInput() {
  1896. if (this.isDefault) this.isDefault = false;
  1897. if (!this.hasInputed) this.hasInputed = true;
  1898. // C.debug("IS INPUTTING");
  1899. }
  1900. async _onReset() {
  1901. if (this.isDefault) return setMessage(this.editorMsgBox, '已为默认内容');
  1902. await trySetText(
  1903. this.textArea, this.editorMsgBox, this.staffMapList.formatToText(true),
  1904. '已恢复默认内容', false
  1905. );
  1906. // 需进行同步等待,由于 setText 可能会触发 input 事件
  1907. this.isDefault = true;
  1908. this.hasInputed = false;
  1909. }
  1910. async _onSubmit() {
  1911. // 判断是否为重置后未对默认内容进行修改
  1912. if (this.isDefault) {
  1913. if (this.staffMapList.isDefault && !this._onInheritStateChange()) {
  1914. setMessage(this.editorMsgBox, '已为默认设置');
  1915. } else {
  1916. // 由自定义改为默认设置,或继承状态发生改变
  1917. this.staffMapList.resetData();
  1918. setMessage(this.editorMsgBox, '保存成功!恢复默认设置');
  1919. }
  1920. this.hasInputed = false;
  1921. return;
  1922. }
  1923. if (!this.hasInputed && !this._onInheritStateChange()) {
  1924. setMessage(this.editorMsgBox, '未作修改');
  1925. return;
  1926. }
  1927. const [modifiedData, isModified, curCursorPos] = StaffMapListEditor.modifyText(this.textArea);
  1928. // 强制将用户输入的文本外层嵌套 `[]`,若为重复嵌套可在 loadMapList 中识别并去除
  1929. const savedData = `[${modifiedData}]`;
  1930. const parsedData = RegexInJSON.parse(savedData);
  1931. // 数据解析失败
  1932. if (!parsedData) return setMessage(this.editorMsgBox, '保存失败!格式存在错误');
  1933. // 保存数据
  1934. this.staffMapList.saveData(savedData, parsedData);
  1935. // 页面显示
  1936. if (modifiedData.trim() === "") setMessage(this.editorMsgBox, '保存成功!空缺设置');
  1937. else if (isModified) {
  1938. await trySetText(
  1939. this.textArea, this.editorMsgBox, modifiedData,
  1940. '保存成功!并自动纠错', true, curCursorPos
  1941. );
  1942. } else setMessage(this.editorMsgBox, '保存成功!');
  1943. this.hasInputed = false;
  1944. }
  1945. _onInheritStateChange() {
  1946. const curInheritState = Store.get(Key.INHERIT_PREVIOUS_MATCHES_KEY);
  1947. if (this.preInheritState !== curInheritState) {
  1948. this.preInheritState = curInheritState;
  1949. return true;
  1950. } else return false;
  1951. }
  1952. /**
  1953. * 对用户输入可能的常见语法与格式错误,进行自动纠错,以满足`JSON`格式
  1954. * 并计算文本修改后,光标的适宜位置
  1955. * 已基本兼容`JavaScript`格式的文本数据,实现格式转化
  1956. * `group2`与`group4`致使正则表达式中不允许出现`/'"`三种字符
  1957. */
  1958. static modifyText(textArea) {
  1959. const preCursorPos = getTextAreaPos(textArea).cursorPos;
  1960. let curCursorPos = preCursorPos;
  1961. let flags = new Array(6).fill(false);
  1962. const rslt = textArea.value.replace(
  1963. /(,\s*)+(?=]|$)|(?<=\[|^)(\s*,)+|(,\s*)+(?=,)|(['‘’“”])|(?<!['"‘“])(\/[^/'"‘’“”]+\/[gimsuy]*)(?!['"’”])|([,、])/g,
  1964. (match, g1, g2, g3, g4, g5, g6, offset) => {
  1965. isTriggered(0, '删除序列末尾元素后的 `,` 逗号', g1);
  1966. isTriggered(1, '删除序列首位元素前的 `,` 逗号', g2);
  1967. isTriggered(2, '删除连续重复的 `,` 逗号', g3);
  1968. isTriggered(3, '将非半角单引号的引号替换', g4);
  1969. isTriggered(4, '将正则表达式以双引号包裹', g5);
  1970. isTriggered(5, '将全角逗号顿号变为半角逗号', g6);
  1971. if (booleanOr(g1, g2, g3)) {
  1972. let diff = preCursorPos - offset;
  1973. if (diff > 0) curCursorPos -= Math.min(diff, match.length);
  1974. return '';
  1975. }
  1976. if (g4) return '"';
  1977. if (g5) {
  1978. if (offset < preCursorPos && preCursorPos < offset + match.length) curCursorPos += 1;
  1979. else if (preCursorPos >= offset + match.length) curCursorPos += 2;
  1980. return `"${match}"`;
  1981. }
  1982. if (g6) return ',';
  1983. return match;
  1984. });
  1985. return [rslt, booleanOr(...flags), curCursorPos];
  1986. function isTriggered(index, msg, ...groups) {
  1987. if (!flags[index] && booleanOr(...groups)) {
  1988. C.log(`${SCRIPT_NAME}:触发自动纠错 - ${msg}`);
  1989. flags[index] = true;
  1990. }
  1991. }
  1992. function booleanOr(...values) {
  1993. return values.reduce((acc, val) => acc || val, false);
  1994. }
  1995. }
  1996. }
  1997. /**
  1998. * 整数步进输入器,
  1999. * 不使用`input.type: 'number'`而是自我搭建相关控制
  2000. <fieldset class="num_input_cntr">
  2001. <span class="text">行数</span>
  2002. <input class="inputtext input_num" type="text" maxlength="2">
  2003. <div class="num_ctrs">
  2004. <div><svg>...</svg></div>
  2005. <div><svg>...</svg></div>
  2006. </div>
  2007. </fieldset>
  2008. */
  2009. class IntInputStepper {
  2010. static default = Key.REFOLD_THRESHOLD_DEFAULT;
  2011. // 所用样式的类名
  2012. static _fieldCls = 'num_input_cntr';
  2013. static _inputCls = 'inputtext input_num';
  2014. static _ctrsCls = 'num_ctrs';
  2015. /**
  2016. * @type {(newNum: int) => void | null}
  2017. * 回调函数,当数据变化时被调用
  2018. */
  2019. onNumChange = null;
  2020. constructor(id, labelName, initNum = IntInputStepper.default) {
  2021. this.root = createElement('fieldset', { class: IntInputStepper._fieldCls });
  2022. this.numInput = null;
  2023. this.incBtn = null;
  2024. this.decBtn = null;
  2025. this.id = id;
  2026. this.labelName = labelName;
  2027. this.initNum = initNum;
  2028. this.minNum = {int: 1, str: '1'};
  2029. this.maxDigits = 2;
  2030. }
  2031. set num(num) {
  2032. if(!num) num = IntInputStepper.default;
  2033. this.numInput.value = String(num);
  2034. }
  2035. get num() {
  2036. return Number(this.numInput.value);
  2037. }
  2038. /** @param {boolean} flag */
  2039. set display(flag) {
  2040. this.root.style.display = flag ? 'flex' : 'none';
  2041. }
  2042. build() {
  2043. // 构建元素结构
  2044. this.numInput = createElement('input', {
  2045. class: IntInputStepper._inputCls, type: 'text', maxlength: this.maxDigits, id: this.id
  2046. });
  2047. this.incBtn = createElement('div', { name: 'inc_btn' });
  2048. this.decBtn = createElement('div', { name: 'dec_btn' });
  2049. this.incBtn.innerHTML = ICON.TRIANGLE_UP;
  2050. this.decBtn.innerHTML = ICON.TRIANGLE_DOWN;
  2051. this.root.append(
  2052. createElement('span', { class: 'text' }, this.labelName),
  2053. this.numInput,
  2054. createElement('div', { class: IntInputStepper._ctrsCls }, [this.incBtn, this.decBtn])
  2055. );
  2056. // 初始化状态并绑定事件
  2057. this.num = this.initNum;
  2058. this.numInput.addEventListener('input', this._onInput.bind(this));
  2059. this.numInput.addEventListener('keydown', this._onKeyDown.bind(this));
  2060. this.incBtn.addEventListener('click', this._onInc.bind(this));
  2061. this.decBtn.addEventListener('click', this._onDec.bind(this));
  2062. }
  2063. /** 限制输入为正整数 */
  2064. _onInput() {
  2065. let value = this.numInput.value.replace(/[^0-9]/g, '');
  2066. if (value === '' || parseInt(value) === 0) value = this.minNum.str;
  2067. this.numInput.value = value;
  2068. if (this.onNumChange) this.onNumChange(this.num);
  2069. }
  2070. /** 限制键盘输入行为,禁止非数字键输入 */
  2071. _onKeyDown(event) {
  2072. if (!/^[0-9]$/.test(event.key) && event.key !== 'Backspace'
  2073. && event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')
  2074. event.preventDefault();
  2075. if (event.key === 'ArrowUp') this._onInc();
  2076. else if (event.key === 'ArrowDown') this._onDec();
  2077. }
  2078. /** 步增,可按钮或键盘触发 */
  2079. _onInc() {
  2080. let value = this.num;
  2081. this.num = value + 1;
  2082. if (this.onNumChange) this.onNumChange(this.num);
  2083. }
  2084. /** 步减,可按钮或键盘触发 */
  2085. _onDec() {
  2086. let value = this.num;
  2087. if (value > this.minNum.int) this.num = value - 1;
  2088. if (this.onNumChange) this.onNumChange(this.num);
  2089. }
  2090. }
  2091. /**
  2092. * 三态滑动选择器
  2093. <div class="tri_state_selector">
  2094. <input type="radio" name="_subject_enable_group" value="allDisable" class="radio_input">
  2095. <label class="radio_label"></label>
  2096. <input type="radio" name="_subject_enable_group" value="partialEnable" class="radio_input">
  2097. <label class="radio_label"></label>
  2098. <input type="radio" name="_subject_enable_group" value="allEnable" class="radio_input">
  2099. <label class="radio_label"></label>
  2100. <div class="select_slider">
  2101. <div class="select_indicator"></div>
  2102. </div>
  2103. </div>
  2104. */
  2105. class TriStateSlider {
  2106. // 所用样式的类名
  2107. static _selectorCls = 'tri_state_selector';
  2108. static _radioCls = 'radio_input';
  2109. static _labelCls = 'radio_label';
  2110. static _sliderCls = 'select_slider';
  2111. static _indicatorCls = 'select_indicator';
  2112. /** 待选状态 */
  2113. states = [
  2114. "0", // 灰
  2115. "1", // 红
  2116. "2", // 蓝
  2117. ];
  2118. /**
  2119. * @type {(newState: string) => void | null}
  2120. * 回调函数,当状态变化时被调用
  2121. */
  2122. onStateChange = null;
  2123. constructor(idPref, states) {
  2124. this.root = createElement('div', { class: TriStateSlider._selectorCls });
  2125. this.radios = {};
  2126. this.idPref = idPref;
  2127. this.states = states.slice(0, 3);
  2128. this.initState = this.states[2];
  2129. this._stateHis = {pre: null, pre2: null};
  2130. }
  2131. set state(state) {
  2132. if (!state || !this.states.includes(state))
  2133. state = this.states[2];
  2134. this.initState = state;
  2135. this._initStateHis();
  2136. this.radios[state].checked = true;
  2137. }
  2138. get state() {
  2139. for (const [state, radio] of Object.entries(this.radios)) {
  2140. if (radio.checked) return state;
  2141. }
  2142. return this.initState;
  2143. }
  2144. /**
  2145. * 构造`DOM`树,并绑定事件
  2146. */
  2147. build() {
  2148. // 构建单选格,radio 本体将通过样式隐藏
  2149. this.states.forEach((state) => {
  2150. const radioId = `${this.idPref}_${state}`;
  2151. const radio = createElement('input', {
  2152. type: 'radio', name: `${this.idPref}_group`, id: radioId,
  2153. value: state, class: TriStateSlider._radioCls
  2154. });
  2155. const label = createElement('label', { htmlFor: radioId, class: TriStateSlider._labelCls });
  2156. this.radios[state] = radio;
  2157. this.root.append(radio, label);
  2158. });
  2159. // 构建滑动外观
  2160. this.root.append(
  2161. createElement('div', { class: TriStateSlider._sliderCls },
  2162. createElement('div', { class: TriStateSlider._indicatorCls })
  2163. ));
  2164. // 初始化状态并绑定事件
  2165. this.radios[this.initState].checked = true;
  2166. // 1) 箭头函数每次事件触发时,都会创建一个新的匿名函数,影响性能
  2167. // this.selector.addEventListener('click', (event) => this._onClick(event));
  2168. // 2) 事件监听器的回调函数本身会改变 this,使得它从指向类的实例对象,变为指向事件触发的元素
  2169. // this.selector.addEventListener('click', this._onClick);
  2170. // 3) 使用绑定后的函数
  2171. this.root.addEventListener('click', this._onClick.bind(this));
  2172. }
  2173. _initStateHis() {
  2174. this._stateHis.pre = this.initState;
  2175. // 设定历史状态,使得无需在 _onClick 为重复点击初始状态单独处理
  2176. this._stateHis.pre2 = this.initState === this.states[1]
  2177. ? this.states[2] : this.states[1]; // ((1,3) 2)->(2 3)
  2178. }
  2179. /**
  2180. * 采用事件委托的形式处理点击事件,
  2181. * 将原本的`radio`操作体验处理为`ToggleSlider`手感
  2182. */
  2183. _onClick(event) {
  2184. if (!event.target.classList.contains('radio_input')) return;
  2185. let curState = event.target.value;
  2186. // 现在与过去互异,正常不处理;现在与过去的过去互异,模拟 Toggle
  2187. if (curState === this._stateHis.pre && curState !== this._stateHis.pre2) {
  2188. this.radios[this._stateHis.pre2].checked = true;
  2189. curState = this._stateHis.pre2;
  2190. }
  2191. this._stateHis.pre2 = this._stateHis.pre;
  2192. this._stateHis.pre = curState;
  2193. // 使用回调函数通知外部
  2194. if (this.onStateChange) this.onStateChange(curState);
  2195. }
  2196. }
  2197. /**
  2198. * 创建一个滑动开关
  2199. * @param {string} sliderId - 开关的`ID`
  2200. * @returns {[HTMLElement, HTMLElement]} 返回`开关`与`开关容器`构成的数组
  2201. <div class="toggle">
  2202. <input class="toggle_input" type="checkbox" id="refold_switch">
  2203. <label class="toggle_slider" for="refold_switch"></label>
  2204. </div>
  2205. */
  2206. function buildToggleSlider(sliderId) {
  2207. const toggle = createElement('input', { class: 'toggle_input', type: 'checkbox', id: sliderId });
  2208. const toggleCntr = createElement('div', { class: 'toggle' },
  2209. [toggle, createElement('label', { class: 'toggle_slider', htmlFor: sliderId })]
  2210. );
  2211. return [toggle, toggleCntr];
  2212. }
  2213. /**
  2214. * 优先尝试使用`execCommand`方法改写文本框,使得改写前的用户历史记录不被浏览器清除
  2215. * (虽然`execCommand`方法已被弃用...但仍然是实现该功能最便捷的途径)
  2216. */
  2217. async function trySetText(textArea, msgBox, text, msg, isRestore, setCursorPos = null, transTime = 100) {
  2218. let {scrollVert, cursorPos} = getTextAreaPos(textArea);
  2219. try {
  2220. setMessage(msgBox);
  2221. await clearAndSetTextarea(textArea, text, transTime);
  2222. setMessage(msgBox, `${msg},可快捷键撤销`, 0);
  2223. } catch {
  2224. textArea.value = '';
  2225. await delay(transTime);
  2226. textArea.value = text;
  2227. setMessage(msgBox, msg, 0);
  2228. C.log(`${SCRIPT_NAME}:浏览器不支持 execCommand 方法,改为直接重置文本框,将无法通过快捷键撤销重置`)
  2229. }
  2230. if (isRestore) {
  2231. setCursorPos ??= cursorPos; // 可以使用外部计算获取的光标位置
  2232. restorePos();
  2233. }
  2234. /**
  2235. * 恢复滚动位置和光标位置
  2236. */
  2237. function restorePos() {
  2238. const currentTextLen = textArea.value.length;
  2239. if (setCursorPos > currentTextLen) setCursorPos = currentTextLen;
  2240. textArea.scrollTop = Math.min(scrollVert, textArea.scrollHeight);
  2241. // textArea.scrollLeft = Math.min(scrollHoriz, textArea.scrollWidth - textArea.clientWidth);
  2242. textArea.setSelectionRange(setCursorPos, setCursorPos);
  2243. }
  2244. }
  2245. /**
  2246. * 获取文本框的滚动位置和光标位置
  2247. */
  2248. function getTextAreaPos(textArea) {
  2249. return {
  2250. scrollVert: textArea.scrollTop,
  2251. scrollHoriz: textArea.scrollLeft,
  2252. cursorPos: textArea.selectionStart
  2253. };
  2254. }
  2255. async function clearAndSetTextarea(textarea, newText, timeout = 100) {
  2256. textarea.focus();
  2257. // 全选文本框内容并删除
  2258. textarea.setSelectionRange(0, textarea.value.length);
  2259. document.execCommand('delete');
  2260. // 延迟一段时间后,插入新的内容
  2261. await delay(timeout);
  2262. document.execCommand('insertText', false, newText);
  2263. }
  2264. async function setMessage(container, message, timeout = 100) {
  2265. container.style.display = 'none';
  2266. if (!message) return; // 无信息输入,则隐藏
  2267. // 隐藏一段时间后,展现新内容
  2268. if (timeout) await delay(timeout);
  2269. container.textContent = message;
  2270. container.style.display = 'inline';
  2271. }
  2272. /**
  2273. * 获取当前页面的视口高度
  2274. */
  2275. function getViewportHeight() {
  2276. return document.documentElement.clientHeight || document.body.clientHeight;
  2277. }
  2278. /**
  2279. * 获取该元素前后所有连续的符合要求的兄弟节点
  2280. * @param {HTMLElement} element - 元素
  2281. * @param {'both' | 'previous' | 'next'} direction - 捕获的方向
  2282. * @param {(el: HTMLElement) => boolean} [condition=() => true]
  2283. * - 兄弟节点的条件要求,一旦结果为否将终止该方向的捕获
  2284. * @returns {Array<HTMLElement>}
  2285. */
  2286. function getSiblings(element, direction, condition = () => true) {
  2287. let siblings = [];
  2288. if (direction === 'both' || direction === 'previous') {
  2289. let sibling = element.previousElementSibling;
  2290. while (sibling && condition(sibling)) {
  2291. siblings.push(sibling);
  2292. sibling = sibling.previousElementSibling;
  2293. }
  2294. }
  2295. if (direction === 'both' || direction === 'next') {
  2296. let sibling = element.nextElementSibling;
  2297. while (sibling && condition(sibling)) {
  2298. siblings.push(sibling);
  2299. sibling = sibling.nextElementSibling;
  2300. }
  2301. }
  2302. return siblings;
  2303. }
  2304. /**
  2305. * 创建元素实例
  2306. * @param {string} tagName - 类名
  2307. * @param {Object | undefined} options - 属性
  2308. * @param {Array<HTMLElement | string> | undefined} subElements - 子元素
  2309. * @param {Object<string, Function> | undefined} eventHandlers - 绑定的事件
  2310. */
  2311. function createElement(tagName, options, subElements, eventHandlers) {
  2312. const element = document.createElement(tagName);
  2313. if (options) {
  2314. for (const opt of Object.keys(options)) {
  2315. if (opt === 'class') element.className = options[opt];
  2316. else if (['maxlength', 'open'].includes(opt)) {
  2317. element.setAttribute(opt, options[opt]);
  2318. } else if (opt === 'dataset' || opt === 'style') {
  2319. for (const key of Object.keys(options[opt])) {
  2320. element[opt][key] = options[opt][key];
  2321. }
  2322. } else element[opt] = options[opt];
  2323. }
  2324. }
  2325. if (subElements) updat###bElements(element, subElements);
  2326. if (eventHandlers) {
  2327. for (const e of Object.keys(eventHandlers)) {
  2328. element.addEventListener(e, eventHandlers[e]);
  2329. }
  2330. }
  2331. return element;
  2332. }
  2333. /**
  2334. * 更新子元素的内容
  2335. * @param {HTMLElement} parent - 父元素
  2336. * @param {Array<HTMLElement | string> | HTMLElement | string | undefined} subElements - 要插入的子元素
  2337. * @param {'append' | 'prepend' | 'replace'} [actionType='append'] - 操作类型,可以是以下之一:
  2338. * - `prepend` 将元素插入到父元素的首位
  2339. * - `append` 将元素插入到父元素的末尾
  2340. * - `replace` 清空父元素内容并插入元素
  2341. */
  2342. function updat###bElements(parent, subElements, actionType = 'append') {
  2343. if (actionType === 'replace') parent.innerHTML = '';
  2344. if (!subElements) return parent;
  2345. if (!utils.isArray(subElements)) subElements = [subElements];
  2346. subElements = subElements.map(e => typeof e === 'string' ? document.createTextNode(e) : e);
  2347. switch (actionType) {
  2348. case 'append':
  2349. case 'replace':
  2350. parent.append(...subElements);
  2351. break;
  2352. case 'prepend':
  2353. parent.prepend(...subElements);
  2354. break;
  2355. default:
  2356. throw new Error(`'${actionType}' is invalid action type of updateElements!`);
  2357. }
  2358. return parent;
  2359. }
  2360. /**
  2361. * 使用闭包定义防抖动函数模板。
  2362. * 若为立即执行,将先执行首次触发,再延迟执行最后一次触发
  2363. * @param {Function} func - 回调函数
  2364. * @param {boolean} [immediate=false] - 是否先立即执行
  2365. */
  2366. function debounce(func, immediate = false, delay = DEBOUNCE_DELAY) {
  2367. let timer = null;
  2368. return function (...args) {
  2369. const context = this; // 保存调用时的上下文
  2370. const callNow = immediate && !timer;
  2371. if (timer) clearTimeout(timer);
  2372. // 设置新的定时器
  2373. timer = setTimeout(() => {
  2374. timer = null;
  2375. if (!immediate) func.apply(context, args); // 延时执行
  2376. }, delay);
  2377. if (callNow) func.apply(context, args); // 立即执行
  2378. };
  2379. }
  2380. /** 异步延迟 */
  2381. function delay(ms) {
  2382. return new Promise(resolve => setTimeout(resolve, ms));
  2383. }
  2384. /** 基本工具函数集 */
  2385. const utils = {
  2386. isObject: (value) => value !== null && typeof value === 'object',
  2387. isArray: Array.isArray,
  2388. isSet: (value) => value instanceof Set,
  2389. isRegExp: (value) => value instanceof RegExp,
  2390. isString: (value) => typeof value === 'string',
  2391. isBoolean: (value) => typeof value === 'boolean',
  2392. isFunction: (value) => typeof value === 'function',
  2393. isNumber: (value) => typeof value === 'number' && !isNaN(value),
  2394. isFinity: (num) => Number.isFinite(num),
  2395. /**
  2396. * 过滤对象中的方法,只返回对象的枚举值
  2397. * @param {Object} obj - 需要过滤的对象
  2398. * @param {(value: any) => boolean} [filterFn=value => typeof value !== 'function']
  2399. * - 过滤函数 (默认过滤掉函数类型)
  2400. * @returns {Array} 过滤后的枚举值数组
  2401. */
  2402. filterEnumValues: (
  2403. obj, filterFn = (value) => typeof value !== 'function'
  2404. ) => Object.values(obj).filter(filterFn),
  2405. /**
  2406. * 归并排序
  2407. * @param {Array} arr1 - 第一个有序数组
  2408. * @param {Array} arr2 - 第二个有序数组
  2409. * @param {number} [len1=arr1.length] - 第一个数组的归并长度
  2410. * @param {number} [len2=arr2.length] - 第二个数组的归并长度
  2411. * @param {(a: any, b: any) => number} [compareFn=(a, b) => a - b] - 自定义比较函数
  2412. * @returns {Array} 归并后的有序数组
  2413. */
  2414. mergeSorted: (
  2415. arr1, arr2, len1 = arr1.length, len2 = arr2.length, compareFn = (a, b) => a - b
  2416. ) => {
  2417. if (len1 > arr1.length) len1 = arr1.length;
  2418. if (len2 > arr2.length) len2 = arr2.length;
  2419. const sortedArray = [];
  2420. let i = 0;
  2421. let j = 0;
  2422. while (i < len1 && j < len2) {
  2423. if (compareFn(arr1[i], arr2[j]) <= 0) sortedArray.push(arr1[i++]);
  2424. else sortedArray.push(arr2[j++]);
  2425. }
  2426. while (i < len1) sortedArray.push(arr1[i++]);
  2427. while (j < len2) sortedArray.push(arr2[j++]);
  2428. return sortedArray;
  2429. },
  2430. };
  2431. /**
  2432. * `infobox.li`职位人员信息的计算样式
  2433. */
  2434. const JobStyle = {
  2435. compStyle: null,
  2436. // fontSize: null, // num
  2437. lineHeight: null, // num
  2438. borderBottom: null, // px
  2439. paddingBottom: null, // px
  2440. initialize(el) {
  2441. this.compStyle = window.getComputedStyle(el); // 通常不会返回 em % normal 类别的数据
  2442. // this.fontSize = parseFloat(this.compStyle.fontSize);
  2443. this.lineHeight = parseFloat(this.compStyle.lineHeight);
  2444. this.borderBottom = this.compStyle.borderBottomWidth;
  2445. this.paddingBottom = this.compStyle.paddingBottom;
  2446. C.log(`${SCRIPT_NAME}:`, {
  2447. 'lineHeight': `${this.lineHeight}px`,
  2448. 'borderBottom': this.borderBottom,
  2449. 'paddingBottom': this.paddingBottom,
  2450. });
  2451. },
  2452. /** 设置职位排序的样式参数 */
  2453. setStaffStyleProperty() {
  2454. if (!JobStyle.compStyle) return;
  2455. document.documentElement.style.setProperty('--job-line-height', `${this.lineHeight}px`);
  2456. document.documentElement.style.setProperty('--job-border-bottom', this.borderBottom);
  2457. document.documentElement.style.setProperty('--job-padding-bottom', this.paddingBottom);
  2458. },
  2459. }
  2460. /**
  2461. * 动态载入职位排序的默认样式。
  2462. * 通过先于`DOM`操作载入样式再设置参数的方式,加快解析与渲染。
  2463. */
  2464. function loadStaffStyle() {
  2465. const style = createElement('style', {class: 'staff_sorting'});
  2466. style.innerHTML = `
  2467. :root {
  2468. --refold-threshold: ${Store.get(Key.REFOLD_THRESHOLD_KEY)};
  2469. --job-line-height: 18px;
  2470. --job-border-bottom: 0.64px;
  2471. --job-padding-bottom: 4px;
  2472. }
  2473. /* 删除与前继元素重复的边线 */
  2474. #infobox li.sub_container li.sub_section:first-child,
  2475. #infobox li.sub_group,
  2476. html[data-theme='dark'] ul#infobox li.sub_group {
  2477. border-top: none; !important
  2478. }
  2479. /* 优化小组样式 */
  2480. #infobox li:not(.last_group)[attr-info-group] {
  2481. border-bottom: none;
  2482. }
  2483. #infobox li:not(.last_group)[attr-info-group] > ul {
  2484. border-bottom: 3px solid #fafafa;
  2485. }
  2486. html[data-theme='dark'] #infobox li:not(.last_group)[attr-info-group] > ul {
  2487. border-bottom: 3px solid #3d3d3f;
  2488. }
  2489. /* 部分情形下隐藏页面右小角的悬浮窗口 */
  2490. #dock.hidden {
  2491. display: none;
  2492. }
  2493. /* 防止图标可能污染爬取 infobox 数据的脚本 */
  2494. .staff_sorting_icon {
  2495. display: none;
  2496. }
  2497. #infobox .staff_sorting_icon {
  2498. display: inline;
  2499. }
  2500. /* 公用 */
  2501. #infobox span.tip.switch:hover i,
  2502. #infobox span.tip.side:hover i,
  2503. #infobox span.folds_list:hover {
  2504. color: #2ea6ff;
  2505. }
  2506. /* 职位信息一次折叠 */
  2507. #infobox li.foldable {
  2508. background-color: transparent;
  2509. transition: background-color 1s ease;
  2510. }
  2511. #infobox li.foldable.folded {
  2512. display: list-item;
  2513. background-color: grey;
  2514. visibility: hidden; /* 不移除元素,只是隐藏 */
  2515. height: 0;
  2516. padding: 0;
  2517. margin: 0;
  2518. border: none;
  2519. }
  2520. /* 更多制作人员 */
  2521. #infobox + .infobox_expand {
  2522. display: grid;
  2523. grid-template-columns: 1fr 1fr;
  2524. border-bottom: 1px solid #EEE; /* 同原有的border-top */
  2525. }
  2526. html[data-theme='dark'] #infobox + .infobox_expand {
  2527. border-bottom: 1px solid #444;
  2528. }
  2529. #infobox + .infobox_expand > a {
  2530. cursor: pointer;
  2531. }
  2532. #infobox + .infobox_expand > a:first-child {
  2533. border-right: 2px solid #EEE;
  2534. }
  2535. html[data-theme='dark'] #infobox + .infobox_expand > a:first-child {
  2536. border-right: 2px solid #2d2e2f;
  2537. }
  2538. /* 职位信息一次折叠队列展开 */
  2539. #infobox.padding_bottom {
  2540. padding-bottom: 10px;
  2541. }
  2542. #infobox li {
  2543. position: relative;
  2544. }
  2545. #infobox span.folds_list {
  2546. position: absolute;
  2547. width: 32px;
  2548. height: 8px;
  2549. right: -8px;
  2550. cursor: pointer;
  2551. }
  2552. #infobox span.folds_list.hidden {
  2553. display: none;
  2554. }
  2555. #infobox span.folds_list:hover {
  2556. height: 16px;
  2557. }
  2558. #infobox span.folds_list.expand_top {
  2559. top: calc(-4px - var(--job-border-bottom) / 2);
  2560. }
  2561. #infobox span.folds_list.expand_top:hover {
  2562. top: calc(-8px - var(--job-border-bottom) / 2);
  2563. }
  2564. #infobox span.folds_list.expand_bottom {
  2565. bottom: calc(-4px - var(--job-border-bottom) / 2);
  2566. }
  2567. #infobox span.folds_list.expand_bottom:hover {
  2568. bottom: calc(-8px - var(--job-border-bottom) / 2);
  2569. }
  2570. #infobox span.folds_list a,
  2571. #infobox span.folds_list svg {
  2572. position: absolute;
  2573. height: 100%;
  2574. top: 0;
  2575. }
  2576. #infobox span.folds_list a {
  2577. width: 100%;
  2578. left: 0;
  2579. }
  2580. #infobox span.folds_list svg {
  2581. width: auto;
  2582. right: 0;
  2583. }
  2584. #infobox span.folds_list:hover svg {
  2585. right: -4px;
  2586. }
  2587. /* 职位信息二次折叠 */
  2588. #infobox li.refoldable {
  2589. display: inline-block; /* 使其容#.tip.side */
  2590. overflow: visible;
  2591. height: auto;
  2592. }
  2593. #infobox li.refolded {
  2594. display: block;
  2595. overflow: hidden;
  2596. height: calc(var(--refold-threshold) * var(--job-line-height));
  2597. /* 由下至上进行遮蔽 */
  2598. -webkit-mask-image:
  2599. linear-gradient(black, black), /* 显现 border-bottom */
  2600. linear-gradient(transparent, transparent), /* 隐藏溢出到 padding-bottom 区域的信息 */
  2601. linear-gradient(160deg, black 10%, transparent 90%), /* 修饰最后一行人员信息 */
  2602. linear-gradient(black, black); /* 显现其余的人员信息 */
  2603. mask-image:
  2604. linear-gradient(black, black),
  2605. linear-gradient(transparent, transparent),
  2606. linear-gradient(160deg, black 10%, transparent 90%),
  2607. linear-gradient(black, black);
  2608. -webkit-mask-size:
  2609. 100% var(--job-border-bottom),
  2610. 100% var(--job-padding-bottom),
  2611. 100% var(--job-line-height),
  2612. 100% 100%;
  2613. mask-size:
  2614. 100% var(--job-border-bottom),
  2615. 100% var(--job-padding-bottom),
  2616. 100% var(--job-line-height),
  2617. 100% 100%;
  2618. -webkit-mask-position:
  2619. 0 100%,
  2620. 0 calc(100% - var(--job-border-bottom)),
  2621. 0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom)),
  2622. 0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom) - var(--job-line-height));
  2623. mask-position:
  2624. 0 100%,
  2625. 0 calc(100% - var(--job-border-bottom)),
  2626. 0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom)),
  2627. 0 calc(100% - var(--job-border-bottom) - var(--job-padding-bottom) - var(--job-line-height));
  2628. -webkit-mask-repeat: no-repeat;
  2629. mask-repeat: no-repeat;
  2630. -webkit-mask-composite: source-over;
  2631. mask-composite: add;
  2632. }
  2633. #infobox .tip.switch,
  2634. #infobox .tip.side {
  2635. cursor: pointer;
  2636. }
  2637. #infobox .tip.switch:hover {
  2638. color: #000;
  2639. }
  2640. html[data-theme='dark'] #infobox .tip.switch:hover {
  2641. color: #FFF;
  2642. }
  2643. #infobox .tip.side {
  2644. display: none;
  2645. float: right; /* 将其推到尾行右侧 */
  2646. clear: right; /* 如果尾行放不下,则换到新行 */
  2647. margin: 0 5px;
  2648. }
  2649. #infobox .tip.side[data-refold-line] {
  2650. display: inline-block;
  2651. }
  2652. `;
  2653. document.head.appendChild(style);
  2654. }
  2655. /** 载入设置界面的样式 */
  2656. function loadSettingStyle() {
  2657. const style = createElement('style', {class: 'staff_sorting'});
  2658. // 使用CSS变量提高对代码的复用性
  2659. style.innerHTML = `
  2660. :root {
  2661. --state-selector-size: 22px;
  2662. --state-selector-step: 19px;
  2663. }
  2664. /* 设置界面的样式 */
  2665. #staff_sorting > .settings {
  2666. margin-left: 5px;
  2667. }
  2668. #staff_sorting .right_inline {
  2669. display: flex;
  2670. float: right;
  2671. align-items: center;
  2672. }
  2673. #staff_sorting .right_inline .tip_j {
  2674. margin-right: 15px;
  2675. }
  2676. #staff_sorting .hidden {
  2677. display: none;
  2678. }
  2679. #staff_sorting h2 {
  2680. display: inline-block;
  2681. }
  2682. #staff_sorting [class*='_block'] h2 {
  2683. font-size: 16px;
  2684. }
  2685. /* 主标题 */
  2686. #staff_sorting .maintitle .right_inline {
  2687. height: 25px;
  2688. }
  2689. #staff_sorting .maintitle .tip_j {
  2690. font-size: 13px;
  2691. margin-right: 6px;
  2692. }
  2693. /* 二次折叠的开关位置设置 */
  2694. #staff_sorting .line_limit_block.turn_off ~ .refold_switch_pos_block {
  2695. display: none;
  2696. }
  2697. /* 各类型条目的职位设置模块 */
  2698. .subject_staff_block h2,
  2699. .subject_staff_block summary::marker {
  2700. cursor: pointer;
  2701. }
  2702. .subject_staff_block .tip_j {
  2703. display: none;
  2704. margin: 0 5px;
  2705. }
  2706. .subject_staff_block .staffMapList_editor {
  2707. padding-right: 10%;
  2708. margin-bottom: 5px;
  2709. }
  2710. .staffMapList_editor .inputBtn {
  2711. margin-right: 5px;
  2712. }
  2713. .staffMapList_editor textarea {
  2714. font-size: 15px;
  2715. line-height: 21px;
  2716. }
  2717. /* 数字输入框与控制器 */
  2718. .num_input_cntr {
  2719. display: flex;
  2720. float: left;
  2721. align-items: center;
  2722. gap: 5px;
  2723. margin-right: 30px;
  2724. }
  2725. .num_input_cntr .text {
  2726. font-size: 14px;
  2727. margin-right: 2px;
  2728. }
  2729. .inputtext.input_num {
  2730. width: 30px;
  2731. height: 11px;
  2732. text-align: center;
  2733. font-size: 15px;
  2734. }
  2735. .num_ctrs {
  2736. display: flex;
  2737. flex-direction: column;
  2738. background-color: white;
  2739. border: 1px solid #d9d9d9;
  2740. border-radius: 4px;
  2741. box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
  2742. gap: 0;
  2743. }
  2744. html[data-theme="dark"] .num_ctrs {
  2745. background-color: black;
  2746. border: 1px solid #757575;
  2747. }
  2748. .num_ctrs div {
  2749. display: flex;
  2750. text-align: center;
  2751. width: 12px;
  2752. height: 6.5px;
  2753. padding: 2px;
  2754. cursor: pointer;
  2755. }
  2756. .num_ctrs div:first-child {
  2757. border-radius: 3px 3px 0 0;
  2758. }
  2759. .num_ctrs div:last-child {
  2760. border-radius: 0 0 3px 3px;
  2761. }
  2762. .num_ctrs div svg {
  2763. width: 100%;
  2764. height: 100%;
  2765. }
  2766. .num_ctrs div:active {
  2767. background-color: #2ea6ff;
  2768. }
  2769. /* 滑动开关 */
  2770. .toggle {
  2771. position: relative;
  2772. width: 44px;
  2773. height: 22px;
  2774. display: block;
  2775. float: right;
  2776. }
  2777. .toggle_input {
  2778. display: none;
  2779. }
  2780. .toggle_slider {
  2781. position: absolute;
  2782. cursor: pointer;
  2783. top: 0;
  2784. left: 0;
  2785. right: 0;
  2786. bottom: 0;
  2787. background-color: #eaeaea;
  2788. border-radius: 22px;
  2789. box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.2);
  2790. transition: background-color 0.2s ease-in;
  2791. }
  2792. html[data-theme="dark"] .toggle_slider {
  2793. background-color: #9a9a9a;
  2794. }
  2795. .toggle_slider::before {
  2796. content: "";
  2797. position: absolute;
  2798. height: 16px;
  2799. width: 16px;
  2800. left: 3px;
  2801. bottom: 3px;
  2802. background-color: white;
  2803. border-radius: 50%;
  2804. box-shadow: 0 2px 3px rgba(0, 0, 0, 0.3);
  2805. transition: transform 0.2s ease-in;
  2806. }
  2807. .toggle_input:checked + .toggle_slider {
  2808. background-color: #72b6e3;
  2809. }
  2810. html[data-theme="dark"] .toggle_input:checked + .toggle_slider {
  2811. background-color: #3072dc;
  2812. }
  2813. .toggle_input:checked + .toggle_slider::before {
  2814. transform: translateX(22px);
  2815. }
  2816. /* 滑动选择器公有 */
  2817. .tri_state_selector {
  2818. position: relative;
  2819. height: var(--state-selector-size);
  2820. display: inline-block;
  2821. }
  2822. .radio_input {
  2823. position: absolute;
  2824. opacity: 0;
  2825. z-index: 2;
  2826. }
  2827. .select_slider {
  2828. position: relative;
  2829. width: 100%;
  2830. height: 100%;
  2831. background-color: #eaeaea;
  2832. border-radius: var(--state-selector-size);
  2833. box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
  2834. z-index: 1;
  2835. overflow: hidden;
  2836. transition: background-color 0.2s ease-in;
  2837. }
  2838. html[data-theme="dark"] .select_slider {
  2839. background-color: #9a9a9a;
  2840. }
  2841. .select_indicator {
  2842. position: absolute;
  2843. width: calc(var(--state-selector-size) - 4px);
  2844. height: calc(var(--state-selector-size) - 4px);
  2845. top: 2px;
  2846. left: 2px;
  2847. background-color: white;
  2848. border-radius: 50%;
  2849. box-shadow: 0 2px 3px rgba(0, 0, 0, 0.3);
  2850. z-index: 1;
  2851. transition: transform 0.2s ease-in;
  2852. }
  2853. .radio_label {
  2854. position: absolute;
  2855. width: var(--state-selector-step);
  2856. height: 100%;
  2857. top: 0;
  2858. cursor: pointer;
  2859. z-index: 3;
  2860. }
  2861. /* 三态滑动选择器 */
  2862. .tri_state_selector {
  2863. width: calc(
  2864. var(--state-selector-size) + var(--state-selector-step) * 2
  2865. );
  2866. }
  2867. label.radio_label:nth-of-type(1) {
  2868. left: 0;
  2869. }
  2870. label.radio_label:nth-of-type(2) {
  2871. left: var(--state-selector-step);
  2872. }
  2873. label.radio_label:nth-of-type(3) {
  2874. width: var(--state-selector-size);
  2875. left: calc(var(--state-selector-step) * 2);
  2876. }
  2877. input.radio_input:nth-of-type(2):checked ~ .select_slider {
  2878. background-color: #f47a88;
  2879. }
  2880. input.radio_input:nth-of-type(3):checked ~ .select_slider {
  2881. background-color: #72b6e3;
  2882. }
  2883. html[data-theme="dark"] input.radio_input:nth-of-type(2):checked ~ .select_slider {
  2884. background-color: #ff668a;
  2885. }
  2886. html[data-theme="dark"] input.radio_input:nth-of-type(3):checked ~ .select_slider {
  2887. background-color: #3072dc;
  2888. }
  2889. input.radio_input:nth-of-type(1):checked ~ .select_slider .select_indicator {
  2890. transform: translateX(0);
  2891. }
  2892. input.radio_input:nth-of-type(2):checked ~ .select_slider .select_indicator {
  2893. transform: translateX(var(--state-selector-step));
  2894. }
  2895. input.radio_input:nth-of-type(3):checked ~ .select_slider .select_indicator {
  2896. transform: translateX(calc(var(--state-selector-step) * 2));
  2897. }
  2898. .select_slider::after {
  2899. content: "";
  2900. position: absolute;
  2901. width: calc(var(--state-selector-size) + var(--state-selector-step));
  2902. height: var(--state-selector-size);
  2903. left: var(--state-selector-step);
  2904. border-radius: calc(var(--state-selector-size) / 2);
  2905. box-shadow: 0 0 3px rgba(0, 0, 0, 0.1), inset 0 0 6px rgba(0, 0, 0, 0.3);
  2906. transition: transform 0.2s ease-in-out;
  2907. }
  2908. input.radio_input:nth-of-type(1):checked ~ .select_slider::after {
  2909. transform: translateX(calc(0px - var(--state-selector-step)));
  2910. }
  2911. `;
  2912. document.head.appendChild(style);
  2913. }
  2914. main();
  2915. })();