Bangumi Ultimate Enhancer

Bangumi 终极增强套件 - 集成Wiki按钮、关联按钮、封面上传、批量关联、批量分集编辑等功能

  1. // ==UserScript==
  2. // @name Bangumi Ultimate Enhancer
  3. // @namespace https://tampermonkey.net/
  4. // @version 2.6.2
  5. // @description Bangumi 终极增强套件 - 集成Wiki按钮、关联按钮、封面上传、批量关联、批量分集编辑等功能
  6. // @author Bios (improved by Claude)
  7. // @match *://bgm.tv/subject/*
  8. // @match *://chii.in/subject/*
  9. // @match *://bangumi.tv/subject*
  10. // @match *://bgm.tv/character/*
  11. // @match *://chii.in/character/*
  12. // @match *://bangumi.tv/character/*
  13. // @match *://bgm.tv/person/*
  14. // @match *://chii.in/person/*
  15. // @match *://bangumi.tv/person/*
  16. // @exclude */character/*/add_related/person*
  17. // @exclude */person/*/add_related/character*
  18. // @connect bgm.tv
  19. // @icon https://lain.bgm.tv/pic/icon/l/000/00/01/128.jpg
  20. // @grant GM_xmlhttpRequest
  21. // @license MIT
  22. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
  23. // @run-at document-idle
  24. // ==/UserScript==
  25.  
  26. (function() {
  27. "use strict";
  28.  
  29. // 样式注入
  30. function injectStyles() {
  31. $('head').append(`
  32. <style>
  33. /* 统一色彩变量 - 定义整个样式表使用的全局CSS变量 */
  34. :root {
  35. --primary-color: #369CF8; /* 主要颜色,蓝色 */
  36. --primary-light: rgba(74, 144, 226, 0.1); /* 主要颜色的浅色版本,透明度10% */
  37. --primary-hover: #3A80D2; /* 鼠标悬停时的主要颜色,深蓝色 */
  38. --text-primary: #333333; /* 主要文本颜色,深灰色 */
  39. --text-secondary: #666666; /* 次要文本颜色,中灰色 */
  40. --border-color: #E0E0E0; /* 边框颜色,浅灰色 */
  41. --shadow-soft: 0 4px 10px rgba(0, 0, 0, 0.05); /* 柔和阴影效果 */
  42. --shadow-hover: 0 6px 14px rgba(0, 0, 0, 0.1); /* 鼠标悬停时的阴影效果,更明显 */
  43. --success-color: #42B983; /* 成功状态颜色,绿色 */
  44. --warning-color: #E6A23C; /* 警告状态颜色,橙色 */
  45. --danger-color: #F56C6C; /* 危险/错误状态颜色,红色 */
  46. --info-color: #4A90E2; /* 信息状态颜色,与主要颜色相同的蓝色 */
  47. --new-color: #F09199; /* 新增粉色变量 */
  48. --border-radius: 8px; /* 统一的边框圆角大小 */
  49. --transition-normal: all 0.1s cubic-bezier(0.4, 0, 0.2, 1); /* 标准过渡动画效果 */
  50. }
  51.  
  52. /* 通用按钮美化 - 定义自定义按钮样式 */
  53. .btnCustom {
  54. margin: 5px 0; /* 上下外边距5px,左右0 */
  55. background-color: var(--primary-color) !important; /* 使用主要颜色作为背景,强制覆盖 */
  56. color: white !important; /* 文字颜色为白色,强制覆盖 */
  57. border-radius: var(--border-radius) !important; /* 使用统一的圆角大小,强制覆盖 */
  58. padding: 8px 16px !important; /* 内边距:上下8px,左右16px,强制覆盖 */
  59. border: none !important; /* 移除边框,强制覆盖 */
  60. cursor: pointer !important; /* 鼠标悬停时显示手指图标,强制覆盖 */
  61. font-size: 14px; /* 文字大小14像素 */
  62. font-weight: 600; /* 字体粗细程度,半粗体 */
  63. text-align: center; /* 文字居中对齐 */
  64. display: flex; /* 使用弹性布局 */
  65. justify-content: center; /* 水平居中对齐 */
  66. align-items: center; /* 垂直居中对齐 */
  67. transition: var(--transition-normal); /* 应用统一的过渡动画 */
  68. box-shadow: var(--shadow-soft); /* 应用柔和阴影效果 */
  69. }
  70. .btnCustom:hover {
  71. background-color: var(--primary-hover) !important; /* 鼠标悬停时背景色变深,强制覆盖 */
  72. transform: translateY(-1px); /* 鼠标悬停时按钮上移1像素,产生悬浮效果 */
  73. box-shadow: var(--shadow-hover); /* 鼠标悬停时阴影效果更明显 */
  74. }
  75.  
  76. /* 文本域美化 - 美化多行文本输入框 */
  77. .enhancer-textarea {
  78. width: 100%; /* 宽度占满父容器 */
  79. min-height: 80px; /* 最小高度80像素 */
  80. max-height: 300px; /* 最大高度300像素 */
  81. border: 1px solid var(--border-color); /* 1像素实线边框,使用统一边框颜色 */
  82. border-radius: var(--border-radius); /* 使用统一的圆角大小 */
  83. padding: 12px; /* 四周内边距12像素 */
  84. margin: 10px 0; /* 上下外边距10像素,左右0 */
  85. resize: vertical; /* 仅允许垂直方向调整大小 */
  86. font-size: 14px; /* 文字大小14像素 */
  87. box-sizing: border-box; /* 盒模型计算方式:边框和内边距包含在宽高中 */
  88. background: white; /* 背景色为白色 */
  89. transition: var(--transition-normal); /* 应用统一的过渡动画 */
  90. }
  91. .enhancer-textarea:focus {
  92. border-color: var(--primary-color); /* 获得焦点时边框颜色变为主要颜色 */
  93. box-shadow: 0 0 0 3px var(--primary-light); /* 获得焦点时添加主要颜色的浅色阴影 */
  94. outline: none; /* 移除默认的焦点轮廓 */
  95. }
  96.  
  97. /* 数字输入框 - 美化数字类型的输入框 */
  98. .input-number {
  99. width: 100px; /* 宽度100像素 */
  100. height: 10px; /* 高度10像素 */
  101. padding: 10px; /* 四周内边距10像素 */
  102. border-radius: var(--border-radius); /* 使用统一的圆角大小 */
  103. border: 1px solid var(--border-color); /* 1像素实线边框,使用统一边框颜色 */
  104. background: white; /* 背景色为白色 */
  105. transition: var(--transition-normal); /* 应用统一的过渡动画 */
  106. font-size: 14px; /* 文字大小14像素 */
  107. }
  108. .input-number:focus {
  109. border-color: var(--primary-color); /* 获得焦点时边框颜色变为主要颜色 */
  110. box-shadow: 0 0 0 3px var(--primary-light); /* 获得焦点时添加主要颜色的浅色阴影 */
  111. outline: none; /* 移除默认的焦点轮廓 */
  112. }
  113.  
  114. /* Relation 相关样式 - 特定组件的样式定义 */
  115. .Relation_wrapper {
  116. width: 280px; /* 宽度280像素 */
  117. margin: 18px 0; /* 上下间距18px,左右0 */
  118. text-align: center; /* 文字居中对齐 */
  119. background: white; /* 背景色为白色 */
  120. padding: 18px; /* 四周内边距18像素 */
  121. border-radius: var(--border-radius); /* 使用统一的圆角大小 */
  122. border: 1px solid var(--border-color); /* 1像素实线边框,使用统一边框颜色 */
  123. box-shadow: var(--shadow-soft); /* 应用柔和阴影效果 */
  124. transition: var(--transition-normal); /* 应用统一的过渡动画 */
  125. }
  126. .Relation_wrapper:hover {
  127. box-shadow: var(--shadow-hover); /* 鼠标悬停时阴影效果更明显 */
  128. }
  129. .select-label {
  130. display: flex; /* 使用弹性布局 */
  131. margin-right: auto; /* 右侧外边距自动(将内容推到左侧) */
  132. font-weight: 600; /* 字体粗细程度,半粗体 */
  133. color: var(--text-secondary); /* 使用次要文本颜色 */
  134. }
  135. .Relation_item_type {
  136. display: flex; /* 使用弹性布局 */
  137. margin-right: auto; /* 右侧外边距自动(将内容推到左侧) */
  138. align-items: center; /* 垂直居中对齐 */
  139. }
  140. .Relation_progress {
  141. margin: 14px 0; /* 上下外边距14像素,左右0 */
  142. color: var(--primary-color); /* 使用主要颜色 */
  143. font-weight: 600; /* 字体粗细程度,半粗体 */
  144. font-size: 18px; /* 文字大小18像素 */
  145. text-align: center; /* 文字居中对齐 */
  146. }
  147.  
  148. /* 状态提示区域 - 用于显示不同状态的提示信息 */
  149. .Relation_item_not_found,
  150. .Relation_item_dupe,
  151. .Relation_item_type_changed,
  152. .Relation_item_unchanged{
  153. margin-top: 10px; /* 顶部外边距10像素 */
  154. padding: 12px; /* 四周内边距12像素 */
  155. min-height: 15px; /* 最小高度15像素 */
  156. border-radius: var(--border-radius); /* 使用统一的圆角大小 */
  157. font-size: 14px; /* 文字大小14像素 */
  158. transition: var(--transition-normal); /* 应用统一的过渡动画 */
  159. }
  160. .Relation_item_not_found {
  161. color: var(--danger-color); /* 文字颜色使用危险/错误状态颜色 */
  162. background: rgba(245, 108, 108, 0.1); /* 浅红色背景,10%透明度 */
  163. border: 1px solid rgba(245, 108, 108, 0.2); /* 红色边框,20%透明度 */
  164. }
  165. .Relation_item_dupe {
  166. color: var(--info-color); /* 文字颜色使用信息状态颜色 */
  167. background: rgba(74, 144, 226, 0.1); /* 浅蓝色背景,10%透明度 */
  168. border: 1px solid rgba(74, 144, 226, 0.2); /* 蓝色边框,20%透明度 */
  169. }
  170. .Relation_item_type_changed {
  171. color: var(--success-color); /* 文字颜色使用成功状态颜色 */
  172. background: rgba(66, 185, 131, 0.1); /* 浅绿色背景,10%透明度 */
  173. border: 1px solid rgba(66, 185, 131, 0.2); /* 绿色边框,20%透明度 */
  174. }
  175.  
  176. .Relation_item_unchanged {
  177. color: var(--warning-color); /* 文字颜色使用警告状态颜色 */
  178. background: rgba(230, 162, 60, 0.1); /* 浅橙色背景,10%透明度 */
  179. border: 1px solid rgba(230, 162, 60, 0.2); /* 橙色边框,20%透明度 */
  180. }
  181. .Relation_header {
  182. font-size: 17px; /* 文字大小17像素 */
  183. margin: 14px 0 8px; /* 上边距14像素,左右0,下边距8像素 */
  184. color: var(--text-primary); /* 使用主要文本颜色 */
  185. font-weight: 600; /* 字体粗细程度,半粗体 */
  186. text-align: left; /* 内容靠左对齐 */
  187. }
  188. .Relation_controls {
  189. display: flex; /* 使用弹性布局 */
  190. justify-content: flex-end; /* 内容靠右对齐 */
  191. align-items: center; /* 垂直居中对齐 */
  192. gap: 10px; /* 元素之间的间距10像素 */
  193. }
  194.  
  195. /* 布局辅助 - 提供辅助布局的通用类 */
  196. .flex-row {
  197. display: flex; /* 使用弹性布局 */
  198. gap: 14px; /* 元素之间的间距14像素 */
  199. align-items: center; /* 垂直居中对齐 */
  200. }
  201.  
  202. /* 标签页导航 - 定义标签页导航栏样式 */
  203. .tab-nav {
  204. display: flex; /* 使用弹性布局 */
  205. justify-content: center; /* 水平居中对齐 */
  206. border-bottom: 2px solid var(--border-color); /* 底部2像素实线边框,使用统一边框颜色 */
  207. margin-bottom: 18px; /* 底部外边距18像素 */
  208. }
  209. .tab-nav button {
  210. background: none; /* 移除背景色 */
  211. border: none; /* 移除边框 */
  212. padding: 12px 24px; /* 内边距:上下12px,左右24px */
  213. cursor: pointer; /* 鼠标悬停时显示手指图标 */
  214. font-size: 14px; /* 文字大小14像素 */
  215. font-weight: 600; /* 字体粗细程度,半粗体 */
  216. color: var(--text-secondary); /* 使用次要文本颜色 */
  217. border-bottom: 3px solid transparent; /* 底部3像素透明边框,为激活状态做准备 */
  218. transition: var(--transition-normal); /* 应用统一的过渡动画 */
  219. }
  220. .tab-nav button.active {
  221. border-bottom: 3px solid var(--new-color); /* 当前活动标签底部边框使用主要颜色 */
  222. color: var(--new-color); /* 当前活动标签文字颜色使用主要颜色 */
  223. }
  224. .tab-nav button:hover {
  225. color: var(--primary-color); /* 鼠标悬停时文字颜色使用主要颜色 */
  226. border-bottom: 3px solid var(--primary-color); /* 底部2像素实线边框,使用统一边框颜色 */
  227. }
  228.  
  229. /* 标签页内容 - 定义标签页内容区域样式 */
  230. .tab-panel {
  231. display: none; /* 默认隐藏所有标签页内容 */
  232. }
  233. .tab-panel.active {
  234. display: block; /* 显示当前活动的标签页内容 */
  235. animation: var(--transition-normal); /* 应用统一的过渡动画 */
  236. }
  237. @keyframes fadeIn {
  238. from { opacity: 0; transform: translateY(8px); } /* 动画起始状态:透明度0,向下偏移8像素 */
  239. to { opacity: 1; transform: translateY(0); } /* 动画结束状态:透明度1,无偏移 */
  240. }
  241.  
  242. /* 封面上传功能 - 定义封面上传相关元素样式 */
  243. #coverUploadForm {
  244. display: flex; /* 使用弹性布局 */
  245. flex-direction: column; /* 垂直排列子元素 */
  246. align-items: center; /* 水平居中对齐 */
  247. }
  248. #coverUploadForm input[type="file"] {
  249. border: 2px dashed var(--border-color); /* 2像素虚线边框,使用统一边框颜色 */
  250. border-radius: var(--border-radius); /* 使用统一的圆角大小 */
  251. padding: 10px; /* 四周内边距10像素 */
  252. width: 100%; /* 宽度占满父容器 */
  253. background: var(--background-light); /* 使用浅色背景 */
  254. box-sizing: border-box; /* 确保padding不会增加实际宽度 */
  255. cursor: pointer; /* 鼠标悬停时显示手指图标 */
  256. transition: var(--transition-normal); /* 应用统一的过渡动画 */
  257. }
  258. #coverUploadForm input[type="file"]:hover {
  259. border-color: var(--primary-color); /* 鼠标悬停时边框颜色变为主要颜色 */
  260. background: var(--primary-light); /* 鼠标悬停时背景色变为主要颜色的浅色版本 */
  261. }
  262. #coverUploadForm input[type="submit"] {
  263. background: var(--primary-color); /* 背景色使用主要颜色 */
  264. color: white; /* 文字颜色为白色 */
  265. border: none; /* 移除边框 */
  266. border-radius: 20px; /* 边框圆角20像素,使按钮更圆润 */
  267. padding: 8px 16px; /* 内边距:上下8px,左右16px */
  268. margin-top: 8px; /* 顶部外边距8像素 */
  269. cursor: pointer; /* 鼠标悬停时显示手指图标 */
  270. font-weight: 600; /* 字体粗细程度,半粗体 */
  271. transition: var(--transition-normal); /* 应用统一的过渡动画 */
  272. box-shadow: var(--shadow-soft); /* 应用柔和阴影效果 */
  273. position: relative; /* 开启相对定位 */
  274. top: -15px; /* 向上移动15px */
  275. }
  276. #coverUploadForm input[type="submit"]:hover {
  277. background: var(--primary-hover); /* 鼠标悬停时背景色变深 */
  278. transform: translateY(-1px); /* 鼠标悬停时按钮上移1像素,产生悬浮效果 */
  279. box-shadow: var(--shadow-hover); /* 鼠标悬停时阴影效果更明显 */
  280. }
  281.  
  282. /* 封面上传模态框的整体容器样式 */
  283. .cover-upload-modal {
  284. display: none; /* 默认隐藏模态框 */
  285. position: fixed; /* 固定定位,相对于视窗 */
  286. z-index: 1000; /* 设置高层级,确保在其他元素上方 */
  287. background-color: white; /* 使用白色背景 */
  288. border: 1px solid var(--border-color); /* 使用全局定义的边框颜色 */
  289. border-radius: var(--border-radius); /* 使用全局定义的圆角大小 */
  290. padding: 15px; /* 内部内容的内边距 */
  291. box-shadow: var(--shadow-soft); /* 使用全局定义的柔和阴影 */
  292. width: 240px; /* 固定宽度 */
  293. max-width: 100%; /* 最大宽度不超过父容器 */
  294. transition: var(--transition-normal); /* 使用全局定义的过渡动画 */
  295. }
  296.  
  297. /* 鼠标悬停在模态框上时的阴影效果 */
  298. .cover-upload-modal:hover {
  299. box-shadow: var(--shadow-hover); /* 使用全局定义的悬停阴影效果 */
  300. }
  301.  
  302. /* 上传区域的整体布局 */
  303. .upload-section {
  304. display: flex; /* 使用弹性布局 */
  305. flex-direction: column; /* 垂直方向排列子元素 */
  306. }
  307.  
  308. /* URL输入框和按钮的容器 */
  309. .url-input-container {
  310. display: flex; /* 使用弹性布局 */
  311. margin-bottom: 10px; /* 底部外边距,与下一个元素间隔 */
  312. height: 30px; /* 固定高度 */
  313. }
  314.  
  315. /* URL输入框样式 */
  316. .image-url-input {
  317. flex-grow: 1; /* 允许输入框自动填充剩余空间 */
  318. padding: 8px; /* 内部内边距 */
  319. margin-right: 10px; /* 右侧外边距,与按钮间隔 */
  320. border: 1px solid var(--border-color); /* 使用全局定义的边框颜色 */
  321. border-radius: var(--border-radius); /* 使用全局定义的圆角大小 */
  322. outline: none; /* 移除默认聚焦轮廓 */
  323. transition: var(--transition-normal); /* 使用全局定义的过渡动画 */
  324. font-size: 14px; /* 文字大小 */
  325. }
  326.  
  327. /* URL输入框获得焦点时的样式 */
  328. .image-url-input:focus {
  329. border-color: var(--primary-color); /* 边框颜色变为主要颜色 */
  330. box-shadow: 0 0 0 3px var(--primary-light); /* 添加主要颜色的浅色阴影 */
  331. }
  332.  
  333. /* 下载按钮样式 */
  334. .download-url-button {
  335. padding: 8px 16px; /* 内部内边距 */
  336. background-color: var(--new-color); /* 使用全局定义的新增粉色 */
  337. color: white; /* 白色文字 */
  338. border: none; /* 移除边框 */
  339. border-radius: var(--border-radius); /* 使用全局定义的圆角大小 */
  340. cursor: pointer; /* 鼠标悬停显示手型 */
  341. font-weight: 600; /* 半粗体文字 */
  342. transition: var(--transition-normal); /* 使用全局定义的过渡动画 */
  343. box-shadow: var(--shadow-soft); /* 使用全局定义的柔和阴影 */
  344. display: flex; /* 让按钮使用弹性布局 */
  345. align-items: center; /* 让文本垂直居中 */
  346. justify-content: center; /* 让文本水平居中 */
  347. height: 100%; /* 确保按钮内部填充满 */
  348. }
  349.  
  350. /* 下载按钮鼠标悬停时的样式 */
  351. .download-url-button:hover {
  352. background-color: color-mix(in srgb, var(--new-color) 90%, black); /* 颜色略微变暗 */
  353. transform: translateY(-1px); /* 稍微上移,产生悬浮效果 */
  354. box-shadow: var(--shadow-hover); /* 使用全局定义的悬停阴影 */
  355. }
  356.  
  357. /* 上传表单容器 */
  358. .upload-form-container {
  359. margin-top: 10px; /* 顶部外边距 */
  360. }
  361.  
  362. /* 图片预览容器 */
  363. .image-preview-container {
  364. margin-top: 10px; /* 顶部外边距 */
  365. display: none; /* 默认隐藏 */
  366. text-align: center; /* 内容居中 */
  367. }
  368.  
  369. /* 图片预览样式 */
  370. .image-preview {
  371. max-width: 100%; /* 最大宽度不超过容器 */
  372. max-height: 300px; /* 最大高度限制 */
  373. object-fit: contain; /* 保持图片比例 */
  374. border: 1px solid var(--border-color); /* 使用全局定义的边框颜色 */
  375. border-radius: var(--border-radius); /* 使用全局定义的圆角大小 */
  376. }
  377. </style>
  378. `);
  379. }
  380.  
  381. /* ====================
  382. Wiki 按钮和关联按钮模块
  383. ======================*/
  384. function initNavButtons() {
  385. // 排除特定编辑页面
  386. const EXCLUDE_PATHS = /(edit_detail|edit|add_related|upload_img)/;
  387. if (EXCLUDE_PATHS.test(location.pathname)) return;
  388.  
  389. // 获取导航栏
  390. const nav = document.querySelector(".subjectNav .navTabs, .navTabs");
  391. if (!nav) return;
  392.  
  393. // 解析页面类型和ID
  394. const pathMatch = location.pathname.match(/\/(subject|person|character)\/(\d+)/);
  395. if (!pathMatch) return;
  396.  
  397. const [, pageType, pageId] = pathMatch;
  398. const origin = location.origin;
  399.  
  400. // 按钮配置
  401. const buttons = [
  402. {
  403. className: "wiki-button",
  404. getText: () => "Wiki",
  405. getUrl: () => pageType === "subject"
  406. ? `${origin}/${pageType}/${pageId}/edit_detail`
  407. : `${origin}/${pageType}/${pageId}/edit`
  408. },
  409. {
  410. className: "relate-button",
  411. getText: () => "关联",
  412. getUrl: () => pageType === "subject"
  413. ? `${origin}/${pageType}/${pageId}/add_related/subject/anime`
  414. : `${origin}/${pageType}/${pageId}/add_related/anime`
  415. }
  416. ];
  417.  
  418. // 添加按钮
  419. buttons.forEach(button => {
  420. if (!nav.querySelector(`.${button.className}`)) {
  421. const li = document.createElement("li");
  422. li.className = button.className;
  423. li.innerHTML = `<a href="${button.getUrl()}" target="_blank">${button.getText()}</a>`;
  424. nav.appendChild(li);
  425. }
  426. });
  427. }
  428.  
  429. // 监听 URL 变化
  430. function observeURLChanges() {
  431. let lastURL = location.href;
  432. new MutationObserver(() => {
  433. if (location.href !== lastURL) {
  434. lastURL = location.href;
  435. initNavButtons();
  436. }
  437. }).observe(document, { subtree: true, childList: true });
  438. }
  439.  
  440. /* ===========================
  441. 封面上传模块 (自动提交和投票)
  442. ============================= */
  443. async function initCoverUpload() {
  444. // 支持的域名和需要排除的路径
  445. const SUPPORTED_DOMAINS = ['bangumi\\.tv', 'bgm\\.tv', 'chii\\.in'];
  446. const EXCLUDE_PATHS = /\/(edit_detail|edit|add_related|upload_img)$/;
  447.  
  448. // 如果是编辑页面,则不执行
  449. if (EXCLUDE_PATHS.test(location.pathname)) return;
  450.  
  451. // 解析页面类型和 ID
  452. const url = window.location.href;
  453. const parseId = (path) => {
  454. const regex = new RegExp(`(${SUPPORTED_DOMAINS.join('|')})\\/${path}\\/(\\d+)`);
  455. const match = url.match(regex);
  456. return match ? { id: match[2], type: path } : null;
  457. };
  458.  
  459. const typeMapping = ['subject', 'person', 'character'];
  460. const parsedInfo = typeMapping.reduce((result, type) => result || parseId(type), null);
  461. if (!parsedInfo) return;
  462.  
  463. // 避免重复添加按钮
  464. if (document.querySelector("#coverUploadButton")) return;
  465.  
  466. // 获取导航栏(兼容多个模板)
  467. const nav = document.querySelector(".subjectNav .navTabs") || document.querySelector(".navTabs");
  468. if (!nav) return;
  469.  
  470. // 创建上传按钮(保持原有UI设计)
  471. const createUploadButton = () => {
  472. const uploadLi = document.createElement("li");
  473. uploadLi.id = "coverUploadButton";
  474. uploadLi.className = "upload-button";
  475. uploadLi.style.float = "right";
  476. uploadLi.innerHTML = `<a href="javascript:void(0);" style="padding: 10px 10px 9px;">上传封面</a>`;
  477. return uploadLi;
  478. };
  479.  
  480. // 创建表单容器,移除格式选择部分
  481. const createFormContainer = () => {
  482. const formContainer = document.createElement("div");
  483. formContainer.id = "coverUploadFormContainer";
  484. formContainer.classList.add("cover-upload-modal");
  485. // 移除格式选择下拉框
  486. formContainer.innerHTML = `
  487. <div class="upload-section">
  488. <div class="url-input-container">
  489. <input type="text" id="imageUrlInput" class="image-url-input" placeholder="输入图片 URL">
  490. <button id="downloadUrlButton" class="download-url-button">下载</button>
  491. </div>
  492. <div id="uploadFormContainer" class="upload-form-container"></div>
  493. <div id="imagePreviewContainer" class="image-preview-container">
  494. <img id="imagePreview"class="image-preview" alt="图片预览">
  495. </div>
  496. <div id="statusMessage" class="status-message" style="display:none; margin-top:10px; padding:5px; text-align:center;"></div>
  497. </div>
  498. `;
  499. // 设置必要的定位和初始隐藏
  500. formContainer.style.position = "absolute";
  501. formContainer.style.zIndex = "9999";
  502. formContainer.style.display = "none";
  503. return formContainer;
  504. };
  505.  
  506. const uploadLi = createUploadButton();
  507. const formContainer = createFormContainer();
  508. nav.appendChild(uploadLi);
  509. document.body.appendChild(formContainer);
  510.  
  511. let formLoaded = false; // 标记本地上传表单是否加载完毕
  512. let hideTimeout = null;
  513. let isFormPinned = false; // 新增:标记表单是否被固定(点击按钮后)
  514.  
  515. // 显示状态消息
  516. function showStatus(message, isError = false) {
  517. const statusDiv = document.getElementById('statusMessage');
  518. statusDiv.textContent = message;
  519. statusDiv.style.display = 'block';
  520. statusDiv.style.backgroundColor = isError ? '#ffeeee' : '#eeffee';
  521. statusDiv.style.color = isError ? '#cc0000' : '#007700';
  522. statusDiv.style.border = `1px solid ${isError ? '#cc0000' : '#007700'}`;
  523. console.log(`[状态] ${message}`);
  524. }
  525.  
  526. // 创建一个隐藏的iframe用于POST请求
  527. function createHiddenIframe() {
  528. const existingIframe = document.getElementById('hiddenUploadFrame');
  529. if (existingIframe) {
  530. return existingIframe;
  531. }
  532.  
  533. const iframe = document.createElement('iframe');
  534. iframe.id = 'hiddenUploadFrame';
  535. iframe.name = 'hiddenUploadFrame';
  536. iframe.style.display = 'none';
  537. document.body.appendChild(iframe);
  538. return iframe;
  539. }
  540.  
  541. // 从上传结果页面中提取投票链接并自动投票
  542. function processUploadResult(iframe) {
  543. try {
  544. // 等待iframe加载完成
  545. return new Promise((resolve, reject) => {
  546. iframe.onload = function() {
  547. try {
  548. // 获取iframe中的文档内容
  549. const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
  550. console.log("Iframe加载完成,检查投票链接...");
  551.  
  552. // 获取所有投票链接
  553. const allVoteLinks = iframeDocument.querySelectorAll('a[href*="/vote/cover/"]');
  554. console.log(`找到 ${allVoteLinks.length} 个投票链接`);
  555.  
  556. // 选择最后一个投票链接
  557. const voteLink = allVoteLinks.length > 0 ? allVoteLinks[allVoteLinks.length - 1] : null;
  558.  
  559. if (voteLink) {
  560. const href = voteLink.getAttribute('href');
  561. console.log("选择最后一个投票链接:", href);
  562.  
  563. // 构建完整的投票URL
  564. const host = window.location.host;
  565. const voteUrl = href.startsWith('http') ? href : `https://${host}${href.startsWith('/') ? '' : '/'}${href}`;
  566. console.log("完整投票URL:", voteUrl);
  567.  
  568. // 使用fetch API进行投票
  569. showStatus('封面上传成功,正在投票...');
  570.  
  571. fetch(voteUrl, {
  572. method: 'GET',
  573. credentials: 'include', // 重要:包含cookies
  574. headers: {
  575. 'Accept': 'text/html,application/xhtml+xml,application/xml',
  576. 'Referer': window.location.href
  577. }
  578. })
  579. .then(response => {
  580. if (response.ok) {
  581. console.log("投票成功,状态:", response.status);
  582. showStatus('投票成功!页面将在3秒后刷新...');
  583. setTimeout(() => window.location.reload(), 3000);
  584. resolve(true);
  585. } else {
  586. console.error("投票失败,状态:", response.status);
  587. showStatus('封面上传成功,但投票失败。3秒后跳转到手动投票页面...', true);
  588. setTimeout(() => {
  589. window.location.href = `${window.location.href.split('?')[0]}/upload_img`;
  590. }, 3000);
  591. reject(new Error(`投票请求失败,状态 ${response.status}`));
  592. }
  593. })
  594. .catch(error => {
  595. console.error("投票请求错误:", error);
  596. showStatus('封面上传成功,但投票失败。3秒后跳转到手动投票页面...', true);
  597. setTimeout(() => {
  598. window.location.href = `${window.location.href.split('?')[0]}/upload_img`;
  599. }, 3000);
  600. reject(error);
  601. });
  602. } else {
  603. // 检查是否有错误信息
  604. console.log("未找到投票链接,检查是否有错误消息");
  605. const errorMsg = iframeDocument.querySelector('.error, .errorMessage, [class*="error"]');
  606. if (errorMsg) {
  607. console.error("上传错误:", errorMsg.textContent);
  608. showStatus(`上传失败: ${errorMsg.textContent},3秒后跳转到手动上传页面...`, true);
  609. setTimeout(() => {
  610. window.location.href = `${window.location.href.split('?')[0]}/upload_img`;
  611. }, 3000);
  612. reject(new Error(errorMsg.textContent));
  613. } else {
  614. console.log("未找到投票链接或错误消息");
  615. showStatus('封面似乎已上传成功,但未找到投票链接。3秒后跳转到手动处理页面...', true);
  616. setTimeout(() => {
  617. window.location.href = `${window.location.href.split('?')[0]}/upload_img`;
  618. }, 3000);
  619. reject(new Error('未找到投票链接'));
  620. }
  621. }
  622. } catch (error) {
  623. console.error('处理上传结果时出错:', error);
  624. showStatus('处理上传结果时出错,3秒后跳转到手动上传页面...', true);
  625. setTimeout(() => {
  626. window.location.href = `${window.location.href.split('?')[0]}/upload_img`;
  627. }, 3000);
  628. reject(error);
  629. }
  630. };
  631.  
  632. // 处理iframe加载错误
  633. iframe.onerror = function(error) {
  634. console.error("Iframe加载错误:", error);
  635. showStatus('上传请求失败,3秒后跳转到手动上传页面...', true);
  636. setTimeout(() => {
  637. window.location.href = `${window.location.href.split('?')[0]}/upload_img`;
  638. }, 3000);
  639. reject(new Error('上传请求失败'));
  640. };
  641. });
  642. } catch (error) {
  643. console.error('处理上传结果时出错:', error);
  644. showStatus(`处理出错: ${error.message},3秒后跳转到手动上传页面...`, true);
  645. setTimeout(() => {
  646. window.location.href = `${window.location.href.split('?')[0]}/upload_img`;
  647. }, 3000);
  648. return Promise.reject(error);
  649. }
  650. }
  651.  
  652. // 修改表单提交处理函数,使用iframe提交
  653. function setupFormForIframeSubmission(form) {
  654. // 创建隐藏的iframe
  655. const iframe = createHiddenIframe();
  656.  
  657. // 设置表单的target为iframe
  658. form.target = 'hiddenUploadFrame';
  659.  
  660. // 监听表单提交事件
  661. form.addEventListener('submit', function(e) {
  662. // 不阻止默认提交行为,而是让它提交到iframe
  663. showStatus('正在上传封面...');
  664.  
  665. // 处理iframe的响应
  666. processUploadResult(iframe).catch(error => {
  667. console.error('处理上传结果失败:', error);
  668. });
  669. });
  670. }
  671.  
  672. // 优化的图片转换函数 - 自动选择最佳格式
  673. async function convertImageFormat(file) {
  674. return new Promise((resolve, reject) => {
  675. try {
  676. // 读取文件
  677. const reader = new FileReader();
  678. reader.onload = (e) => {
  679. const img = new Image();
  680. img.onload = () => {
  681. // 检查图片类型并自动决定最佳格式
  682. const fileType = file.type.toLowerCase();
  683. let hasTransparency = false;
  684. let finalFormat;
  685.  
  686. // 创建画布检测透明度
  687. const canvas = document.createElement('canvas');
  688. canvas.width = img.width;
  689. canvas.height = img.height;
  690. const ctx = canvas.getContext('2d');
  691. ctx.drawImage(img, 0, 0);
  692.  
  693. // 检查图片是否包含透明像素
  694. try {
  695. const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  696. const pixels = imageData.data;
  697.  
  698. // 检查透明度通道
  699. for (let i = 3; i < pixels.length; i += 4) {
  700. if (pixels[i] < 255) {
  701. hasTransparency = true;
  702. break;
  703. }
  704. }
  705. } catch (e) {
  706. console.warn("无法检查透明度,默认使用原格式", e);
  707. }
  708.  
  709. // 决定最终格式
  710. if (hasTransparency) {
  711. // 有透明度,使用PNG
  712. finalFormat = 'image/png';
  713. console.log("检测到透明像素,使用PNG格式");
  714. } else if (fileType.includes('png') && !hasTransparency) {
  715. // 无透明度的PNG图片转为JPEG以减小体积
  716. finalFormat = 'image/jpeg';
  717. console.log("PNG图片无透明像素,转换为JPEG格式以优化体积");
  718. } else {
  719. // 保持原格式
  720. finalFormat = fileType.includes('jpeg') || fileType.includes('jpg')
  721. ? 'image/jpeg'
  722. : fileType.includes('png')
  723. ? 'image/png'
  724. : 'image/jpeg'; // 默认JPEG
  725. console.log(`保持原始格式: ${finalFormat}`);
  726. }
  727.  
  728. // 重新创建画布以适应格式
  729. canvas.width = img.width;
  730. canvas.height = img.height;
  731. const newCtx = canvas.getContext('2d');
  732.  
  733. // 如果是JPEG,先填充白色背景
  734. if (finalFormat === 'image/jpeg') {
  735. newCtx.fillStyle = '#FFFFFF';
  736. newCtx.fillRect(0, 0, canvas.width, canvas.height);
  737. }
  738.  
  739. // 绘制图片
  740. newCtx.drawImage(img, 0, 0);
  741.  
  742. // 转换为指定格式
  743. const quality = finalFormat === 'image/jpeg' ? 0.92 : undefined; // JPEG质量设置
  744. canvas.toBlob((blob) => {
  745. if (!blob) {
  746. reject(new Error('转换图片失败'));
  747. return;
  748. }
  749.  
  750. // 确定文件扩展名
  751. const ext = finalFormat === 'image/png' ? 'png' : 'jpg';
  752. const newFileName = file.name.split('.')[0] + '.' + ext;
  753.  
  754. // 创建新文件对象
  755. const convertedFile = new File([blob], newFileName, { type: finalFormat });
  756. console.log(`图片已转换为 ${finalFormat}`);
  757.  
  758. resolve({
  759. file: convertedFile,
  760. dataURL: canvas.toDataURL(finalFormat, quality),
  761. format: finalFormat.split('/')[1]
  762. });
  763. }, finalFormat, quality);
  764. };
  765.  
  766. img.onerror = () => {
  767. reject(new Error('加载图片失败'));
  768. };
  769.  
  770. img.src = e.target.result;
  771. };
  772.  
  773. reader.onerror = () => {
  774. reject(new Error('读取文件失败'));
  775. };
  776.  
  777. reader.readAsDataURL(file);
  778. } catch (error) {
  779. reject(error);
  780. }
  781. });
  782. }
  783.  
  784. // 图片下载和转换函数 - 更新为自动转换格式
  785. async function downloadAndConvertImage(imageUrl) {
  786. try {
  787. // 尝试提取实际的图片 URL
  788. let actualImageUrl = imageUrl;
  789.  
  790. // 检查是否是 Google 图片重定向链接
  791. if (imageUrl.includes('google.com/imgres')) {
  792. const urlParams = new URL(imageUrl).searchParams;
  793. actualImageUrl = urlParams.get('imgurl');
  794. }
  795.  
  796. // 如果没有提取到图片 URL,则使用原始链接
  797. if (!actualImageUrl) {
  798. actualImageUrl = imageUrl;
  799. }
  800.  
  801. // 显示正在下载的状态
  802. showStatus('正在下载图片...');
  803.  
  804. // 发送请求下载图片
  805. const response = await fetch(actualImageUrl);
  806. const blob = await response.blob();
  807.  
  808. // 创建临时文件对象
  809. const tempFileName = actualImageUrl.split('/').pop() || 'image';
  810. const tempFile = new File([blob], tempFileName, { type: blob.type });
  811.  
  812. // 使用自动转换函数
  813. showStatus('正在优化图片格式...');
  814. const convertedData = await convertImageFormat(tempFile);
  815.  
  816. // 预览转换后的图片
  817. const previewContainer = document.querySelector("#imagePreviewContainer");
  818. const previewImage = document.querySelector("#imagePreview");
  819. previewImage.src = convertedData.dataURL;
  820. previewContainer.style.display = "block";
  821.  
  822. // 查找文件上传表单
  823. const fileInput = document.querySelector("#coverUploadForm input[type='file']");
  824. if (fileInput) {
  825. // 创建 DataTransfer 对象并填充文件
  826. const dataTransfer = new DataTransfer();
  827. dataTransfer.items.add(convertedData.file);
  828. fileInput.files = dataTransfer.files;
  829.  
  830. // 触发文件上传输入框的 change 事件
  831. const event = new Event('change', { bubbles: true });
  832. fileInput.dispatchEvent(event);
  833.  
  834. // 显示提交按钮
  835. const submitButton = document.querySelector("#coverUploadForm input[type='submit']");
  836. if (submitButton) {
  837. submitButton.style.display = 'block';
  838. }
  839.  
  840. showStatus(`图片已优化为 ${convertedData.format.toUpperCase()} 格式,点击提交按钮上传`);
  841. } else {
  842. showStatus('未找到文件上传输入框', true);
  843. }
  844. } catch (error) {
  845. console.error('下载或转换图片时发生错误:', error);
  846. showStatus(`下载图片失败:${error.message}`, true);
  847. }
  848. }
  849.  
  850. // 全局点击事件,点击表单容器外区域关闭表单
  851. function setupGlobalClickHandler(container, trigger) {
  852. document.addEventListener('click', function (event) {
  853. if (!container.contains(event.target) && !trigger.contains(event.target)) {
  854. container.style.display = "none";
  855. isFormPinned = false; // 复位固定状态
  856. }
  857. });
  858. }
  859.  
  860. // 预先加载本地上传表单,提升加载速度
  861. async function preloadLocalUpload() {
  862. if (formLoaded) return;
  863. const uploadFormContainer = formContainer.querySelector("#uploadFormContainer");
  864. uploadFormContainer.innerHTML = "加载中...";
  865. try {
  866. const uploadUrl = `https://${window.location.host}/${parsedInfo.type}/${parsedInfo.id}/upload_img`;
  867. const res = await fetch(uploadUrl);
  868. const doc = new DOMParser().parseFromString(await res.text(), "text/html");
  869. const form = doc.querySelector("form[enctype='multipart/form-data']");
  870. if (form) {
  871. form.id = "coverUploadForm";
  872. form.style.margin = "0";
  873. form.style.padding = "0";
  874.  
  875. // 配置表单使用iframe提交
  876. uploadFormContainer.innerHTML = form.outerHTML;
  877.  
  878. // 获取插入到DOM中的表单元素并设置
  879. const insertedForm = document.getElementById("coverUploadForm");
  880. setupFormForIframeSubmission(insertedForm);
  881.  
  882. // 为本地文件上传绑定预览处理事件
  883. const fileInput = document.querySelector("#coverUploadForm input[type='file']");
  884. fileInput.addEventListener('change', async (e) => {
  885. const file = e.target.files[0];
  886. if (file) {
  887. try {
  888. // 显示正在处理的状态
  889. showStatus('正在处理图片...');
  890.  
  891. // 使用自动转换函数处理图片
  892. const convertedData = await convertImageFormat(file);
  893.  
  894. // 更新文件输入框的文件
  895. const dataTransfer = new DataTransfer();
  896. dataTransfer.items.add(convertedData.file);
  897. fileInput.files = dataTransfer.files;
  898.  
  899. // 更新预览
  900. const previewContainer = formContainer.querySelector("#imagePreviewContainer");
  901. const previewImage = formContainer.querySelector("#imagePreview");
  902. previewImage.src = convertedData.dataURL;
  903. previewContainer.style.display = "block";
  904.  
  905. // 显示提交按钮
  906. const submitButton = document.querySelector("#coverUploadForm input[type='submit']");
  907. if (submitButton) {
  908. submitButton.style.display = 'block';
  909. }
  910.  
  911. showStatus(`图片已优化为 ${convertedData.format.toUpperCase()} 格式,点击提交按钮上传`);
  912. } catch (error) {
  913. console.error('处理本地图片失败:', error);
  914. showStatus(`处理图片失败: ${error.message}`, true);
  915.  
  916. // 如果转换失败,回退到原始文件的简单预览
  917. const reader = new FileReader();
  918. reader.onload = (ev) => {
  919. const previewContainer = formContainer.querySelector("#imagePreviewContainer");
  920. const previewImage = formContainer.querySelector("#imagePreview");
  921. previewImage.src = ev.target.result;
  922. previewContainer.style.display = "block";
  923. showStatus('使用原始格式,点击提交按钮上传');
  924. };
  925. reader.readAsDataURL(file);
  926. }
  927. }
  928. });
  929. formLoaded = true;
  930. } else {
  931. uploadFormContainer.innerHTML = "无法加载上传表单";
  932. showStatus("无法加载上传表单", true);
  933. }
  934. } catch (e) {
  935. uploadFormContainer.innerHTML = "加载失败";
  936. showStatus("加载上传表单失败", true);
  937. console.error("上传模块加载失败:", e);
  938. }
  939. }
  940.  
  941. // 事件绑定逻辑,保持原有UI展示行为但增加点击固定功能
  942. const setupEventHandlers = () => {
  943. const urlInput = formContainer.querySelector("#imageUrlInput");
  944. const downloadButton = formContainer.querySelector("#downloadUrlButton");
  945.  
  946. // 显示表单函数
  947. const showForm = () => {
  948. clearTimeout(hideTimeout);
  949. const buttonRect = uploadLi.getBoundingClientRect();
  950. formContainer.style.top = `${buttonRect.bottom + window.scrollY + 5}px`;
  951. formContainer.style.left = `${buttonRect.left + window.scrollX - 180}px`;
  952. formContainer.style.display = "block";
  953. };
  954.  
  955. // 延迟隐藏表单逻辑(在有预览时不自动隐藏)
  956. const hideForm = () => {
  957. // 如果表单已被固定,不自动隐藏
  958. if (isFormPinned) return;
  959.  
  960. const previewContainer = formContainer.querySelector("#imagePreviewContainer");
  961. const statusMessage = formContainer.querySelector("#statusMessage");
  962. if (previewContainer.style.display === "block" || statusMessage.style.display === "block") return;
  963. hideTimeout = setTimeout(() => {
  964. if (!formContainer.matches(":hover") && !isFormPinned) {
  965. formContainer.style.display = "none";
  966. }
  967. }, 200);
  968. };
  969.  
  970. // 固定表单(点击按钮时)
  971. uploadLi.addEventListener("click", () => {
  972. showForm();
  973. isFormPinned = true; // 固定表单
  974. console.log("表单已固定,鼠标移出不会自动关闭");
  975. });
  976.  
  977. // 鼠标悬停显示表单(暂时保留,提供更好的用户体验)
  978. uploadLi.addEventListener("mouseenter", showForm);
  979.  
  980. // 鼠标移出事件(仅当表单未固定时才尝试隐藏)
  981. uploadLi.addEventListener("mouseleave", () => {
  982. if (!isFormPinned) {
  983. hideForm();
  984. }
  985. });
  986.  
  987. formContainer.addEventListener("mouseenter", () => clearTimeout(hideTimeout));
  988.  
  989. // 表单鼠标移出事件(仅当表单未固定时才尝试隐藏)
  990. formContainer.addEventListener("mouseleave", () => {
  991. if (!isFormPinned) {
  992. hideForm();
  993. }
  994. });
  995.  
  996. // URL上传部分事件处理
  997. urlInput.addEventListener('focus', () => {
  998. urlInput.style.borderColor = '#F4C7CC';
  999. urlInput.style.boxShadow = '0 0 5px rgba(244, 199, 204, 0.5)';
  1000. });
  1001. urlInput.addEventListener('blur', () => {
  1002. urlInput.style.borderColor = '#ddd';
  1003. urlInput.style.boxShadow = 'none';
  1004. });
  1005. downloadButton.addEventListener('click', () => {
  1006. const imageUrl = urlInput.value.trim();
  1007. if (imageUrl) {
  1008. downloadAndConvertImage(imageUrl);
  1009. } else {
  1010. showStatus('请输入图片 URL', true);
  1011. }
  1012. });
  1013. urlInput.addEventListener('keypress', (e) => {
  1014. if (e.key === 'Enter') downloadButton.click();
  1015. });
  1016. };
  1017.  
  1018. // 注册(不可用)全局点击事件(点击容器外关闭表单)
  1019. setupGlobalClickHandler(formContainer, uploadLi);
  1020. // 预先加载本地上传表单(在页面初始加载时即启动)
  1021. preloadLocalUpload();
  1022. // 事件处理绑定
  1023. setupEventHandlers();
  1024. }
  1025.  
  1026. /* MutationObserver 保证上传按钮始终存在 */
  1027. const observer = new MutationObserver(() => {
  1028. if (!document.querySelector("#coverUploadButton")) {
  1029. initCoverUpload();
  1030. }
  1031. });
  1032. observer.observe(document.body, { childList: true, subtree: true });
  1033.  
  1034. /* ===============
  1035. 批量添加章节模板
  1036. =================*/
  1037. const BatchEpisodeCreator = {
  1038. // 初始化入口
  1039. init() {
  1040. // 检查是否为添加章节页面
  1041. if (!this.isEpisodeCreatePage()) return;
  1042.  
  1043. // 设置元素监听,等待页面 DOM 变化后执行
  1044. this.setupElementObserver();
  1045.  
  1046. // 检查目标元素
  1047. this.checkTargetElement();
  1048. },
  1049.  
  1050. // 判断当前页面是否为添加章节页面
  1051. isEpisodeCreatePage() {
  1052. const pattern = /\/subject\/\d+\/ep\/create/;
  1053. return pattern.test(window.location.href);
  1054. },
  1055.  
  1056. // 设置监听器,观察页面 DOM 变化
  1057. setupElementObserver() {
  1058. // 创建一个 MutationObserver,监听 DOM 变化
  1059. this.observer = new MutationObserver(() => this.checkTargetElement());
  1060.  
  1061. // 监听整个文档的变化,特别是子节点变化
  1062. this.observer.observe(document.body, { childList: true, subtree: true });
  1063. },
  1064.  
  1065. // 检查目标元素是否存在
  1066. checkTargetElement() {
  1067. // 查找目标元素 div#batch,并检查其是否处于显示状态
  1068. const targetElement = document.querySelector('div#batch[style*="display: block"]');
  1069.  
  1070. // 如果目标元素存在,则添加批量创建界面
  1071. if (targetElement) {
  1072. this.addBatchCreationInterface();
  1073. } else {
  1074. // 如果目标元素不存在,移除已有的批量创建界面
  1075. const existing = document.querySelector('.batch-creator-area');
  1076. if (existing) existing.remove();
  1077. }
  1078. },
  1079.  
  1080. // 添加批量创建界面
  1081. addBatchCreationInterface() {
  1082. // 如果批量创建界面已经存在,则不再重复添加
  1083. if (document.querySelector('.batch-creator-area')) return;
  1084.  
  1085. // 查找目标元素 div#batch,并确认其处于显示状态
  1086. const targetElement = document.querySelector('div#batch[style*="display: block"]');
  1087. if (!targetElement) return;
  1088.  
  1089. // 创建批量添加区域的 HTML 内容
  1090. const batchArea = document.createElement('div');
  1091. batchArea.className = 'batch-creator-area Relation_wrapper';
  1092. batchArea.innerHTML = `
  1093. <h2 class="batch_header" style="margin-bottom: 20px;">批量添加章节</h2>
  1094. <div class="batch-controls">
  1095. <div class="flex-row" style="margin-bottom: 14px; gap: 12px; flex-wrap: wrap;">
  1096. <div class="flex-row" style="gap: 8px; align-items: center; width: 100%; justify-content: center;">
  1097. <span class="select-label">范围:</span>
  1098. <input type="number" id="batch-start" min="1" value="" class="input-number" style="width: 50px">
  1099. <span> - </span>
  1100. <input type="number" id="batch-end" min="1" value="" class="input-number" style="width: 50px">
  1101. </div>
  1102. <div class="flex-row" style="gap: 8px; align-items: center; width: 100%; justify-content: center; margin-top: 8px;">
  1103. <span class="select-label">结束值:</span>
  1104. <label style="display: inline-flex; align-items: center; margin-right: 10px;">
  1105. <input type="radio" name="end-value-mode" value="auto" checked>
  1106. <span style="margin-left: 5px">自动输入</span>
  1107. </label>
  1108. <label style="display: inline-flex; align-items: center;">
  1109. <input type="radio" name="end-value-mode" value="manual">
  1110. <span style="margin-left: 5px">手动输入</span>
  1111. </label>
  1112. </div>
  1113. <label class="flex-row" style="gap: 6px; align-items: center; width: 100%; margin-top: 8px;">
  1114. <input type="checkbox" id="empty-content">
  1115. <span class="text-secondary">空内容模式</span>
  1116. </label>
  1117. </div>
  1118. <button type="button" id="generate-episodes" class="btnCustom" style="display: block; margin: 0 auto 10px auto;">生成章节模板</button>
  1119. </div>
  1120. <div class="batch-preview">
  1121. <textarea id="batch-result" rows="5" class="enhancer-textarea" placeholder="生成的章节将显示在这里"></textarea>
  1122. <div class="batch_controls" style="margin-top: 8px; display: flex; justify-content: center;">
  1123. <button type="button" id="apply-episodes" class="btnCustom">应用到表单</button>
  1124. </div>
  1125. </div>
  1126. `;
  1127.  
  1128. // 设置批量创建区域的样式
  1129. batchArea.style.cssText = `
  1130. width: 200px;
  1131. margin-bottom: var(--margin-medium);
  1132. padding: 18px;
  1133. background: white;
  1134. `;
  1135.  
  1136. // 在指定位置插入批量创建区域
  1137. const subjectInnerInfo = document.getElementById('subject_inner_info');
  1138. if (subjectInnerInfo) {
  1139. subjectInnerInfo.insertAdjacentElement('afterend', batchArea);
  1140. batchArea.style.marginTop = 'var(--margin-medium)';
  1141. batchArea.style.marginBottom = 'var(--margin-medium)';
  1142. } else {
  1143. console.warn('未找到 subject_inner_info 元素,已附加到body末尾');
  1144. document.body.appendChild(batchArea);
  1145. }
  1146.  
  1147. // 绑定批量创建按钮事件
  1148. this.bindBatchEvents();
  1149.  
  1150. // 在输入框创建后绑定联动逻辑
  1151. this.setupEndValueMode();
  1152. },
  1153.  
  1154. // 绑定批量创建按钮事件
  1155. bindBatchEvents() {
  1156. const generateBtn = document.getElementById('generate-episodes');
  1157. const applyBtn = document.getElementById('apply-episodes');
  1158.  
  1159. // 绑定生成按钮事件
  1160. if (generateBtn) {
  1161. generateBtn.addEventListener('click', () => this.generateEpisodes());
  1162. } else {
  1163. console.error('生成按钮未找到');
  1164. }
  1165.  
  1166. // 绑定应用按钮事件
  1167. if (applyBtn) {
  1168. applyBtn.addEventListener('click', () => this.applyToForm());
  1169. } else {
  1170. console.error('应用按钮未找到');
  1171. }
  1172. },
  1173.  
  1174. // 生成章节模板
  1175. generateEpisodes() {
  1176. const start = parseInt(document.getElementById('batch-start').value) || 1;
  1177. const end = parseInt(document.getElementById('batch-end').value) || 20;
  1178. const isEmpty = document.getElementById('empty-content').checked;
  1179.  
  1180. // 校验起始数字是否大于结束数字
  1181. if (start > end) {
  1182. alert('起始数字不能大于结束数字');
  1183. return;
  1184. }
  1185.  
  1186. // 当生成章节数过多时进行确认
  1187. if (end - start >= 100) {
  1188. if (!confirm(`您将生成 ${end - start + 1} 个章节,确定继续吗?`)) return;
  1189. }
  1190.  
  1191. // 生成章节内容
  1192. let result = '';
  1193. for (let i = start; i <= end; i++) {
  1194. result += isEmpty
  1195. ? `${i}| | | |\n`
  1196. : `${i}| | | m| 0000-00-00\n`;
  1197. }
  1198.  
  1199. // 显示生成的章节内容
  1200. const resultArea = document.getElementById('batch-result');
  1201. if (resultArea) {
  1202. resultArea.value = result.trim();
  1203. } else {
  1204. console.error('结果区域未找到');
  1205. }
  1206. },
  1207.  
  1208. // 应用生成的章节内容到表单
  1209. applyToForm() {
  1210. const episodeText = document.getElementById('batch-result')?.value;
  1211. const epTextarea = document.querySelector('textarea[name="eplist"]') ||
  1212. document.querySelector('textarea#eplist') ||
  1213. document.querySelector('form textarea');
  1214.  
  1215. if (epTextarea && episodeText) {
  1216. epTextarea.value = epTextarea.value.trim()
  1217. ? epTextarea.value + '\n' + episodeText
  1218. : episodeText;
  1219. alert('章节模板已应用到表单');
  1220. } else {
  1221. console.error('未找到章节输入区域');
  1222. alert('未找到章节输入区域,请手动复制生成的内容');
  1223. }
  1224. },
  1225.  
  1226. // 设置结束值输入模式
  1227. setupEndValueMode() {
  1228. const startInput = document.getElementById('batch-start');
  1229. const endInput = document.getElementById('batch-end');
  1230. const radioButtons = document.querySelectorAll('input[name="end-value-mode"]');
  1231.  
  1232. if (startInput && endInput && radioButtons.length) {
  1233. // 获取当前选中的模式
  1234. const getCurrentMode = () => {
  1235. const checkedRadio = document.querySelector('input[name="end-value-mode"]:checked');
  1236. return checkedRadio ? checkedRadio.value : 'auto';
  1237. };
  1238.  
  1239. // 根据初始模式设置状态
  1240. this.updateEndInputState(getCurrentMode());
  1241.  
  1242. // 监听单选按钮变化
  1243. radioButtons.forEach(radio => {
  1244. radio.addEventListener('change', () => {
  1245. this.updateEndInputState(getCurrentMode());
  1246. });
  1247. });
  1248.  
  1249. // 监听 batch-start 输入框的变化
  1250. startInput.addEventListener('input', () => {
  1251. if (getCurrentMode() === 'auto') {
  1252. const val = parseInt(startInput.value);
  1253. if (!isNaN(val)) {
  1254. endInput.value = val + 19; // 将 batch-end 设置为 batch-start + 19
  1255. } else {
  1256. endInput.value = ''; // 如果输入无效,清空 batch-end
  1257. }
  1258. }
  1259. });
  1260.  
  1261. // 初始化时,如果 start 有值,自动设置 end
  1262. if (startInput.value) {
  1263. const val = parseInt(startInput.value);
  1264. if (!isNaN(val) && getCurrentMode() === 'auto') {
  1265. endInput.value = val + 19;
  1266. }
  1267. }
  1268. }
  1269. },
  1270.  
  1271. // 更新结束值输入框状态
  1272. updateEndInputState(mode) {
  1273. const startInput = document.getElementById('batch-start');
  1274. const endInput = document.getElementById('batch-end');
  1275.  
  1276. if (mode === 'auto') {
  1277. // 自动模式:更新结束值,并使其读取 start 值的变化
  1278. const val = parseInt(startInput.value);
  1279. if (!isNaN(val)) {
  1280. endInput.value = val + 19;
  1281. }
  1282. }
  1283. }
  1284. };
  1285.  
  1286. // 延迟初始化,防止 DOM 未加载完成
  1287. setTimeout(() => {
  1288. BatchEpisodeCreator.init();
  1289. }, 1000);
  1290.  
  1291. /* =============
  1292. 批量关联增强版
  1293. ===============*/
  1294. function initBatchRelation() {
  1295. injectStyles();
  1296.  
  1297. // 参数配置
  1298. const DELAY_AFTER_CLICK = 150;
  1299. const DELAY_BETWEEN_ITEMS = 300;
  1300. const MAX_RETRY_ATTEMPTS = 10;
  1301. const RETRY_INTERVAL = 100;
  1302.  
  1303. // 全局变量
  1304. let globalItemType = '1';
  1305. let currentProcessingIndex = -1;
  1306. // 添加全局设置变量
  1307. let enableExistingRelationCheck = false;
  1308.  
  1309. // 根据当前 URL 判断页面类型(支持排除特定路径)
  1310. function getCurrentPageType() {
  1311. const path = window.location.pathname;
  1312. // 调整正则表达式优先级,先检查更具体的路径
  1313. if (/^\/(?:subject\/\d+\/add_related\/character|character\/\d+\/add_related\/)/.test(path)) {
  1314. return 'character';
  1315. }
  1316. else if (/^\/subject\/\d+\/add_related\//.test(path)) {
  1317. return 'subject';
  1318. } else {
  1319. return 'person';
  1320. }
  1321. }
  1322.  
  1323. // 增强版下拉框生成
  1324. function generateTypeSelector() {
  1325. const pageType = getCurrentPageType();
  1326.  
  1327. // 公共选项生成逻辑
  1328. const generateOptions = (types) => {
  1329. return Object.entries(types)
  1330. .map(([value, text]) => `<option value="${value}">${text}</option>`)
  1331. .join('');
  1332. };
  1333. switch(pageType) {
  1334. case 'character':
  1335. return `<span class="select-label">类型: </span>
  1336. <select>${generateOptions({
  1337. '1': '主角',
  1338. '2': '配角',
  1339. '3': '客串'
  1340. })}</select>`;
  1341. default:
  1342. return `<span class="select-label"></span>${
  1343. typeof genPrsnStaffList === "function" ? genPrsnStaffList(-1) : ''
  1344. }`;
  1345. }
  1346. }
  1347.  
  1348. // 针对传入的元素内的下拉框进行设置,并通过递归确保修改成功
  1349. function setRelationTypeWithElement($li, item_type) {
  1350. return new Promise((resolve) => {
  1351. let attempts = 0;
  1352. function trySet() {
  1353. // 确保我们获取的是当前元素内部的select,而不是全局的
  1354. let $select = $li.find('select').first();
  1355.  
  1356. if ($select.length > 0) {
  1357. // 先确保下拉框可交互
  1358. if ($select.prop('disabled')) {
  1359. setTimeout(trySet, RETRY_INTERVAL);
  1360. return;
  1361. }
  1362.  
  1363. $select.val(item_type);
  1364. // 触发 change 事件
  1365. const event = new Event('change', { bubbles: true });
  1366. $select[0].dispatchEvent(event);
  1367.  
  1368. setTimeout(() => {
  1369. if ($select.val() == item_type) {
  1370. resolve(true);
  1371. } else if (attempts < MAX_RETRY_ATTEMPTS) {
  1372. attempts++;
  1373. setTimeout(trySet, RETRY_INTERVAL);
  1374. } else {
  1375. resolve(false);
  1376. }
  1377. }, 200);
  1378. } else if (attempts < MAX_RETRY_ATTEMPTS) {
  1379. attempts++;
  1380. setTimeout(trySet, RETRY_INTERVAL);
  1381. } else {
  1382. resolve(false);
  1383. }
  1384. }
  1385. trySet();
  1386. });
  1387. }
  1388.  
  1389. // 修改 checkAndHandleExistingRelation 函数
  1390. function checkAndHandleExistingRelation(search_name, item_id, item_type) {
  1391. return new Promise(async (resolve) => {
  1392. // 如果开关关闭,直接返回未关联状态
  1393. if (!enableExistingRelationCheck) {
  1394. resolve({ exists: false });
  1395. return;
  1396. }
  1397.  
  1398. // 获取所有已关联条目的容器
  1399. const relatedContainer = document.querySelector('#crtRelateSubjects');
  1400. if (!relatedContainer) {
  1401. resolve({ exists: false });
  1402. return;
  1403. }
  1404.  
  1405. // 原有的检查逻辑保持不变...
  1406. const relatedItems = relatedContainer.querySelectorAll('li');
  1407. for (const item of relatedItems) {
  1408. // 检查条目ID是否匹配 - 从URL中提取ID
  1409. const itemLink = item.querySelector('a[href*="/subject/"], a[href*="/character/"], a[href*="/person/"]');
  1410. if (!itemLink) continue;
  1411.  
  1412. const urlMatch = itemLink.href.match(/\/(subject|character|person)\/(\d+)/);
  1413. if (!urlMatch || urlMatch[2] !== item_id.toString()) continue;
  1414.  
  1415. // 找到匹配的已关联条目,检查并更新类型
  1416. const $select = $(item).find('select').first();
  1417. if ($select.length > 0) {
  1418. const currentType = $select.val();
  1419.  
  1420. if (currentType !== item_type) {
  1421. // 类型不同,需要更新
  1422. const success = await setRelationTypeWithElement($(item), item_type);
  1423. if (success) {
  1424. $('.Relation_item_type_changed').append(`${search_name} `);
  1425. resolve({ exists: true, typeChanged: true });
  1426. return;
  1427. }
  1428. } else {
  1429. // 类型相同,无需更新
  1430. $('.Relation_item_unchanged').append(`${search_name} `);
  1431. resolve({ exists: true, typeChanged: false });
  1432. return;
  1433. }
  1434. }
  1435. }
  1436.  
  1437. // 未找到匹配的已关联条目
  1438. resolve({ exists: false });
  1439. });
  1440. }
  1441.  
  1442. // 点击项目后利用 MutationObserver 监听新增条目,然后对该条目的下拉框设置类型
  1443. function processItem(element, item_type, item_id, search_name) {
  1444. return new Promise(async (resolve) => {
  1445. // 先检查条目是否已关联
  1446. const existingCheck = await checkAndHandleExistingRelation(search_name, item_id, item_type);
  1447. if (existingCheck.exists) {
  1448. return resolve(true); // 已处理完毕,无需继续
  1449. }
  1450.  
  1451. // 条目未关联,进行新增操作
  1452. // 关联列表容器
  1453. const container = document.querySelector('#crtRelateSubjects');
  1454. if (!container) {
  1455. return resolve(false);
  1456. }
  1457. // 保存处理前的条目列表
  1458. const initialItems = Array.from(container.children);
  1459. // 绑定 MutationObserver 监听子节点变化
  1460. const observer = new MutationObserver((mutations) => {
  1461. // 获取当前所有条目
  1462. const currentItems = Array.from(container.children);
  1463. // 找出新增的条目(在当前列表中但不在初始列表中的元素)
  1464. const newItems = currentItems.filter(item => !initialItems.includes(item));
  1465.  
  1466. if (newItems.length > 0) {
  1467. observer.disconnect();
  1468. const newItem = newItems[0]; // 获取第一个新增条目
  1469.  
  1470. // 确保等待DOM完全渲染
  1471. setTimeout(async () => {
  1472. // 使用新的条目元素直接查找其内部的select
  1473. const $select = $(newItem).find('select');
  1474.  
  1475. if ($select.length > 0) {
  1476. const success = await setRelationTypeWithElement($(newItem), item_type);
  1477. resolve(success);
  1478. } else {
  1479. resolve(false);
  1480. }
  1481. }, DELAY_AFTER_CLICK);
  1482. }
  1483. });
  1484.  
  1485. observer.observe(container, { childList: true, subtree: true });
  1486. // 触发点击
  1487. $(element).click();
  1488. // 超时防护
  1489. setTimeout(() => {
  1490. observer.disconnect();
  1491. resolve(false);
  1492. }, MAX_RETRY_ATTEMPTS * RETRY_INTERVAL);
  1493. });
  1494. }
  1495.  
  1496. // 处若搜索结果不唯一且没有完全匹配项则自动选择第一个
  1497. function normalizeText(text) {
  1498. return text.normalize("NFC").replace(/\s+/g, '').replace(/[\u200B-\u200D\uFEFF]/g, '').trim();
  1499. }
  1500.  
  1501. function extractTextFromElement(el) {
  1502. if (!el) return '';
  1503. let text = el.innerText || el.textContent || $(el).text();
  1504. // 尝试从 `iframe` 和 `shadowRoot` 获取文本
  1505. if (!text.trim()) {
  1506. if (el.shadowRoot) {
  1507. text = [...el.shadowRoot.querySelectorAll('*')].map(e => e.textContent).join('');
  1508. }
  1509. let iframe = el.querySelector('iframe');
  1510. if (iframe && iframe.contentDocument) {
  1511. text = iframe.contentDocument.body.textContent;
  1512. }
  1513. }
  1514. return normalizeText(text);
  1515. }
  1516.  
  1517. async function processSingleItem(elements, item_type, search_name) {
  1518. return new Promise(async (resolve) => {
  1519. if (elements.length === 0) {
  1520. $('.Relation_item_not_found').append(search_name + ' ');
  1521. resolve(false);
  1522. return;
  1523. }
  1524. let elementsArray = elements.toArray();
  1525. let normalizedSearchName = normalizeText(search_name);
  1526.  
  1527. console.log("搜索名(规范化):", normalizedSearchName);
  1528.  
  1529. // 等待元素加载,避免空文本
  1530. await new Promise(res => setTimeout(res, 500));
  1531. let selectedElement = elementsArray.find(el => {
  1532. let normalizedElementText = extractTextFromElement(el);
  1533. console.log("元素文本(规范化):", normalizedElementText); // 调试用
  1534. return normalizedElementText === normalizedSearchName;
  1535. });
  1536.  
  1537. if (!selectedElement) {
  1538. if (elements.length > 1) {
  1539. $('.Relation_item_dupe').append(`${search_name} `);
  1540. }
  1541. selectedElement = elements[0]; // 没有完全匹配,取第一个
  1542. }
  1543.  
  1544. // 提取条目ID
  1545. let item_id = null;
  1546. const itemHref = $(selectedElement).attr('href');
  1547. const idMatch = itemHref && itemHref.match(/\/(subject|character|person)\/(\d+)/);
  1548. if (idMatch) {
  1549. item_id = idMatch[2];
  1550. }
  1551.  
  1552. resolve(await processItem(selectedElement, item_type, item_id, search_name));
  1553. });
  1554. }
  1555.  
  1556. // 处理下一个项目
  1557. async function proceedToNextItem(idx, item_list, item_type, item_num) {
  1558. if (idx < item_num - 1) {
  1559. setTimeout(async () => {
  1560. await ctd_findItemFunc(item_list, item_type, idx + 1);
  1561. }, DELAY_BETWEEN_ITEMS);
  1562. } else {
  1563. setTimeout(() => {
  1564. $('#subjectList').empty();
  1565. $('#subjectList').show();
  1566. alert('全部添加完成');
  1567. }, DELAY_BETWEEN_ITEMS);
  1568. }
  1569. }
  1570.  
  1571. // 核心查找及处理函数:依次检索每个条目并处理
  1572. var ctd_findItemFunc = async function(item_list, item_type, idx) {
  1573. currentProcessingIndex = idx;
  1574. item_type = globalItemType;
  1575. let search_name = item_list[idx].trim();
  1576. if (!search_name) {
  1577. proceedToNextItem(idx, item_list, item_type, item_list.length);
  1578. return;
  1579. }
  1580. var item_num = item_list.length;
  1581. $('#subjectList').html('<tr><td>正在检索中...</td></tr>');
  1582. var search_mod = $('#sbjSearchMod').attr('value');
  1583. try {
  1584. const response = await new Promise((resolve, reject) => {
  1585. $.ajax({
  1586. type: "GET",
  1587. url: '/json/search-' + search_mod + '/' + encodeURIComponent(search_name),
  1588. dataType: 'json',
  1589. success: resolve,
  1590. error: reject
  1591. });
  1592. });
  1593. var html = '';
  1594. if ($(response).length > 0) {
  1595. subjectList = response;
  1596. for (var i in response) {
  1597. if ($.inArray(search_mod, enableStaffSbjType) != -1) {
  1598. html += genSubjectList(response[i], i, 'submitForm');
  1599. } else {
  1600. html += genSubjectList(response[i], i, 'searchResult');
  1601. }
  1602. }
  1603. $('#subjectList').html(html);
  1604. $('.Relation_current_idx').text(idx + 1);
  1605. $('.Relation_all_num').text(item_num);
  1606. await new Promise(resolve => setTimeout(resolve, 400)); // 减少等待时间
  1607. var elements = $('#subjectList>li>a.avatar.h');
  1608. if (window.location.pathname.includes('/person/') && window.location.pathname.includes('/add_related/character/anime')) {
  1609. if (elements.length === 0) {
  1610. $('.Relation_item_not_found').append(search_name + ' ');
  1611. } else {
  1612. // 提取条目ID
  1613. let item_id = null;
  1614. const itemHref = $(elements[0]).attr('href');
  1615. const idMatch = itemHref && itemHref.match(/\/(subject|character|person)\/(\d+)/);
  1616. if (idMatch) {
  1617. item_id = idMatch[2];
  1618. }
  1619.  
  1620. // 检查是否已关联
  1621. const existingCheck = await checkAndHandleExistingRelation(search_name, item_id, item_type);
  1622. if (!existingCheck.exists) {
  1623. $(elements[0]).click();
  1624. }
  1625.  
  1626. if (elements.length > 1) {
  1627. $('.Relation_item_dupe').append(`${search_name} `);
  1628. }
  1629. }
  1630.  
  1631. $('.Relation_current_idx').text(idx + 1);
  1632. if (idx < item_num - 1) {
  1633. setTimeout(async () => {
  1634. await ctd_findItemFunc(item_list, item_type, idx + 1);
  1635. }, DELAY_BETWEEN_ITEMS);
  1636. } else {
  1637. setTimeout(() => {
  1638. $('#subjectList').empty();
  1639. $('#subjectList').show();
  1640. alert('全部添加完成');
  1641. }, DELAY_BETWEEN_ITEMS);
  1642. }
  1643. } else {
  1644. await processSingleItem(elements, item_type, search_name, idx, item_list, item_num);
  1645. await proceedToNextItem(idx, item_list, item_type, item_num);
  1646. }
  1647. } else {
  1648. $("#robot").fadeIn(300);
  1649. $("#robot_balloon").html(`没有找到 ${search_name} 的相关结果`);
  1650. $("#robot").animate({ opacity: 1 }, 500).fadeOut(500); // 减少动画时间
  1651. $('.Relation_item_not_found').append(search_name + ' ');
  1652. $('#subjectList').html(html);
  1653. $('.Relation_current_idx').text(idx + 1);
  1654. $('.Relation_all_num').text(item_num);
  1655. await proceedToNextItem(idx, item_list, item_type, item_num);
  1656. }
  1657. } catch (error) {
  1658. console.error('查询出错:', error);
  1659. $("#robot").fadeIn(300);
  1660. $("#robot_balloon").html('通信错误,您是不是重复查询太快了?');
  1661. $("#robot").animate({ opacity: 1 }, 500).fadeOut(1000); // 减少动画时间
  1662. $('#subjectList').html('');
  1663. setTimeout(async () => {
  1664. if (idx < item_list.length - 1) {
  1665. await ctd_findItemFunc(item_list, item_type, idx + 1);
  1666. } else {
  1667. $('#subjectList').empty();
  1668. $('#subjectList').show();
  1669. alert('全部添加完成');
  1670. }
  1671. }, 1500); // 减少等待时间
  1672. }
  1673. };
  1674.  
  1675. // 增强的解析函数:支持多种ID分隔和准确搜索
  1676. function parsePersonInput(input) {
  1677. input = input.trim();
  1678. // 支持URL格式
  1679. const urlMatch = input.match(/(?:bgm\.tv|bangumi\.tv|chii\.in)\/(?:person|character|subject)\/(\d+)/i);
  1680. if (urlMatch) return urlMatch[1];
  1681. // 提取纯数字ID - 每次只返回一个ID
  1682. const numberMatch = input.match(/^\d+$/);
  1683. if (numberMatch) return numberMatch[0];
  1684. // 支持姓名直接搜索
  1685. if (/^[\u4e00-\u9fa5a-zA-Z0-9\s]+$/.test(input)) {
  1686. return encodeURIComponent(input);
  1687. }
  1688. return input; // 如果无法识别,返回原始输入
  1689. }
  1690.  
  1691. // 从ID范围中提取ID列表
  1692. function getIDsFromRange(start, end) {
  1693. const startID = parseInt(start, 10);
  1694. const endID = parseInt(end, 10);
  1695. if (isNaN(startID) || isNaN(endID) || startID > endID) {
  1696. alert("ID范围无效");
  1697. return [];
  1698. }
  1699. return Array.from({ length: endID - startID + 1 }, (_, i) => "bgm_id=" + (startID + i));
  1700. }
  1701.  
  1702. const numberMap = {
  1703. '0': '零', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五',
  1704. '6': '六', '7': '七', '8': '八', '9': '九', '10': '十',
  1705. 'Ⅰ': '一', 'Ⅱ': '二', 'Ⅲ': '三', 'Ⅳ': '四', 'Ⅴ': '五',
  1706. 'Ⅵ': '六', 'Ⅶ': '七', 'Ⅷ': '八', 'Ⅸ': '九', 'Ⅹ': '十'
  1707. };
  1708.  
  1709. // 修改后的 normalizeSeasonOrEpisode 函数
  1710. function normalizeSeasonOrEpisode(text) {
  1711. text = text.replace(/\s+/g, '');
  1712. // 如果完全由数字组成,则直接返回原文本
  1713. if (/^\d+$/.test(text)) return text;
  1714.  
  1715. // 处理带数字的情况(包括直接的数字转换)
  1716. const numberMatch = text.match(/(\d+)季$/);
  1717. if (numberMatch) {
  1718. const number = numberMatch[1];
  1719. const chineseNumber = numberMap[number] || number;
  1720. return text.replace(/\d+季$/, `${chineseNumber}季`);
  1721. }
  1722.  
  1723. // 处理原有的罗马数字模式
  1724. const romanMatch = text.match(/[^\d]([ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ])$/);
  1725. if (romanMatch) {
  1726. const romanNumber = romanMatch[1];
  1727. const chineseNumber = numberMap[romanNumber];
  1728. return text.replace(/[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]$/, `${chineseNumber}季`);
  1729. }
  1730.  
  1731. // 新增:处理"标题 数字"格式
  1732. const simpleTitleNumberMatch = text.match(/(.+?)(\d+)$/);
  1733. if (simpleTitleNumberMatch) {
  1734. const title = simpleTitleNumberMatch[1];
  1735. const number = simpleTitleNumberMatch[2];
  1736. const chineseNumber = numberMap[number] || number;
  1737. return `${title}第${chineseNumber}季`;
  1738. }
  1739. return text;
  1740. }
  1741.  
  1742. // 修改后的 getIDsFromText 函数
  1743. function getIDsFromText(input) {
  1744. input = input.trim();
  1745. if (!input) {
  1746. alert("请输入ID或内容");
  1747. return [];
  1748. }
  1749.  
  1750. // 先识别 URL 形式的 ID
  1751. const urlPattern = /(bgm\.tv|bangumi\.tv|chii\.in)\/(subject|character|person)\/(\d+)/g;
  1752. const urlMatches = [...input.matchAll(urlPattern)].map(m => m[3]);
  1753. if (urlMatches.length > 0) {
  1754. return urlMatches.map(id => "bgm_id=" + id);
  1755. }
  1756.  
  1757. // 如果以 "bgm_id=" 开头,则去掉前缀后进行分割,使用 /[^0-9]+/ 作为分隔符
  1758. if (input.startsWith("bgm_id=")) {
  1759. return input.substring(7)
  1760. .split(/[^0-9]+/)
  1761. .filter(token => token)
  1762. .map(token => "bgm_id=" + token);
  1763. }
  1764.  
  1765. // 否则先按标点和各种分隔符拆分,再进行标准化和数字提取
  1766. return input.split(/[,\n\r,、\/|;。.()【】<>!?]+/)
  1767. .map(part => part.trim())
  1768. .filter(part => part.length > 0)
  1769. .map(normalizeSeasonOrEpisode)
  1770. .map(part => {
  1771. // 处理纯数字ID(此时 normalizeSeasonOrEpisode 不会修改纯数字)
  1772. const numberMatch = part.match(/\b\d+\b/);
  1773. if (numberMatch) {
  1774. return "bgm_id=" + numberMatch[0];
  1775. }
  1776. return part;
  1777. })
  1778. .filter(part => part);
  1779. }
  1780.  
  1781. // 批量查找入口函数
  1782. var Relation_MultiFindItemFunc = async function() {
  1783. let item_type = '1';
  1784. let typeSelector = $('.Relation_item_type select');
  1785. if (typeSelector.length > 0) {
  1786. item_type = typeSelector.val();
  1787. if (item_type == '-999') {
  1788. alert('请先选择关联类型');
  1789. return false;
  1790. }
  1791. globalItemType = item_type;
  1792. }
  1793.  
  1794. let ctd_item_list = [];
  1795. const activeTab = $('.tab-panel.active').attr('id');
  1796. if (activeTab === 'tab-text') {
  1797. // 处理文本输入模式
  1798. const inputVal = $('#custom_ids').val().trim();
  1799. ctd_item_list = getIDsFromText(inputVal);
  1800. } else if (activeTab === 'tab-range') {
  1801. // 处理ID范围模式
  1802. const startID = $('#id_start').val().trim();
  1803. const endID = $('#id_end').val().trim();
  1804. ctd_item_list = getIDsFromRange(startID, endID);
  1805. }
  1806. if (ctd_item_list.length === 0) {
  1807. return false;
  1808. }
  1809.  
  1810. $('#subjectList').hide();
  1811. $('.Relation_item_not_found').empty();
  1812. $('.Relation_item_dupe').empty();
  1813. $('.Relation_item_type_changed').empty();
  1814. $('.Relation_item_unchanged').empty();
  1815. $('.Relation_current_idx').text('0');
  1816. $('.Relation_all_num').text(ctd_item_list.length);
  1817.  
  1818. currentProcessingIndex = -1;
  1819. await ctd_findItemFunc(ctd_item_list, item_type, 0);
  1820. };
  1821.  
  1822. // 切换标签页
  1823. function switchTab(tabId) {
  1824. $('.tab-nav button').removeClass('active');
  1825. $(`.tab-nav button[data-tab="${tabId}"]`).addClass('active');
  1826. $('.tab-panel').removeClass('active');
  1827. $(`#${tabId}`).addClass('active');
  1828. }
  1829.  
  1830. let uiTitle = '条目';
  1831. const pathname = window.location.pathname;
  1832. if (pathname.includes('/subject/') && pathname.includes('/add_related/person')) {
  1833. uiTitle = '人物';
  1834. } else if (pathname.includes('/subject/') && pathname.includes('/add_related/character')) {
  1835. uiTitle = '角色';
  1836. }
  1837.  
  1838. // 创建改进的UI界面
  1839. $('.subjectListWrapper').after(`
  1840. <div class="Relation_wrapper">
  1841. <h2 style="color: #333333;">批量关联助手</h2>
  1842. <div class="tab-nav">
  1843. <button data-tab="tab-text" class="active">自由文本输入</button>
  1844. <button data-tab="tab-range">ID范围输入</button>
  1845. </div>
  1846.  
  1847. <div id="tab-text" class="tab-panel active">
  1848. <textarea id="custom_ids" class="enhancer-textarea"
  1849. placeholder="输入ID/网址/名称(支持多种格式:bgm_id=xx、数字、网址、文本,支持除空格外各类符号分隔)"></textarea>
  1850. </div>
  1851.  
  1852. <div id="tab-range" class="tab-panel">
  1853. <div class="flex-row" style="justify-content: center">
  1854. <input id="id_start" type="number" placeholder="起始ID" class="input-number">
  1855. <span style="line-height: 30px">~</span>
  1856. <input id="id_end" type="number" placeholder="结束ID" class="input-number">
  1857. </div>
  1858. </div>
  1859.  
  1860. <div class="Relation_controls" style="margin-top: 10px">
  1861. <span class="Relation_item_type"></span>
  1862. <button id="btn_ctd_multi_search" class="btnCustom">批量关联</button>
  1863. </div>
  1864.  
  1865. <div class="toggle-container" style="margin-top: 10px; display: flex; align-items: center;">
  1866. <input type="checkbox" id="toggle_existing_check">
  1867. <label for="toggle_existing_check" style="margin-left: 8px;">已关联条目检查</label>
  1868. <span class="toggle-description" style="margin-left: 8px; font-size: 12px; color: #666;">(取消勾选将不检查已关联项)</span>
  1869. </div>
  1870.  
  1871. <div class="Relation_progress">
  1872. 添加进度:<span class="Relation_current_idx">0</span>/<span class="Relation_all_num">0</span>
  1873. </div>
  1874.  
  1875. <div class="Relation_header">未找到的${uiTitle}:</div>
  1876. <div class="Relation_item_not_found"></div>
  1877.  
  1878. <div class="Relation_header">存在多结果的${uiTitle}(无最佳匹配结果,将自动选择第一个):</div>
  1879. <div class="Relation_item_dupe"></div>
  1880.  
  1881. <div class="Relation_header">已修改类型的${uiTitle}:</div>
  1882. <div class="Relation_item_type_changed"></div>
  1883.  
  1884. <div class="Relation_header">无需修改类型的${uiTitle}:</div>
  1885. <div class="Relation_item_unchanged"></div>
  1886. </div>
  1887. `);
  1888.  
  1889. // 绑定开关事件
  1890. $('#toggle_existing_check').on('change', function() {
  1891. enableExistingRelationCheck = $(this).prop('checked');
  1892. console.log("已关联条目检查功能:", enableExistingRelationCheck ? "已启用" : "已禁用");
  1893. });
  1894. // 添加关联类型选择器
  1895. $('.Relation_item_type').append(generateTypeSelector());
  1896. $('.Relation_item_type select').prepend('<option value="-999">请选择关联类型</option>').val('-999');
  1897.  
  1898. // 绑定事件
  1899. $('#btn_ctd_multi_search').on('click', Relation_MultiFindItemFunc);
  1900. $('.Relation_item_type select').on('change', function() {
  1901. globalItemType = $(this).val();
  1902. });
  1903. $('.tab-nav button').on('click', function() {
  1904. switchTab($(this).data('tab'));
  1905. });
  1906. }
  1907.  
  1908. // 启动所有功能
  1909. function startEnhancer() {
  1910. initNavButtons();
  1911. observeURLChanges();
  1912. initCoverUpload();
  1913. initBatchRelation()
  1914. BatchEpisodeEditor.init();
  1915. console.log("Bangumi Ultimate Enhancer 已启动");
  1916. }
  1917. // 在DOM加载完成后启动脚本
  1918. if (document.readyState === 'loading') {
  1919. document.addEventListener('DOMContentLoaded', startEnhancer);
  1920. } else {
  1921. startEnhancer();
  1922. }
  1923. })();

QingJ © 2025

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