CC98-TAGs

为CC98用户添加可持久化的多标签功能

  1. // ==UserScript==
  2. // @name CC98-TAGs
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.0
  5. // @description 为CC98用户添加可持久化的多标签功能
  6. // @license MIT
  7. // @author 萌萌人
  8. // @match http://www-cc98-org-s.webvpn.zju.edu.cn:8001/*
  9. // @match https://www.cc98.org/*
  10. // @grant GM_registerMenuCommand
  11. // @grant GM_setValue
  12. // @grant GM_getValue
  13. // @grant GM_listValues
  14. // @grant GM_deleteValue
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. 'use strict';
  19.  
  20. // 自定义样式
  21. const css = `
  22. .user-tags-container {
  23. margin-top: 8px;
  24. display: flex;
  25. flex-wrap: wrap;
  26. gap: 4px;
  27. width: 100%; /* 让容器宽度跟随父容器 */
  28. }
  29. .user-tag {
  30. display: inline-flex;
  31. align-items: center;
  32. padding: 2px 6px; /* 上下左右的内边距 */
  33. border-radius: 4px;
  34. font-size: 0.75rem;
  35. color: white;
  36. cursor: pointer;
  37. position: relative;
  38. max-width: 90%; /* 限制最大宽度 */
  39. overflow: hidden; /* 超出部分隐藏 */
  40. text-overflow: ellipsis; /* 超出部分显示省略号 */
  41. white-space: normal; /* 允许换行 */
  42. flex-shrink: 0; /* 允许缩小 */
  43. box-sizing: border-box; /* 确保 padding 不影响宽度计算 */
  44. word-wrap: break-word; /* 允许长单词换行 */
  45. overflow-wrap: break-word; /* 确保长单词和字符串在必要时换行 */
  46. }
  47. .user-tag:hover::after {
  48. content: '×';
  49. margin-left: 4px;
  50. font-size: 0.9em;
  51. }
  52. .add-tag-btn {
  53. background: #ddd;
  54. color: #666;
  55. border: 1px dashed #999;
  56. cursor: pointer;
  57. max-width: 100%; /* 限制最大宽度 */
  58. overflow: hidden; /* 超出部分隐藏 */
  59. text-overflow: ellipsis; /* 超出部分显示省略号 */
  60. white-space: nowrap; /* 不换行 */
  61. }
  62. .add-tag-btn:hover {
  63. background: #eee;
  64. }
  65. .import-export-menu {
  66. position: relative; /* 使用绝对定位 */
  67. right: 0; /* 对齐到页面最右边 */
  68. top: 55%; /* 垂直居中 */
  69. transform: translateY(-50%); /* 通过 transform 微调垂直居中 */
  70. display: inline-block;
  71. margin-left: 10px;
  72. padding-bottom: 5px; /* 增加底部 padding,扩展悬停区域 */
  73. }
  74.  
  75. .import-export-menu button {
  76. background: none;
  77. border: none;
  78. color: white; /* 文字颜色为白色 */
  79. cursor: pointer;
  80. font-size: 16px;
  81. display: flex; /* 使用 flexbox 布局 */
  82. align-items: center; /* 垂直居中 */
  83. justify-content: center; /* 水平居中 */
  84. height: 100%; /* 确保按钮高度与父容器一致 */
  85. }
  86.  
  87. .import-export-menu button:hover {
  88. color: #ccc; /* 鼠标悬停时文字颜色变为浅灰色 */
  89. }
  90.  
  91. .import-export-dropdown {
  92. display: none;
  93. position: absolute;
  94. background-color: #f9f9f9;
  95. width: 100%; /* 宽度与按钮对齐 */
  96. min-width: 80pt;
  97. box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
  98. z-index: 1;
  99. right: 0; /* 下拉菜单也对齐到最右边 */
  100. top: 100%; /* 下拉菜单在按钮下方 */
  101. margin-top: 0;
  102. text-align: center; /* 下拉菜单文字居中 */
  103. }
  104.  
  105. .import-export-dropdown a {
  106. color: black;
  107. padding: 8px 16px; /* 调整下拉菜单项的内边距 */
  108. text-decoration: none;
  109. display: block;
  110. text-align: center; /* 下拉菜单文字居中 */
  111. }
  112.  
  113. .import-export-dropdown a:hover {
  114. background-color: #f1f1f1;
  115. }
  116.  
  117. .import-export-menu:hover .import-export-dropdown {
  118. display: block;
  119. }
  120. `;
  121. const style = document.createElement('style');
  122. style.textContent = css;
  123. document.head.appendChild(style);
  124.  
  125. // 获取UID
  126. const getUidFromLink = (link) => {
  127. const match = link.href.match(/\/user\/id\/(\d+)/);
  128. return match ? match[1] : null;
  129. };
  130.  
  131. // 固定颜色数组
  132. const colors = ['#FF6B6B', '#4ECDC4', '#45B7D5', '#54C6EB', '#6BFF6B', '#FFD166', '#A06CD5'];
  133.  
  134. // 根据UID获取颜色
  135. const getColorByUid = (uid, index) => {
  136. const uidNumber = parseInt(uid, 10); // 将UID转换为数字
  137. const colorIndex = (uidNumber + index) % colors.length; // 对颜色数组长度取模
  138. return colors[colorIndex];
  139. };
  140.  
  141. // 创建标签
  142. const createTag = (uid, tag, index) => {
  143. const tagElem = document.createElement('div');
  144. tagElem.className = 'user-tag';
  145. tagElem.textContent = tag;
  146. tagElem.style.backgroundColor = getColorByUid(uid, index); // 使用UID确定颜色
  147. tagElem.onclick = () => {
  148. if (confirm(`确定要删除标签 "${tag}" 吗?`)) {
  149. const tags = GM_getValue(`tags_${uid}`, []);
  150. tags.splice(index, 1);
  151. GM_setValue(`tags_${uid}`, tags);
  152. updateTags(uid);
  153. }
  154. };
  155. return tagElem;
  156. };
  157.  
  158. // 创建添加标签按钮
  159. const createAddButton = (uid) => {
  160. const btn = document.createElement('div');
  161. btn.className = 'user-tag add-tag-btn';
  162. btn.textContent = '+';
  163. btn.style.backgroundColor = '#FFFFFF'; // 使用UID确定颜色
  164. btn.onclick = () => {
  165. const tag = prompt('请输入新标签:');
  166. if (tag && tag.trim()) {
  167. const tags = GM_getValue(`tags_${uid}`, []);
  168. tags.push(tag.trim());
  169. GM_setValue(`tags_${uid}`, tags);
  170. updateTags(uid);
  171. }
  172. };
  173. return btn;
  174. };
  175.  
  176. // 更新标签显示
  177. const updateTags = (uid) => {
  178. const containers = document.querySelectorAll(`.user-tags-container[data-uid="${uid}"]`);
  179. containers.forEach(container => {
  180. container.innerHTML = '';
  181. const tags = GM_getValue(`tags_${uid}`, []);
  182. tags.forEach((tag, index) => {
  183. container.appendChild(createTag(uid, tag, index));
  184. });
  185. container.appendChild(createAddButton(uid));
  186.  
  187. // 动态调整标签宽度
  188. const userMessage = container.closest('.userMessage');
  189. if (userMessage) {
  190. const userMessageWidth = userMessage.offsetWidth * 0.75;
  191. container.style.width = `${userMessageWidth}px`; // 设置标签容器宽度
  192. }
  193. });
  194. };
  195.  
  196. // 初始化标签容器
  197. const initTagsContainer = (uid, postId) => {
  198. const container = document.createElement('div');
  199. container.className = 'user-tags-container';
  200. container.setAttribute('data-uid', uid);
  201. container.setAttribute('data-post-id', postId); // 添加帖子ID作为唯一标识
  202. return container;
  203. };
  204.  
  205. // 检查是否需要刷新标签
  206. const shouldUpdateTags = () => {
  207. const currentUrl = window.location.href;
  208. // 检查URL是否以 /topic/数字 结尾
  209. return /\/topic\/\d+(\/.+)?$/.test(currentUrl);
  210. };
  211.  
  212. // 主更新函数
  213. const updateAllTags = () => {
  214. if (!shouldUpdateTags()) return; // 如果不需要刷新标签,则直接返回
  215.  
  216. document.querySelectorAll('.userMessage-left').forEach(container => {
  217. const link = container.querySelector('a[href*="/user/id/"]');
  218. if (!link) return;
  219.  
  220. const uid = getUidFromLink(link);
  221. const postId = container.closest('.reply').id; // 获取当前楼层的ID
  222. const existingContainer = container.querySelector(`.user-tags-container[data-post-id="${postId}"]`);
  223. if (existingContainer) existingContainer.remove();
  224.  
  225. const tagsContainer = initTagsContainer(uid, postId);
  226. const infoContainer = container.querySelector('.column[style*="padding-left: 1.5rem"]');
  227. if (infoContainer) {
  228. infoContainer.appendChild(tagsContainer);
  229. updateTags(uid);
  230. }
  231. });
  232. };
  233.  
  234. // 导出标签数据(明文或Base64编码)
  235. const exportTags = (encode = false, copyToClipboard = false) => {
  236. const allTags = {};
  237. const allKeys = GM_listValues();
  238. allKeys.forEach(key => {
  239. if (key.startsWith('tags_')) {
  240. const uid = key.replace('tags_', '');
  241. allTags[uid] = GM_getValue(key, []);
  242. }
  243. });
  244. const jsonData = JSON.stringify(allTags, null, 2);
  245. const outputData = encode ? btoa(unescape(encodeURIComponent(jsonData))) : jsonData;
  246.  
  247. if (copyToClipboard) {
  248. // 创建一个文本框用于显示导出的数据
  249. const textarea = document.createElement('textarea');
  250. textarea.style.position = 'fixed';
  251. textarea.style.top = '0';
  252. textarea.style.left = '0';
  253. textarea.style.width = '100%';
  254. textarea.style.height = '200px';
  255. textarea.style.zIndex = '10000';
  256. textarea.style.backgroundColor = '#fff';
  257. textarea.style.border = '1px solid #ccc';
  258. textarea.style.padding = '10px';
  259. textarea.style.boxSizing = 'border-box';
  260. textarea.style.fontFamily = 'monospace';
  261. textarea.style.fontSize = '14px';
  262. textarea.value = outputData;
  263.  
  264. // 添加一个关闭按钮
  265. const closeButton = document.createElement('button');
  266. closeButton.textContent = '关闭';
  267. closeButton.style.position = 'fixed';
  268. closeButton.style.top = '210px';
  269. closeButton.style.left = '50%';
  270. closeButton.style.zIndex = '10001';
  271. closeButton.style.backgroundColor = '#f44336';
  272. closeButton.style.color = '#fff';
  273. closeButton.style.border = 'none';
  274. closeButton.style.padding = '5px 10px';
  275. closeButton.style.cursor = 'pointer';
  276. closeButton.onclick = () => {
  277. document.body.removeChild(textarea);
  278. document.body.removeChild(closeButton);
  279. };
  280.  
  281. // 将文本框和按钮添加到页面
  282. document.body.appendChild(textarea);
  283. document.body.appendChild(closeButton);
  284.  
  285. // 自动选中文本框内容
  286. textarea.select();
  287. } else {
  288. const blob = new Blob([outputData], { type: 'text/plain' });
  289. const url = URL.createObjectURL(blob);
  290. const a = document.createElement('a');
  291. a.href = url;
  292. a.download = encode ? 'cc98_tags_export_base64.txt' : 'cc98_tags_export.json';
  293. a.click();
  294. URL.revokeObjectURL(url);
  295. }
  296. };
  297.  
  298. // 导入标签数据(明文或Base64编码)
  299. const importTags = (encoded = false, useTextInput = false) => {
  300. if (useTextInput) {
  301. // 创建一个大文本框
  302. const textarea = document.createElement('textarea');
  303. textarea.style.position = 'fixed';
  304. textarea.style.top = '0';
  305. textarea.style.left = '0';
  306. textarea.style.width = '100%';
  307. textarea.style.height = '200px';
  308. textarea.style.zIndex = '10000';
  309. textarea.style.backgroundColor = '#fff';
  310. textarea.style.border = '1px solid #ccc';
  311. textarea.style.padding = '10px';
  312. textarea.style.boxSizing = 'border-box';
  313. textarea.style.fontFamily = 'monospace';
  314. textarea.style.fontSize = '14px';
  315. textarea.placeholder = '请在此粘贴要导入的标签数据...';
  316.  
  317. // 创建导入按钮
  318. const importButton = document.createElement('button');
  319. importButton.textContent = '导入';
  320. importButton.style.position = 'fixed';
  321. importButton.style.top = '210px';
  322. importButton.style.left = '50%';
  323. importButton.style.transform = 'translateX(-100%)'; // 向左偏移 50% 的宽度
  324. importButton.style.zIndex = '10001';
  325. importButton.style.backgroundColor = '#4CAF50';
  326. importButton.style.color = '#fff';
  327. importButton.style.border = 'none';
  328. importButton.style.padding = '5px 10px';
  329. importButton.style.cursor = 'pointer';
  330. importButton.onclick = () => {
  331. const jsonData = textarea.value.trim();
  332. if (!jsonData) {
  333. alert('请输入要导入的数据!');
  334. return;
  335. }
  336.  
  337. try {
  338. let parsedData = jsonData;
  339. if (encoded) {
  340. parsedData = decodeURIComponent(escape(atob(jsonData)));
  341. }
  342. const tagsData = JSON.parse(parsedData);
  343. for (const uid in tagsData) {
  344. if (tagsData.hasOwnProperty(uid)) {
  345. GM_setValue(`tags_${uid}`, tagsData[uid]);
  346. }
  347. }
  348. alert('标签导入成功!');
  349. updateAllTags();
  350. document.body.removeChild(textarea);
  351. document.body.removeChild(importButton);
  352. document.body.removeChild(closeButton);
  353. } catch (error) {
  354. alert('导入失败:数据格式不正确!');
  355. }
  356. };
  357.  
  358. // 创建关闭按钮
  359. const closeButton = document.createElement('button');
  360. closeButton.textContent = '关闭';
  361. closeButton.style.position = 'fixed';
  362. closeButton.style.top = '210px';
  363. closeButton.style.left = '50%';
  364. closeButton.style.zIndex = '10001';
  365. closeButton.style.backgroundColor = '#f44336';
  366. closeButton.style.color = '#fff';
  367. closeButton.style.border = 'none';
  368. closeButton.style.padding = '5px 10px';
  369. closeButton.style.cursor = 'pointer';
  370. closeButton.onclick = () => {
  371. document.body.removeChild(textarea);
  372. document.body.removeChild(importButton);
  373. document.body.removeChild(closeButton);
  374. };
  375.  
  376. // 将文本框和按钮添加到页面
  377. document.body.appendChild(textarea);
  378. document.body.appendChild(importButton);
  379. document.body.appendChild(closeButton);
  380.  
  381. // 自动聚焦文本框
  382. textarea.focus();
  383. } else {
  384. const input = document.createElement('input');
  385. input.type = 'file';
  386. input.accept = '.txt,.json';
  387. input.onchange = (event) => {
  388. const file = event.target.files[0];
  389. if (!file) return;
  390.  
  391. const reader = new FileReader();
  392. reader.onload = (e) => {
  393. try {
  394. let jsonData = e.target.result;
  395. if (encoded) {
  396. jsonData = decodeURIComponent(escape(atob(jsonData)));
  397. }
  398. const tagsData = JSON.parse(jsonData);
  399. for (const uid in tagsData) {
  400. if (tagsData.hasOwnProperty(uid)) {
  401. GM_setValue(`tags_${uid}`, tagsData[uid]);
  402. }
  403. }
  404. alert('标签导入成功!');
  405. updateAllTags();
  406. } catch (error) {
  407. alert('导入失败:文件格式不正确!');
  408. }
  409. };
  410. reader.readAsText(file);
  411. };
  412. input.click();
  413. }
  414. };
  415.  
  416.  
  417. const clearTags = () => {
  418. if (confirm("确定要清除所有用户的标签吗?")) {
  419. GM_listValues().forEach(key => {
  420. if (key.startsWith('tags_')) GM_deleteValue(key);
  421. });
  422. updateAllTags();
  423. }
  424. }
  425.  
  426. // 创建导入导出菜单
  427. const createImportExportMenu = () => {
  428. const menuContainer = document.createElement('div');
  429. menuContainer.className = 'import-export-menu';
  430.  
  431. const menuButton = document.createElement('button');
  432. menuButton.textContent = '导入/导出';
  433. menuContainer.appendChild(menuButton);
  434.  
  435. const dropdown = document.createElement('div');
  436. dropdown.className = 'import-export-dropdown';
  437.  
  438. const exportBase64 = document.createElement('a');
  439. exportBase64.textContent = '导出标签';
  440. exportBase64.onclick = () => exportTags(true, true);
  441. dropdown.appendChild(exportBase64);
  442.  
  443. const importBase64 = document.createElement('a');
  444. importBase64.textContent = '导入标签';
  445. importBase64.onclick = () => importTags(true, true);
  446. dropdown.appendChild(importBase64);
  447.  
  448. const clearAllTags = document.createElement('a');
  449. clearAllTags.textContent = '清除所有';
  450. clearAllTags.onclick = () => clearTags();
  451. dropdown.appendChild(clearAllTags);
  452.  
  453. menuContainer.appendChild(dropdown);
  454.  
  455. // 将菜单插入到页面左上方
  456. const topBar = document.querySelector('.topBar');
  457. if (topBar) {
  458. topBar.appendChild(menuContainer);
  459. }
  460. };
  461.  
  462.  
  463. // 初始化
  464. const init = () => {
  465. updateAllTags();
  466. createImportExportMenu();
  467.  
  468. setInterval(updateAllTags, 1000); // 每1000ms更新一次tag
  469. };
  470.  
  471. // 延迟初始化,确保页面加载完成
  472. setTimeout(init, 1000);
  473.  
  474. // Tampermonkey菜单命令
  475. GM_registerMenuCommand("清除所有用户标签", () => {
  476. if (confirm("确定要清除所有用户的标签吗?")) {
  477. GM_listValues().forEach(key => {
  478. if (key.startsWith('tags_')) GM_deleteValue(key);
  479. });
  480. updateAllTags();
  481. }
  482. });
  483.  
  484. GM_registerMenuCommand("导出标签(json)", () => exportTags(false));
  485. GM_registerMenuCommand("导入标签(json)", () => importTags(false));
  486. })();