Appinn Forum Upload Enhancer

小众软件论坛发帖或回复时,粘贴、拖曳或上传按钮选择图片/文件,自动上传到 h1.appinn.me 并转为对应的 Markdown 格式输出。

  1. // ==UserScript==
  2. // @name Appinn Forum Upload Enhancer
  3. // @name:zh-CN 小众软件论坛上传优化
  4. // @license AGPL-3.0
  5. // @version 0.4.0
  6. // @author xymoryn
  7. // @namespace https://github.com/xymoryn
  8. // @icon https://h1.appinn.me/logo.png
  9. // @description 小众软件论坛发帖或回复时,粘贴、拖曳或上传按钮选择图片/文件,自动上传到 h1.appinn.me 并转为对应的 Markdown 格式输出。
  10. // @homepage https://github.com/xymoryn/user-scripts
  11. // @supportURL https://github.com/xymoryn/user-scripts/issues
  12. // @run-at document-idle
  13. // @match https://meta.appinn.net/*
  14. // @grant GM_xmlhttpRequest
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. 'use strict';
  19.  
  20. /**
  21. * 全局配置对象
  22. * @type {Object}
  23. */
  24. const CONFIG = {
  25. /** 是否开启调试模式,控制控制台输出 */
  26. DEBUG: false,
  27.  
  28. /** 文件大小限制 (20MB) */
  29. MAX_FILE_SIZE: 20 * 1024 * 1024,
  30.  
  31. /** 上传端点 */
  32. UPLOAD_ENDPOINT: 'https://h1.appinn.me/upload',
  33.  
  34. /**
  35. * 上传配置参数
  36. * @property {string} authCode - 认证码(必填)
  37. * @property {boolean} serverCompress - 是否启用 Telegram 图片压缩:
  38. * - 启用丢失透明度
  39. * - 对大于 10MB 的文件无效
  40. * @property {'telegram'|'cfr2'|'s3'} uploadChannel - 文件上传渠道:
  41. * - 'telegram': Telegram。当前小众图床唯一可用的上传渠道。
  42. * - 'cfr2': Cloudflare R2
  43. * - 's3': Amazon S3
  44. * @property {'default'|'index'|'origin'|'short'} uploadNameType - 文件命名方式:
  45. * - 'default': 时间戳_原始文件名
  46. * - 'index': 仅时间戳
  47. * - 'origin': 原始文件名
  48. * - 'short': 类似短链接的随机字母数字
  49. * @property {boolean} autoRetry - 上传失败时是否自动切换到其他渠道
  50. */
  51. UPLOAD_PARAMS: {
  52. authCode: 'appinn2',
  53. serverCompress: false,
  54. uploadChannel: 'telegram',
  55. uploadNameType: 'default',
  56. autoRetry: true,
  57. },
  58.  
  59. /** 资源访问 URL 前缀 */
  60. ASSETS_URL_PREFIX: 'https://h1.appinn.me',
  61.  
  62. /**
  63. * 支持的文件类型
  64. * @type {Object.<string, {test: Function, format: Function, acceptString: string}>}
  65. */
  66. SUPPORTED_MIME_TYPES: {
  67. 'image': {
  68. test: (type) => type.startsWith('image/'),
  69. format: (filename, url) => `![${filename}](${url})`,
  70. acceptString: 'image/*',
  71. },
  72. 'video': {
  73. test: (type) => type.startsWith('video/'),
  74. format: (filename, url) => `![${filename}|video](${url})`,
  75. acceptString: 'video/*',
  76. },
  77. 'audio': {
  78. test: (type) => type.startsWith('audio/'),
  79. format: (filename, url) => `![${filename}|audio](${url})`,
  80. acceptString: 'audio/*',
  81. },
  82. 'pdf': {
  83. test: (type) => type === 'application/pdf',
  84. format: (filename, url) => `[${filename}|attachment](${url})`,
  85. acceptString: '.pdf',
  86. },
  87. },
  88.  
  89. /** 内容格式配置 */
  90. CONTENT_FORMAT: {
  91. /** 内容前面的换行符 */
  92. BEFORE: '\n',
  93. /** 内容后面的换行符 */
  94. AFTER: '\n\n',
  95. },
  96.  
  97. /** DOM选择器 */
  98. SELECTORS: {
  99. REPLY_CONTROL: '#reply-control', // 回复框
  100. EDITOR_CONTROLS: '.toolbar-visible.wmd-controls', // 编辑器控件
  101. EDITOR_INPUT: '.d-editor-input', // 编辑区域
  102. UPLOAD_BUTTON: '.btn.upload', // 上传按钮
  103. },
  104.  
  105. /** 错误类型 */
  106. ERROR_TYPES: {
  107. NETWORK: 'network', // 网络连接问题
  108. SERVER: 'server', // 服务器错误
  109. PERMISSION: 'permission', // 权限问题
  110. FORMAT: 'format', // 响应格式错误
  111. FILETYPE: 'filetype', // 文件类型不支持
  112. FILESIZE: 'filesize', // 文件大小超限
  113. UNKNOWN: 'unknown', // 未知错误
  114. },
  115. };
  116.  
  117. /**
  118. * 日志工具
  119. * @namespace
  120. */
  121. const Logger = {
  122. /**
  123. * 输出普通日志
  124. * @param {...any} args - 日志参数
  125. */
  126. log(...args) {
  127. if (CONFIG.DEBUG) {
  128. console.log('[小众论坛上传]', ...args);
  129. }
  130. },
  131.  
  132. /**
  133. * 输出错误日志
  134. * @param {...any} args - 日志参数
  135. */
  136. error(...args) {
  137. if (CONFIG.DEBUG) {
  138. console.error('[小众论坛上传]', ...args);
  139. }
  140. },
  141. };
  142.  
  143. /**
  144. * 应用状态管理
  145. * @namespace
  146. */
  147. const AppState = {
  148. /**
  149. * 保存所有上传状态
  150. * @type {Object.<string, {insertPosition: number, placeholderText: string, active: boolean}>}
  151. */
  152. uploads: {},
  153.  
  154. /** 上传计数器 */
  155. uploadCounter: 0,
  156.  
  157. /** DOM元素缓存 */
  158. elements: {
  159. replyControl: null,
  160. editorInput: null,
  161. editorControls: null,
  162. uploadButton: null,
  163. },
  164.  
  165. /**
  166. * 生成唯一上传ID
  167. * @returns {string} 唯一ID
  168. */
  169. generateUploadId() {
  170. return `${Date.now()}-${++this.uploadCounter}`;
  171. },
  172.  
  173. /**
  174. * 添加上传状态
  175. * @param {string} uploadId - 上传ID
  176. * @param {number} position - 插入位置
  177. * @param {string} placeholderText - 占位符文本
  178. */
  179. addUpload(uploadId, position, placeholderText) {
  180. this.uploads[uploadId] = {
  181. insertPosition: position,
  182. placeholderText,
  183. active: true,
  184. timestamp: Date.now(),
  185. };
  186. },
  187.  
  188. /**
  189. * 获取上传状态
  190. * @param {string} uploadId - 上传ID
  191. * @returns {Object|null} 上传状态对象
  192. */
  193. getUpload(uploadId) {
  194. return this.uploads[uploadId] ?? null;
  195. },
  196.  
  197. /**
  198. * 移除上传状态
  199. * @param {string} uploadId - 上传ID
  200. */
  201. removeUpload(uploadId) {
  202. delete this.uploads[uploadId];
  203. },
  204. };
  205.  
  206. /**
  207. * DOM操作工具
  208. * @namespace
  209. */
  210. const DOMUtils = {
  211. /**
  212. * 判断回复控制面板是否处于打开状态
  213. * @param {HTMLElement} element - 回复框元素
  214. * @returns {boolean} 是否处于打开状态
  215. */
  216. isReplyControlOpen(element) {
  217. return element && element.id === 'reply-control' && !element.classList.contains('closed');
  218. },
  219.  
  220. /**
  221. * 保存编辑器状态
  222. * @param {HTMLTextAreaElement} editor - 编辑器元素
  223. * @returns {Object} 编辑器状态
  224. */
  225. saveEditorState(editor) {
  226. const { selectionStart, selectionEnd, scrollTop, value } = editor;
  227. return { selectionStart, selectionEnd, scrollTop, value };
  228. },
  229.  
  230. /**
  231. * 触发输入事件
  232. * @param {HTMLTextAreaElement} editor - 编辑器元素
  233. */
  234. triggerInputEvent(editor) {
  235. editor.dispatchEvent(new Event('input', { bubbles: true }));
  236. },
  237. };
  238.  
  239. /**
  240. * 文件工具
  241. * @namespace
  242. */
  243. const FileUtils = {
  244. /**
  245. * 检查文件类型
  246. * @param {File} file - 文件对象
  247. * @returns {string|null} 文件类型或null
  248. */
  249. getFileType(file) {
  250. const { type: mimeType } = file;
  251. const entry = Object.entries(CONFIG.SUPPORTED_MIME_TYPES).find(([_, info]) =>
  252. info.test(mimeType),
  253. );
  254. return entry ? entry[0] : null;
  255. },
  256.  
  257. /**
  258. * 检查文件是否合法
  259. * @param {File} file - 文件对象
  260. * @returns {{valid: boolean, error: string|null}} 检查结果及错误类型
  261. */
  262. validateFile(file) {
  263. // 检查类型
  264. const fileType = this.getFileType(file);
  265. if (fileType === null) {
  266. return {
  267. valid: false,
  268. error: CONFIG.ERROR_TYPES.FILETYPE,
  269. };
  270. }
  271.  
  272. // 检查大小
  273. if (file.size > CONFIG.MAX_FILE_SIZE) {
  274. return {
  275. valid: false,
  276. error: CONFIG.ERROR_TYPES.FILESIZE,
  277. };
  278. }
  279.  
  280. return { valid: true, error: null };
  281. },
  282.  
  283. /**
  284. * 检查是否有文件在拖放数据中
  285. * @param {DataTransfer} dataTransfer - 数据传输对象
  286. * @returns {boolean} 是否有文件
  287. */
  288. hasFileInDataTransfer(dataTransfer) {
  289. if (!dataTransfer) return false;
  290.  
  291. // 通过items检查
  292. if (dataTransfer.items?.length) {
  293. return [...dataTransfer.items].some((item) => item.kind === 'file');
  294. }
  295.  
  296. // 通过types检查
  297. if (dataTransfer.types?.includes('Files')) {
  298. return true;
  299. }
  300.  
  301. // 通过files检查
  302. return dataTransfer.files?.length > 0;
  303. },
  304.  
  305. /**
  306. * 获取剪贴板中的文件
  307. * @param {ClipboardData} clipboardData - 剪贴板数据
  308. * @returns {File|null} 文件或null
  309. */
  310. getFileFromClipboard(clipboardData) {
  311. if (!clipboardData?.items) return null;
  312.  
  313. for (const item of clipboardData.items) {
  314. if (item.kind === 'file') {
  315. return item.getAsFile();
  316. }
  317. }
  318.  
  319. return null;
  320. },
  321.  
  322. /**
  323. * 生成文件选择器的accept属性
  324. * @returns {string} accept属性值
  325. */
  326. generateAcceptString() {
  327. return Object.values(CONFIG.SUPPORTED_MIME_TYPES)
  328. .map((type) => type.acceptString)
  329. .join(',');
  330. },
  331.  
  332. /**
  333. * 显示文件错误消息
  334. * @param {File} file - 文件对象
  335. * @param {string} errorType - 错误类型
  336. */
  337. showFileError(file, errorType) {
  338. const { name, type } = file;
  339. let message;
  340.  
  341. switch (errorType) {
  342. case CONFIG.ERROR_TYPES.FILETYPE:
  343. message = `不支持的文件类型: ${type}`;
  344. break;
  345. case CONFIG.ERROR_TYPES.FILESIZE:
  346. message = `文件"${name}"超过${
  347. CONFIG.MAX_FILE_SIZE / (1024 * 1024)
  348. }MB大小限制,无法上传。`;
  349. break;
  350. default:
  351. message = `文件"${name}"无法上传: 未知错误`;
  352. }
  353.  
  354. alert(message);
  355. Logger.log(message);
  356. },
  357. };
  358.  
  359. /**
  360. * Markdown格式化工具
  361. * @namespace
  362. */
  363. const MarkdownFormatter = {
  364. /**
  365. * 获取文件对应的Markdown链接
  366. * @param {File} file - 文件对象
  367. * @param {string} url - 文件URL
  368. * @returns {string} Markdown格式文本
  369. */
  370. getMarkdownLink(file, url) {
  371. const { name: filename = `file_${Date.now()}` } = file;
  372. const fileType = FileUtils.getFileType(file);
  373.  
  374. if (fileType && CONFIG.SUPPORTED_MIME_TYPES[fileType]) {
  375. return CONFIG.SUPPORTED_MIME_TYPES[fileType].format(filename, url);
  376. }
  377.  
  378. // 默认格式
  379. return `[${filename}](${url})`;
  380. },
  381.  
  382. /**
  383. * 获取占位符文本
  384. * @param {File} file - 文件对象
  385. * @param {string} uploadId - 上传ID
  386. * @returns {string} 占位符文本
  387. */
  388. getPlaceholderText(file, uploadId) {
  389. const fileType = FileUtils.getFileType(file);
  390. const prefix =
  391. fileType === 'image' || fileType === 'video' || fileType === 'audio' ? '!' : '';
  392. const suffix =
  393. fileType === 'video'
  394. ? '|video'
  395. : fileType === 'audio'
  396. ? '|audio'
  397. : fileType === 'pdf'
  398. ? '|attachment'
  399. : '';
  400.  
  401. return `${prefix}[上传中...${uploadId}${suffix}]`;
  402. },
  403.  
  404. /**
  405. * 获取上传失败的Markdown文本
  406. * @param {string} uploadId - 上传ID
  407. * @param {string} errorType - 错误类型
  408. * @returns {string} 失败提示文本
  409. */
  410. getFailureText(uploadId, errorType) {
  411. const errorMessages = {
  412. [CONFIG.ERROR_TYPES.NETWORK]: '网络错误',
  413. [CONFIG.ERROR_TYPES.SERVER]: '服务器错误',
  414. [CONFIG.ERROR_TYPES.PERMISSION]: '权限错误',
  415. [CONFIG.ERROR_TYPES.FORMAT]: '格式错误',
  416. [CONFIG.ERROR_TYPES.FILETYPE]: '类型不支持',
  417. [CONFIG.ERROR_TYPES.FILESIZE]: '文件过大',
  418. [CONFIG.ERROR_TYPES.UNKNOWN]: '未知错误',
  419. };
  420.  
  421. const errorMessage = errorMessages[errorType] || '未知错误';
  422. return `[上传失败(${errorMessage})-${uploadId}]`;
  423. },
  424.  
  425. /**
  426. * 格式化内容,添加配置的前后换行符
  427. * @param {string} content - 原始内容
  428. * @returns {string} 格式化后的内容
  429. */
  430. formatContent(content) {
  431. return CONFIG.CONTENT_FORMAT.BEFORE + content + CONFIG.CONTENT_FORMAT.AFTER;
  432. },
  433. };
  434.  
  435. /**
  436. * 占位符管理器
  437. * @namespace
  438. */
  439. const PlaceholderManager = {
  440. /**
  441. * 插入占位符到编辑器
  442. * @param {HTMLTextAreaElement} editor - 编辑器元素
  443. * @param {Object} editorState - 编辑器状态
  444. * @param {string} placeholderText - 占位符文本
  445. * @param {string} uploadId - 上传ID
  446. * @param {Function} onInserted - 占位符插入完成后的回调
  447. */
  448. insertPlaceholder(editor, editorState, placeholderText, uploadId, onInserted) {
  449. const { selectionStart: position, scrollTop } = editorState;
  450. const currentText = editor.value;
  451.  
  452. // 使用配置的格式化占位符
  453. const completeText = MarkdownFormatter.formatContent(placeholderText);
  454.  
  455. // 插入带换行的占位符
  456. editor.value =
  457. currentText.substring(0, position) + completeText + currentText.substring(position);
  458.  
  459. // 触发输入事件
  460. DOMUtils.triggerInputEvent(editor);
  461.  
  462. // 将光标移动到占位符后面
  463. const newCursorPosition = position + completeText.length;
  464. editor.selectionStart = newCursorPosition;
  465. editor.selectionEnd = newCursorPosition;
  466. editor.scrollTop = scrollTop;
  467. editor.focus();
  468.  
  469. // 保存上传状态
  470. AppState.addUpload(uploadId, position, placeholderText);
  471.  
  472. // 调用回调函数
  473. onInserted?.(uploadId, position);
  474. },
  475.  
  476. /**
  477. * 查找占位符在文本中的位置
  478. * @param {string} text - 编辑器文本内容
  479. * @param {string} uploadId - 上传ID
  480. * @returns {Object|null} 占位符位置信息或null
  481. */
  482. findPlaceholder(text, uploadId) {
  483. const regex = new RegExp(`(!)?\\[上传中...${uploadId}(\\|[a-z]+)?\\]`);
  484. const match = regex.exec(text);
  485.  
  486. if (match) {
  487. // 仅找到占位符文本本身
  488. return {
  489. start: match.index,
  490. end: match.index + match[0].length,
  491. text: match[0],
  492. };
  493. }
  494.  
  495. return null;
  496. },
  497.  
  498. /**
  499. * 替换编辑器中的占位符
  500. * @param {HTMLTextAreaElement} editor - 编辑器元素
  501. * @param {string} uploadId - 上传ID
  502. * @param {string} markdownLink - Markdown链接文本
  503. * @param {Function} onReplaced - 替换完成后的回调
  504. */
  505. replacePlaceholder(editor, uploadId, markdownLink, onReplaced) {
  506. // 保存当前用户光标状态
  507. const currentState = DOMUtils.saveEditorState(editor);
  508. const { value: currentText } = currentState;
  509.  
  510. // 查找占位符位置
  511. const placeholder = this.findPlaceholder(currentText, uploadId);
  512.  
  513. // 如果找不到占位符,使用备选策略
  514. if (!placeholder) {
  515. Logger.error('找不到占位符,将添加到编辑器末尾');
  516. this._appendToEditor(editor, markdownLink, onReplaced);
  517. return;
  518. }
  519.  
  520. // 计算长度变化
  521. const originalLength = placeholder.end - placeholder.start;
  522. const newLength = markdownLink.length;
  523. const lengthDiff = newLength - originalLength;
  524.  
  525. // 替换内容
  526. const newText =
  527. currentText.substring(0, placeholder.start) +
  528. markdownLink +
  529. currentText.substring(placeholder.end);
  530.  
  531. // 更新编辑器内容
  532. editor.value = newText;
  533. DOMUtils.triggerInputEvent(editor);
  534.  
  535. // 调整光标位置
  536. this._adjustCursorPosition(editor, currentState, placeholder, lengthDiff);
  537.  
  538. // 调用回调函数
  539. onReplaced?.(true);
  540. },
  541.  
  542. /**
  543. * 调整光标位置
  544. * @private
  545. * @param {HTMLTextAreaElement} editor - 编辑器元素
  546. * @param {Object} currentState - 当前编辑器状态
  547. * @param {Object} placeholder - 占位符信息
  548. * @param {number} lengthDiff - 长度变化
  549. */
  550. _adjustCursorPosition(editor, currentState, placeholder, lengthDiff) {
  551. const { selectionStart, selectionEnd, scrollTop } = currentState;
  552. let newSelectionStart = selectionStart;
  553. let newSelectionEnd = selectionEnd;
  554.  
  555. // 情况1:光标在占位符之前 - 不需要调整
  556. if (selectionStart < placeholder.start) {
  557. // 不调整光标位置
  558. }
  559. // 情况2:光标在占位符范围内 - 移动到替换内容之后
  560. else if (selectionStart >= placeholder.start && selectionStart <= placeholder.end) {
  561. newSelectionStart = placeholder.start + (placeholder.end - placeholder.start) + lengthDiff;
  562. newSelectionEnd = newSelectionStart;
  563. }
  564. // 情况3:光标在占位符之后 - 根据内容长度变化调整
  565. else if (selectionStart > placeholder.end) {
  566. newSelectionStart = selectionStart + lengthDiff;
  567. newSelectionEnd = selectionEnd + lengthDiff;
  568. }
  569.  
  570. // 设置新的光标位置
  571. editor.selectionStart = newSelectionStart;
  572. editor.selectionEnd = newSelectionEnd;
  573. editor.scrollTop = scrollTop;
  574. editor.focus();
  575. },
  576.  
  577. /**
  578. * 将内容添加到编辑器末尾
  579. * @private
  580. * @param {HTMLTextAreaElement} editor - 编辑器元素
  581. * @param {string} content - 要添加的内容
  582. * @param {Function} onAppended - 添加完成后的回调
  583. */
  584. _appendToEditor(editor, content, onAppended) {
  585. const currentText = editor.value;
  586.  
  587. // 确保有换行分隔
  588. let newText = currentText;
  589. if (newText.length > 0 && !newText.endsWith('\n')) {
  590. newText += '\n';
  591. }
  592.  
  593. // 添加新内容,使用配置的格式
  594. newText += MarkdownFormatter.formatContent(content);
  595.  
  596. // 更新编辑器内容
  597. editor.value = newText;
  598. DOMUtils.triggerInputEvent(editor);
  599.  
  600. // 移动光标到末尾
  601. editor.selectionStart = newText.length;
  602. editor.selectionEnd = newText.length;
  603. editor.focus();
  604.  
  605. // 调用回调
  606. onAppended?.(false);
  607. },
  608. };
  609.  
  610. /**
  611. * 上传服务
  612. * @namespace
  613. */
  614. const UploadService = {
  615. /**
  616. * 处理文件上传
  617. * @param {FileList|Array<File>} files - 文件列表
  618. * @param {HTMLTextAreaElement} editor - 编辑器元素
  619. */
  620. processFiles(files, editor) {
  621. if (!files?.length) return;
  622.  
  623. [...files].forEach((file) => {
  624. const validation = FileUtils.validateFile(file);
  625.  
  626. if (validation.valid) {
  627. this.uploadFile(file, editor);
  628. } else {
  629. FileUtils.showFileError(file, validation.error);
  630. }
  631. });
  632. },
  633.  
  634. /**
  635. * 上传单个文件
  636. * @param {File} file - 文件对象
  637. * @param {HTMLTextAreaElement} editor - 编辑器元素
  638. */
  639. uploadFile(file, editor) {
  640. // 生成唯一ID
  641. const uploadId = AppState.generateUploadId();
  642.  
  643. // 获取占位符文本
  644. const placeholderText = MarkdownFormatter.getPlaceholderText(file, uploadId);
  645.  
  646. // 保存当前编辑器状态
  647. const editorState = DOMUtils.saveEditorState(editor);
  648.  
  649. // 插入占位符
  650. PlaceholderManager.insertPlaceholder(editor, editorState, placeholderText, uploadId, () => {
  651. // 占位符插入后执行上传
  652. this._executeUpload(file, editor, uploadId);
  653. });
  654. },
  655.  
  656. /**
  657. * 执行文件上传
  658. * @private
  659. * @param {File} file - 文件对象
  660. * @param {HTMLTextAreaElement} editor - 编辑器元素
  661. * @param {string} uploadId - 上传ID
  662. */
  663. async _executeUpload(file, editor, uploadId) {
  664. try {
  665. const result = await this.performUpload(file);
  666.  
  667. // 生成Markdown链接
  668. const markdownLink = MarkdownFormatter.getMarkdownLink(file, result.url);
  669.  
  670. // 替换占位符
  671. PlaceholderManager.replacePlaceholder(editor, uploadId, markdownLink, (success) => {
  672. if (success) {
  673. Logger.log('占位符替换成功:', uploadId);
  674. } else {
  675. Logger.log('占位符未找到,已添加到编辑器末尾:', uploadId);
  676. }
  677. // 清理上传状态
  678. AppState.removeUpload(uploadId);
  679. });
  680. } catch (error) {
  681. // 处理上传失败
  682. Logger.error('上传文件失败:', error);
  683.  
  684. // 确定错误类型
  685. const errorType = this._categorizeError(error);
  686.  
  687. // 生成错误文本
  688. const failureText = MarkdownFormatter.getFailureText(uploadId, errorType);
  689.  
  690. // 替换占位符
  691. PlaceholderManager.replacePlaceholder(editor, uploadId, failureText, () => {
  692. // 清理上传状态
  693. AppState.removeUpload(uploadId);
  694. });
  695. }
  696. },
  697.  
  698. /**
  699. * 分类错误类型
  700. * @private
  701. * @param {string|Error} error - 错误信息
  702. * @returns {string} 错误类型
  703. */
  704. _categorizeError(error) {
  705. const errorStr = error.toString().toLowerCase();
  706.  
  707. if (
  708. errorStr.includes('network') ||
  709. errorStr.includes('failed to fetch') ||
  710. errorStr.includes('网络请求失败')
  711. ) {
  712. return CONFIG.ERROR_TYPES.NETWORK;
  713. }
  714.  
  715. if (errorStr.includes('401') || errorStr.includes('403') || errorStr.includes('permission')) {
  716. return CONFIG.ERROR_TYPES.PERMISSION;
  717. }
  718.  
  719. if (errorStr.includes('500') || errorStr.includes('503') || errorStr.includes('服务器')) {
  720. return CONFIG.ERROR_TYPES.SERVER;
  721. }
  722.  
  723. if (errorStr.includes('解析') || errorStr.includes('parse') || errorStr.includes('format')) {
  724. return CONFIG.ERROR_TYPES.FORMAT;
  725. }
  726.  
  727. return CONFIG.ERROR_TYPES.UNKNOWN;
  728. },
  729.  
  730. /**
  731. * 执行文件上传到服务器
  732. * @param {File} file - 文件对象
  733. * @returns {Promise<{url: string, filename: string}>} 上传结果
  734. */
  735. performUpload(file) {
  736. return new Promise((resolve, reject) => {
  737. const { name: filename = `file_${Date.now()}` } = file;
  738. const formData = new FormData();
  739. formData.append('filename', filename);
  740. formData.append('file', file);
  741.  
  742. const params = new URLSearchParams();
  743. Object.entries(CONFIG.UPLOAD_PARAMS).forEach(([key, val]) => {
  744. params.append(key, val);
  745. });
  746.  
  747. const uploadUrl = `${CONFIG.UPLOAD_ENDPOINT}?${params.toString()}`;
  748.  
  749. GM_xmlhttpRequest({
  750. method: 'POST',
  751. url: uploadUrl,
  752. data: formData,
  753. responseType: 'json',
  754. onload: (response) => {
  755. if (response.status !== 200) {
  756. return reject(`HTTP错误: ${response.status}`);
  757. }
  758.  
  759. try {
  760. const data = response.response;
  761. if (!data?.[0]?.src) {
  762. return reject('无效的响应数据');
  763. }
  764.  
  765. const fileUrl = CONFIG.ASSETS_URL_PREFIX + data[0].src;
  766. resolve({
  767. url: fileUrl,
  768. filename,
  769. });
  770. } catch (error) {
  771. reject('解析响应数据失败');
  772. }
  773. },
  774. onerror: () => reject('网络请求失败'),
  775. });
  776. });
  777. },
  778. };
  779.  
  780. /**
  781. * 事件处理
  782. * @namespace
  783. */
  784. const EventHandlers = {
  785. /**
  786. * 粘贴事件处理
  787. * @param {ClipboardEvent} e - 粘贴事件
  788. */
  789. pasteHandler(e) {
  790. const editor = e.target;
  791. const file = FileUtils.getFileFromClipboard(e.clipboardData);
  792.  
  793. // 如果没有文件,不干预原有处理
  794. if (!file) return;
  795.  
  796. // 拦截事件,自己处理
  797. e.preventDefault();
  798. e.stopPropagation();
  799.  
  800. // 验证文件
  801. const validation = FileUtils.validateFile(file);
  802.  
  803. if (validation.valid) {
  804. // 文件有效,上传
  805. UploadService.uploadFile(file, editor);
  806. } else {
  807. // 文件无效,显示错误
  808. FileUtils.showFileError(file, validation.error);
  809. }
  810. },
  811.  
  812. /**
  813. * 拖放处理
  814. * @param {DragEvent} e - 拖放事件
  815. */
  816. dropHandler(e) {
  817. // 检查是否有文件被拖放
  818. if (e.dataTransfer?.files?.length > 0) {
  819. // 阻止默认行为
  820. e.preventDefault();
  821. e.stopPropagation();
  822.  
  823. // 查找编辑器元素
  824. const { editorInput: editor } = AppState.elements;
  825. if (editor) {
  826. // 处理所有拖放文件
  827. UploadService.processFiles(e.dataTransfer.files, editor);
  828. }
  829. }
  830. },
  831.  
  832. /**
  833. * 上传按钮点击事件
  834. * @param {MouseEvent} e - 点击事件
  835. */
  836. uploadButtonClickHandler(e) {
  837. e.preventDefault();
  838. e.stopPropagation();
  839.  
  840. // 创建文件选择器
  841. const fileInput = document.createElement('input');
  842. fileInput.type = 'file';
  843. fileInput.multiple = true;
  844. fileInput.accept = FileUtils.generateAcceptString();
  845.  
  846. // 文件选择处理
  847. fileInput.addEventListener('change', function () {
  848. if (this.files?.length > 0) {
  849. const { editorInput: editor } = AppState.elements;
  850. if (!editor) return;
  851.  
  852. // 处理所有选中的文件
  853. UploadService.processFiles(this.files, editor);
  854. }
  855. });
  856.  
  857. // 触发文件选择对话框
  858. fileInput.click();
  859. },
  860. };
  861.  
  862. /**
  863. * 初始化管理
  864. * @namespace
  865. */
  866. const Initializer = {
  867. /**
  868. * 查找并缓存DOM元素
  869. */
  870. findElements() {
  871. const replyControl = document.querySelector(CONFIG.SELECTORS.REPLY_CONTROL);
  872. if (!DOMUtils.isReplyControlOpen(replyControl)) return false;
  873.  
  874. const editorControls = replyControl.querySelector(CONFIG.SELECTORS.EDITOR_CONTROLS);
  875. if (!editorControls) return false;
  876.  
  877. // 一次性更新所有元素缓存
  878. Object.assign(AppState.elements, {
  879. replyControl,
  880. editorControls,
  881. editorInput: editorControls.querySelector(CONFIG.SELECTORS.EDITOR_INPUT),
  882. uploadButton: editorControls.querySelector(CONFIG.SELECTORS.UPLOAD_BUTTON),
  883. });
  884.  
  885. return !!AppState.elements.editorInput;
  886. },
  887.  
  888. /**
  889. * 设置事件处理器
  890. */
  891. setupEventHandlers() {
  892. const { editorInput: editor, editorControls, uploadButton } = AppState.elements;
  893.  
  894. if (!editor || !editorControls) return false;
  895.  
  896. // 设置粘贴处理
  897. editor.removeEventListener('paste', EventHandlers.pasteHandler);
  898. editor.addEventListener('paste', EventHandlers.pasteHandler, { capture: true });
  899.  
  900. // 设置拖放处理
  901. editorControls.removeEventListener('drop', EventHandlers.dropHandler, true);
  902. editorControls.addEventListener('drop', EventHandlers.dropHandler, { capture: true });
  903.  
  904. // 设置上传按钮(如果存在)
  905. if (uploadButton) {
  906. // 检查按钮是否隐藏
  907. const computedStyle = window.getComputedStyle(uploadButton);
  908. if (computedStyle.display === 'none' || uploadButton.style.display === 'none') {
  909. // 设置按钮为可见
  910. uploadButton.style.display = 'inline-flex';
  911.  
  912. // 清除现有的事件处理器
  913. const newBtn = uploadButton.cloneNode(true);
  914. uploadButton.parentNode.replaceChild(newBtn, uploadButton);
  915.  
  916. // 更新缓存引用
  917. AppState.elements.uploadButton = newBtn;
  918.  
  919. // 添加新的点击事件
  920. newBtn.addEventListener('click', EventHandlers.uploadButtonClickHandler);
  921.  
  922. Logger.log('上传按钮已设置为可见并添加事件监听器');
  923. }
  924. }
  925.  
  926. Logger.log('事件处理器设置完成');
  927. return true;
  928. },
  929.  
  930. /**
  931. * 初始化函数
  932. */
  933. init() {
  934. Logger.log('初始化小众软件论坛上传优化脚本...');
  935.  
  936. // 查找元素
  937. if (this.findElements()) {
  938. this.setupEventHandlers();
  939. }
  940.  
  941. // 观察编辑器的出现
  942. const observer = new MutationObserver((mutations) => {
  943. let needsUpdate = false;
  944.  
  945. for (const mutation of mutations) {
  946. if (
  947. mutation.type === 'attributes' &&
  948. mutation.attributeName === 'class' &&
  949. DOMUtils.isReplyControlOpen(mutation.target)
  950. ) {
  951. needsUpdate = true;
  952. break;
  953. }
  954. }
  955.  
  956. if (needsUpdate && this.findElements()) {
  957. this.setupEventHandlers();
  958. }
  959. });
  960.  
  961. // MutationObserver 配置
  962. const replyControl = document.querySelector('#reply-control');
  963. if (replyControl) {
  964. observer.observe(replyControl, {
  965. attributes: true,
  966. attributeFilter: ['class'],
  967. childList: false,
  968. subtree: false,
  969. });
  970. } else {
  971. // 如果尚未找到目标元素,监听body以等待其创建
  972. observer.observe(document.body, {
  973. childList: true,
  974. subtree: true,
  975. });
  976. }
  977.  
  978. Logger.log('初始化完成。');
  979. },
  980. };
  981.  
  982. // 启动脚本
  983. Initializer.init();
  984. })();