动漫花园树状显示

将动漫花园的文件列表转换为树状视图,支持搜索、智能展开等功能

  1. // ==UserScript==
  2. // @name 动漫花园树状显示
  3. // @name:zh-CN 动漫花园文件列表树状显示
  4. // @name:en DMHY Tree View
  5. // @namespace https://github.com/xkbkx5904/dmhy-tree-view
  6. // @version 0.5.3
  7. // @description 将动漫花园的文件列表转换为树状视图,支持搜索、智能展开等功能
  8. // @description:zh-CN 将动漫花园的文件列表转换为树状视图,支持搜索、智能展开等功能
  9. // @description:en Convert DMHY file list into a tree view with search and smart collapse features
  10. // @author xkbkx5904
  11. // @license GPL-3.0
  12. // @homepage https://github.com/xkbkx5904/dmhy-tree-view
  13. // @supportURL https://github.com/xkbkx5904/dmhy-tree-view/issues
  14. // @match *://share.dmhy.org/topics/view/*
  15. // @require https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.min.js
  16. // @require https://cdn.jsdelivr.net/npm/jstree@3.3.11/dist/jstree.min.js
  17. // @resource customCSS https://cdn.jsdelivr.net/npm/jstree@3.3.11/dist/themes/default/style.min.css
  18. // @icon https://share.dmhy.org/favicon.ico
  19. // @grant GM_addStyle
  20. // @grant GM_getResourceText
  21. // @run-at document-end
  22. // @originalAuthor TautCony
  23. // @originalURL https://gf.qytechs.cn/zh-CN/scripts/26430-dmhy-tree-view
  24. // ==/UserScript==
  25.  
  26. /* 更新日志
  27. * v0.5.3
  28. * - 刚刚更新了日志但是代码复制了旧版本的,幽默了一下,更新版本号从新推送一下代码
  29. *
  30. * v0.5.2
  31. * - 修复了当文件无法提取到文件名时,文件大小被识别为文件名的问题
  32. * - 修复了智能展开模式下,单层级目录没有自动打开的问题
  33. *
  34. * v0.5.1
  35. * - 修复智能模式下单层目录展开/折叠的问题
  36. *
  37. * v0.5.0
  38. * - 添加文件名和大小排序功能
  39. * - 优化搜索性能
  40. * - 修复搜索和排序功能的冲突问题
  41. * - 添加动漫花园网站图标
  42. *
  43. * v0.4.0
  44. * - 初始版本发布
  45. * - 实现树状显示功能
  46. * - 添加搜索功能
  47. * - 添加智能展开功能
  48. */
  49. // 文件类型图标映射
  50. const ICONS = {
  51. audio: "/images/icon/mp3.gif",
  52. bmp: "/images/icon/bmp.gif",
  53. image: "/images/icon/jpg.gif",
  54. png: "/images/icon/png.gif",
  55. rar: "/images/icon/rar.gif",
  56. text: "/images/icon/txt.gif",
  57. unknown: "/images/icon/unknown.gif",
  58. video: "/images/icon/mp4.gif"
  59. };
  60. // 文件扩展名分类
  61. const FILE_TYPES = {
  62. audio: ["flac", "aac", "wav", "mp3", "m4a", "mka"],
  63. bmp: ["bmp"],
  64. image: ["jpg", "jpeg", "webp"],
  65. png: ["png", "gif"],
  66. rar: ["rar", "zip", "7z", "tar", "gz"],
  67. text: ["txt", "log", "cue", "ass", "ssa", "srt", "doc", "docx", "xls", "xlsx", "pdf"],
  68. video: ["mkv", "mp4", "avi", "wmv", "flv", "m2ts"]
  69. };
  70. // 设置样式
  71. const setupCSS = () => {
  72. GM_addStyle(GM_getResourceText("customCSS"));
  73. GM_addStyle(`
  74. .jstree-node, .jstree-default .jstree-icon {
  75. background-image: url(https://cdn.jsdelivr.net/npm/jstree@3.3.11/dist/themes/default/32px.png);
  76. }
  77. .tree-container {
  78. background: #fff;
  79. border: 2px solid;
  80. border-color: #404040 #dfdfdf #dfdfdf #404040;
  81. padding: 5px;
  82. }
  83. .control-panel {
  84. background: #f0f0f0;
  85. border-bottom: 1px solid #ccc;
  86. padding: 5px;
  87. display: flex;
  88. align-items: center;
  89. gap: 10px;
  90. }
  91. .control-panel-left {
  92. display: flex;
  93. align-items: center;
  94. gap: 10px;
  95. }
  96. .control-panel-right {
  97. margin-left: auto;
  98. display: flex;
  99. align-items: center;
  100. }
  101. #search_input {
  102. border: 1px solid #ccc;
  103. padding: 2px 5px;
  104. width: 200px;
  105. }
  106. #switch {
  107. padding: 2px 5px;
  108. cursor: pointer;
  109. }
  110. #file_tree {
  111. padding: 5px;
  112. max-height: 600px;
  113. overflow: auto;
  114. }
  115. .filesize {
  116. padding-left: 8px;
  117. color: #666;
  118. }
  119. .smart-toggle {
  120. display: flex;
  121. align-items: center;
  122. gap: 4px;
  123. cursor: pointer;
  124. user-select: none;
  125. }
  126. .smart-toggle input {
  127. margin: 0;
  128. }
  129. .sort-controls {
  130. display: flex;
  131. align-items: center;
  132. gap: 5px;
  133. }
  134. .sort-btn {
  135. padding: 2px 8px;
  136. cursor: pointer;
  137. border: 1px solid #ccc;
  138. background: #f8f8f8;
  139. display: flex;
  140. align-items: center;
  141. gap: 4px;
  142. }
  143. .sort-btn.active {
  144. background: #e0e0e0;
  145. }
  146. .sort-direction {
  147. display: inline-block;
  148. width: 12px;
  149. }
  150. `);
  151. };
  152. // 树节点基础类
  153. class TreeNode {
  154. constructor(name) {
  155. this.name = name;
  156. this.length = 0;
  157. this.childNode = new Map();
  158. this._cache = new Map();
  159. }
  160. // 插入节点
  161. insert(path, size) {
  162. let currentNode = this;
  163. for (const node of path) {
  164. if (!currentNode.childNode.has(node)) {
  165. currentNode.childNode.set(node, new TreeNode(node));
  166. }
  167. currentNode = currentNode.childNode.get(node);
  168. }
  169. currentNode.length = this.toLength(size);
  170. return currentNode;
  171. }
  172. // 转换为显示文本
  173. toString() {
  174. const size = this.childNode.size > 0 ? this.calculateTotalSize() : this.length;
  175. return `<span class="filename">${this.name}</span><span class="filesize">${this.toSize(size)}</span>`;
  176. }
  177. // 计算总大小
  178. calculateTotalSize() {
  179. if (this._cache.has('totalSize')) return this._cache.get('totalSize');
  180. let total = this.length;
  181. for (const node of this.childNode.values()) {
  182. total += node.childNode.size === 0 ? node.length : node.calculateTotalSize();
  183. }
  184. this._cache.set('totalSize', total);
  185. return total;
  186. }
  187. // 转换为jstree对象
  188. toObject() {
  189. if (this._cache.has('object')) return this._cache.get('object');
  190. const ret = {
  191. text: this.toString(),
  192. children: [],
  193. state: { opened: false }
  194. };
  195. // 分别处理文件夹和文件
  196. const folders = [];
  197. const files = [];
  198. for (const [, value] of this.childNode) {
  199. if (value.childNode.size === 0) {
  200. files.push({
  201. icon: value.icon,
  202. length: value.length,
  203. text: value.toString()
  204. });
  205. } else {
  206. const inner = value.toObject();
  207. folders.push({
  208. ...inner,
  209. text: `<span class="filename">${value.name}</span><span class="filesize">${this.toSize(value.calculateTotalSize())}</span>`,
  210. state: { opened: false }
  211. });
  212. }
  213. }
  214. ret.children = [...folders, ...files];
  215. this._cache.set('object', ret);
  216. return ret;
  217. }
  218. // 获取文件扩展名
  219. get ext() {
  220. if (this._ext !== undefined) return this._ext;
  221. const dotIndex = this.name.lastIndexOf(".");
  222. this._ext = dotIndex > 0 ? this.name.substr(dotIndex + 1).toLowerCase() : "";
  223. return this._ext;
  224. }
  225. // 获取文件图标
  226. get icon() {
  227. if (this._icon !== undefined) return this._icon;
  228. this._icon = ICONS.unknown;
  229. for (const [type, extensions] of Object.entries(FILE_TYPES)) {
  230. if (extensions.includes(this.ext)) {
  231. this._icon = ICONS[type];
  232. break;
  233. }
  234. }
  235. return this._icon;
  236. }
  237. // 转换文件大小字符串为字节数
  238. toLength(size) {
  239. if (!size) return -1;
  240. const match = size.toLowerCase().match(/^([\d.]+)\s*([kmgt]?b(?:ytes)?)$/);
  241. if (!match) return -1;
  242. const [, value, unit] = match;
  243. const factors = { b: 0, bytes: 0, kb: 10, mb: 20, gb: 30, tb: 40 };
  244. return parseFloat(value) * Math.pow(2, factors[unit] || 0);
  245. }
  246. // 转换字节数为可读大小
  247. toSize(length) {
  248. if (length < 0) return "";
  249. const units = [[40, "TiB"], [30, "GiB"], [20, "MiB"], [10, "KiB"], [0, "Bytes"]];
  250. for (const [factor, unit] of units) {
  251. if (length >= Math.pow(2, factor)) {
  252. return (length / Math.pow(2, factor)).toFixed(unit === "Bytes" ? 0 : 3) + unit;
  253. }
  254. }
  255. return "0 Bytes";
  256. }
  257. }
  258. // 查找树中第一个分叉节点
  259. function findFirstForkNode(tree) {
  260. const findForkInNode = (nodeId) => {
  261. const node = tree.get_node(nodeId);
  262. if (!node || !node.children) return null;
  263. if (node.children.length > 1) return node;
  264. if (node.children.length === 1) return findForkInNode(node.children[0]);
  265. return null;
  266. };
  267. return findForkInNode('#');
  268. }
  269. // 获取到指定节点的路径
  270. function getPathToNode(tree, targetNode) {
  271. const path = [];
  272. let currentNode = targetNode;
  273. while (currentNode.id !== '#') {
  274. path.unshift(currentNode.id);
  275. currentNode = tree.get_node(tree.get_parent(currentNode));
  276. }
  277. return path;
  278. }
  279. // 获取第一个分叉点及其路径信息
  280. function getFirstForkInfo(tree) {
  281. const firstFork = findFirstForkNode(tree);
  282. if (!firstFork) return null;
  283. const pathToFork = getPathToNode(tree, firstFork);
  284. const protectedNodes = new Set(pathToFork);
  285. protectedNodes.add(firstFork.id);
  286. return {
  287. fork: firstFork,
  288. pathToFork,
  289. protectedNodes
  290. };
  291. }
  292. // 智能折叠:只折叠分叉点以下的节点
  293. function smartCollapse(tree, treeDepth) {
  294. // 如果是单层目录,直接使用普通折叠
  295. if (treeDepth <= 1) {
  296. tree.close_all();
  297. return;
  298. }
  299. const forkInfo = getFirstForkInfo(tree);
  300. if (!forkInfo) return;
  301.  
  302. // 获取所有打开的节点
  303. const openNodes = tree.get_json('#', { flat: true })
  304. .filter(node => tree.is_open(node.id))
  305. .map(node => node.id);
  306. // 只折叠不在保护名单中的节点
  307. openNodes.forEach(nodeId => {
  308. if (!forkInfo.protectedNodes.has(nodeId)) {
  309. tree.close_node(nodeId);
  310. }
  311. });
  312. }
  313. // 检查文件树的最大层级
  314. function checkTreeDepth(tree) {
  315. const getNodeDepth = (nodeId, currentDepth = 0) => {
  316. const node = tree.get_node(nodeId);
  317. // 如果节点不存在,或者是文件节点(没有子节点),返回当前深度
  318. if (!node || !node.children || node.children.length === 0) {
  319. return currentDepth - 1; // 文件节点不计入深度
  320. }
  321. return Math.max(...node.children.map(childId =>
  322. getNodeDepth(childId, currentDepth + 1)
  323. ));
  324. };
  325. return Math.max(0, getNodeDepth('#'));
  326. }
  327. // 主程序入口
  328. (() => {
  329. // 设置样式
  330. setupCSS();
  331. // 创建树数据
  332. const data = new TreeNode($(".topic-title > h3").text());
  333. const pattern = /^(.+?) (\d+(?:\.\d+)?[TGMK]?B(?:ytes)?)$/;
  334. // 解析文件列表
  335. let unnamedCounter = 1; // 添加计数器用于区分未命名文件
  336.  
  337. $(".file_list:first > ul li").each(function() {
  338. const text = $(this).text().trim();
  339. const line = text.replace(/\t+/i, "\t").split("\t");
  340. if (line.length === 2) {
  341. // 标准格式:文件名和大小被制表符分隔
  342. data.insert(line[0].split("/"), line[1]);
  343. } else if (line.length === 1) {
  344. // 尝试解析可能的文件名和大小格式
  345. const match = pattern.exec(text);
  346. if (match) {
  347. // 成功匹配到文件名和大小
  348. data.insert(match[1].split("/"), match[2]);
  349. } else {
  350. // 检查是否只是一个文件大小
  351. const sizeMatch = /^\d+(?:\.\d+)?[TGMK]?B(?:ytes)?$/.test(text);
  352. if (sizeMatch) {
  353. // 如果只是文件大小,使用带编号的占位符作为文件名
  354. data.insert([`unknown (${unnamedCounter++})`], text);
  355. } else {
  356. // 如果不是文件大小,则视为纯文件名
  357. data.insert(text.split("/"), "");
  358. }
  359. }
  360. }
  361. });
  362. // 创建UI
  363. const fragment = document.createDocumentFragment();
  364. const treeContainer = $('<div class="tree-container"></div>').appendTo(fragment);
  365. const controlPanel = $('<div class="control-panel"></div>')
  366. .append($('<div class="control-panel-left"></div>')
  367. .append('<input type="text" id="search_input" placeholder="搜索文件..." />')
  368. .append('<button id="switch">展开全部</button>')
  369. .append($('<div class="sort-controls"></div>')
  370. .append('<button class="sort-btn" data-sort="name">名称<span class="sort-direction">↑</span></button>')
  371. .append('<button class="sort-btn" data-sort="size">大小<span class="sort-direction">↓</span></button>')
  372. )
  373. )
  374. .append($('<div class="control-panel-right"></div>')
  375. .append('<label class="smart-toggle"><input type="checkbox" id="smart_mode" />智能展开</label>')
  376. )
  377. .appendTo(treeContainer);
  378. const fileTree = $('<div id="file_tree"></div>').appendTo(treeContainer);
  379. $('.file_list:first').replaceWith(fragment);
  380. // 创建树实例
  381. const treeInstance = fileTree.jstree({
  382. core: {
  383. data: data.toObject(),
  384. themes: { variant: "large" }
  385. },
  386. plugins: ["search", "wholerow", "contextmenu"],
  387. contextmenu: {
  388. select_node: false,
  389. show_at_node: false,
  390. items: {
  391. getText: {
  392. label: "复制",
  393. action: selected => {
  394. const text = selected.reference.find(".filename").text();
  395. navigator.clipboard.writeText(text);
  396. }
  397. }
  398. }
  399. }
  400. });
  401. // 绑定事件
  402. treeInstance.on("ready.jstree", function() {
  403. const tree = treeInstance.jstree(true);
  404. const isSmartMode = localStorage.getItem('dmhy_smart_mode') !== 'false';
  405. if (isSmartMode) {
  406. const treeDepth = checkTreeDepth(tree);
  407. if (treeDepth > 1) {
  408. // 多层目录时执行智能展开
  409. const firstFork = findFirstForkNode(tree);
  410. if (firstFork) {
  411. const pathToFork = getPathToNode(tree, firstFork);
  412. pathToFork.forEach(nodeId => tree.open_node(nodeId));
  413. }
  414. } else {
  415. // 单层目录时全部展开
  416. tree.open_all();
  417. }
  418. }
  419. });
  420. treeInstance.on("loaded.jstree", function() {
  421. const tree = treeInstance.jstree(true);
  422. let isExpanded = false;
  423. let isSmartMode = localStorage.getItem('dmhy_smart_mode') !== 'false';
  424. let previousState = null;
  425. let hasSearched = false;
  426. let searchTimeout = null;
  427. let treeNodes = null;
  428.  
  429. // 1. 更新展开/折叠按钮状态的函数
  430. const updateSwitchButton = () => {
  431. $("#switch").text(isExpanded ? "折叠全部" : "展开全部");
  432. };
  433.  
  434. // 2. 绑定展开/折叠按钮事件
  435. $("#switch").click(function() {
  436. isExpanded = !isExpanded;
  437. const treeDepth = checkTreeDepth(tree);
  438. if (isSmartMode) {
  439. if (isExpanded) {
  440. tree.open_all();
  441. } else {
  442. if (treeDepth > 1) {
  443. // 多层目录时使用智能折叠
  444. smartCollapse(tree, treeDepth);
  445. } else {
  446. // 单层目录时全部折叠
  447. tree.close_all();
  448. }
  449. }
  450. } else {
  451. if (isExpanded) {
  452. tree.open_all();
  453. } else {
  454. tree.close_all();
  455. }
  456. }
  457. updateSwitchButton();
  458. });
  459.  
  460. // 3. 绑定智能模式切换事件
  461. $("#smart_mode").prop('checked', isSmartMode).change(function() {
  462. isSmartMode = this.checked;
  463. localStorage.setItem('dmhy_smart_mode', isSmartMode);
  464. isExpanded = false;
  465. localStorage.setItem('dmhy_tree_expanded', isExpanded);
  466. if (isSmartMode) {
  467. tree.close_all();
  468. const firstFork = findFirstForkNode(tree);
  469. if (firstFork) {
  470. const pathToFork = getPathToNode(tree, firstFork);
  471. pathToFork.forEach(nodeId => tree.open_node(nodeId));
  472. }
  473. } else {
  474. tree.close_all();
  475. }
  476. updateSwitchButton();
  477. });
  478.  
  479. // 4. 初始化排序
  480. const rootNode = tree.get_node('#');
  481. $('.sort-btn[data-sort="name"]').addClass('active').find('.sort-direction').text('↑');
  482.  
  483. const sortNodes = (node, sortType, isAsc) => {
  484. if (node.children && node.children.length) {
  485. node.children.sort((a, b) => {
  486. const nodeA = tree.get_node(a);
  487. const nodeB = tree.get_node(b);
  488. // 文件夹始终排在前面
  489. const isAFolder = nodeA.children.length > 0;
  490. const isBFolder = nodeB.children.length > 0;
  491. if (isAFolder !== isBFolder) {
  492. return isAFolder ? -1 : 1;
  493. }
  494.  
  495. let result = 0;
  496. if (sortType === 'size') {
  497. const sizeA = parseFloat(nodeA.text.match(/[\d.]+(?=[TGMK]iB|Bytes)/)) || 0;
  498. const sizeB = parseFloat(nodeB.text.match(/[\d.]+(?=[TGMK]iB|Bytes)/)) || 0;
  499. const unitA = nodeA.text.match(/[TGMK]iB|Bytes/)?.[0] || '';
  500. const unitB = nodeB.text.match(/[TGMK]iB|Bytes/)?.[0] || '';
  501. const units = { 'TiB': 4, 'GiB': 3, 'MiB': 2, 'KiB': 1, 'Bytes': 0 };
  502. const unitCompare = (units[unitA] || 0) - (units[unitB] || 0);
  503. result = unitCompare !== 0 ? unitCompare : sizeA - sizeB;
  504. } else {
  505. const nameA = nodeA.text.match(/class="filename">([^<]+)/)?.[1] || '';
  506. const nameB = nodeB.text.match(/class="filename">([^<]+)/)?.[1] || '';
  507. result = nameA.localeCompare(nameB, undefined, { numeric: true });
  508. }
  509. return isAsc ? result : -result;
  510. });
  511.  
  512. node.children.forEach(childId => {
  513. sortNodes(tree.get_node(childId), sortType, isAsc);
  514. });
  515. }
  516. };
  517.  
  518. // 执行初始排序(按文件名升序)
  519. sortNodes(rootNode, 'name', true);
  520. tree.redraw(true);
  521.  
  522. // 绑定排序按钮事件
  523. $('.sort-btn').on('click', function() {
  524. const $this = $(this);
  525. const $direction = $this.find('.sort-direction');
  526. const sortType = $this.data('sort');
  527. if ($this.hasClass('active')) {
  528. $direction.text($direction.text() === '↑' ? '↓' : '↑');
  529. } else {
  530. $('.sort-btn').removeClass('active').find('.sort-direction').text('↓');
  531. $this.addClass('active');
  532. }
  533. const isAsc = $direction.text() === '↑';
  534. sortNodes(rootNode, sortType, isAsc);
  535. tree.redraw(true);
  536. });
  537.  
  538. // 5. 初始化搜索功能
  539. treeNodes = tree.get_json('#', { flat: true }); // 缓存已排序的节点
  540. const searchDebounceTime = 250;
  541.  
  542. $('#search_input').keyup(function() {
  543. if (searchTimeout) {
  544. clearTimeout(searchTimeout);
  545. }
  546. searchTimeout = setTimeout(() => {
  547. const searchText = $(this).val().toLowerCase();
  548. if (searchText) {
  549. if (!hasSearched) {
  550. previousState = {
  551. isExpanded,
  552. openNodes: treeNodes.filter(node => tree.is_open(node.id))
  553. .map(node => node.id)
  554. };
  555. hasSearched = true;
  556. }
  557. const matchedNodes = new Set();
  558. treeNodes.forEach(node => {
  559. const nodeText = tree.get_text(node.id).toLowerCase();
  560. if (nodeText.includes(searchText)) {
  561. matchedNodes.add(node.id);
  562. let parent = tree.get_parent(node.id);
  563. while (parent !== '#') {
  564. matchedNodes.add(parent);
  565. parent = tree.get_parent(parent);
  566. }
  567. }
  568. });
  569.  
  570. const operations = [];
  571. treeNodes.forEach(node => {
  572. if (matchedNodes.has(node.id)) {
  573. operations.push(() => {
  574. tree.show_node(node.id);
  575. tree.open_node(node.id);
  576. });
  577. } else {
  578. operations.push(() => tree.hide_node(node.id));
  579. }
  580. });
  581.  
  582. const batchSize = 50;
  583. const executeBatch = (startIndex) => {
  584. const endIndex = Math.min(startIndex + batchSize, operations.length);
  585. for (let i = startIndex; i < endIndex; i++) {
  586. operations[i]();
  587. }
  588. if (endIndex < operations.length) {
  589. requestAnimationFrame(() => executeBatch(endIndex));
  590. }
  591. };
  592. executeBatch(0);
  593. isExpanded = true;
  594. } else {
  595. if (previousState) {
  596. tree.show_all();
  597. tree.close_all();
  598. const restoreNodes = () => {
  599. const batch = previousState.openNodes.splice(0, 50);
  600. batch.forEach(nodeId => tree.open_node(nodeId, false));
  601. if (previousState.openNodes.length > 0) {
  602. requestAnimationFrame(restoreNodes);
  603. }
  604. };
  605. restoreNodes();
  606. isExpanded = previousState.isExpanded;
  607. previousState = null;
  608. hasSearched = false;
  609. }
  610. }
  611. updateSwitchButton();
  612. }, searchDebounceTime);
  613. });
  614. });
  615. })();

QingJ © 2025

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