website-time-tracker

用于追踪和统计用户在每个二级域名下的在线时长,并提供友好的可视化统计界面

  1. // ==UserScript==
  2. // @name website-time-tracker
  3. // @namespace https://github.com/sansan0/useful-userscripts
  4. // @version 3.0
  5. // @description 用于追踪和统计用户在每个二级域名下的在线时长,并提供友好的可视化统计界面
  6. // @author sansan
  7. // @match http://*/*
  8. // @match https://*/*
  9. // @require https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js
  10. // @grant GM_getValue
  11. // @grant GM_setValue
  12. // @license GPL-3.0 License
  13. // @icon data:image/png;base64,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
  14. // ==/UserScript==
  15.  
  16. (function () {
  17. "use strict";
  18.  
  19. const DEFAULT_STYLES = {
  20. position: "fixed",
  21. zIndex: 999999,
  22. backgroundColor: "rgba(255, 255, 255, 0.95)",
  23. boxShadow: "0 2px 12px rgba(0, 0, 0, 0.1)",
  24. borderRadius: "8px",
  25. fontFamily:
  26. '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif',
  27. transition: "all 0.3s ease",
  28. };
  29.  
  30. class TimeTracker {
  31. constructor() {
  32. this.startTime = null;
  33. this.animationFrameId = null;
  34. this.isStatsShown = false;
  35. this.isTabActive = true;
  36. this.timeDisplay = null;
  37. this.statsButton = null;
  38. this.initialize();
  39. }
  40.  
  41. /**
  42. * 获取时间日期
  43. */
  44. getBeijingDate() {
  45. const now = new Date();
  46. const year = now.getFullYear();
  47. const month = String(now.getMonth() + 1).padStart(2, "0");
  48. const day = String(now.getDate()).padStart(2, "0");
  49. return `${year}-${month}-${day}`;
  50. }
  51.  
  52. /**
  53. * 记录在线时间
  54. */
  55. logTime() {
  56. if (!this.startTime || !this.isTabActive) return;
  57.  
  58. const currentTimeInSeconds = Math.floor(
  59. (Date.now() - this.startTime) / 1000
  60. );
  61. const today = this.getBeijingDate();
  62. const currentDomain = window.location.hostname
  63. .split(".")
  64. .slice(-2)
  65. .join(".");
  66. let data = this.getStorageData();
  67.  
  68. if (!data[currentDomain]) {
  69. data[currentDomain] = {};
  70. }
  71. if (!data[currentDomain][today]) {
  72. data[currentDomain][today] = 0;
  73. }
  74.  
  75. data[currentDomain][today] += currentTimeInSeconds;
  76. this.setStorageData(data);
  77.  
  78. this.startTime = Date.now();
  79. }
  80.  
  81. /**
  82. * 更新 GM_setValue 并同步显示
  83. */
  84. updateGMStorage(timeSpentInSeconds) {
  85. if (timeSpentInSeconds <= 0) return;
  86. const today = this.getBeijingDate();
  87. const currentDomain = window.location.hostname
  88. .split(".")
  89. .slice(-2)
  90. .join(".");
  91. let data = this.getStorageData();
  92.  
  93. if (!data[currentDomain]) {
  94. data[currentDomain] = {};
  95. }
  96. if (!data[currentDomain][today]) {
  97. data[currentDomain][today] = 0;
  98. }
  99.  
  100. data[currentDomain][today] += timeSpentInSeconds;
  101. this.setStorageData(data);
  102. this.updateDisplay();
  103. }
  104.  
  105. /**
  106. * 将秒数格式化为时分秒格式
  107. */
  108. formatTime(secondsTotal) {
  109. const hours = Math.floor(secondsTotal / 3600);
  110. const minutes = Math.floor((secondsTotal % 3600) / 60);
  111. const seconds = secondsTotal % 60;
  112.  
  113. return [
  114. hours > 0 ? `${hours} 时` : "",
  115. minutes > 0 ? `${minutes} 分` : "",
  116. `${seconds} 秒`,
  117. ]
  118. .filter(Boolean)
  119. .join(" ");
  120. }
  121.  
  122. /**
  123. * 创建UI元素
  124. */
  125. createUIElements() {
  126. const container = this.createFixedElement("div", {
  127. style: {
  128. ...DEFAULT_STYLES,
  129. top: "20px",
  130. right: "20px",
  131. display: "flex",
  132. alignItems: "center",
  133. padding: "12px 20px",
  134. gap: "20px",
  135. cursor: "move",
  136. width: "fit-content",
  137. height: "fit-content",
  138. maxWidth: "max-content",
  139. whiteSpace: "nowrap",
  140. boxSizing: "border-box",
  141. userSelect: "none",
  142. },
  143. });
  144.  
  145. container.setAttribute("draggable", "true");
  146.  
  147. const dragFrame = this.createFixedElement("div", {
  148. style: {
  149. position: "fixed",
  150. border: "2px dashed #2196F3",
  151. zIndex: 999998,
  152. pointerEvents: "none",
  153. display: "none",
  154. },
  155. });
  156.  
  157. this.setupDragEvents(container, dragFrame);
  158.  
  159. const savedPosition = JSON.parse(
  160. GM_getValue("timeTrackerPosition", "{}")
  161. );
  162.  
  163. if (savedPosition) {
  164. const { left, top, isShrinked } = savedPosition;
  165.  
  166. container.style.left = `${left}px`;
  167. container.style.top = `${top}px`;
  168. container.style.right = "auto";
  169.  
  170. this.adjustContainerStyle(container, isShrinked);
  171. }
  172.  
  173. document.body.appendChild(dragFrame);
  174.  
  175. const timeContainer = this.createTimeContainer();
  176. const divider = this.createDivider();
  177. this.statsButton = this.createStatsButton();
  178.  
  179. container.appendChild(timeContainer);
  180. container.appendChild(divider);
  181. container.appendChild(this.statsButton);
  182.  
  183. document.body.appendChild(container);
  184.  
  185. this.addButtonHoverEffects(this.statsButton);
  186. this.addContainerHoverEffects(container);
  187.  
  188. this.statsButton.addEventListener("click", () => {
  189. this.logTime();
  190. this.showStats();
  191. this.addButtonClickEffect(this.statsButton);
  192. });
  193.  
  194. document.addEventListener("dblclick", (event) => {
  195. if (container.contains(event.target)) {
  196. const savedPosition = JSON.parse(
  197. GM_getValue("timeTrackerPosition", "{}")
  198. );
  199. savedPosition.isShrinked = !savedPosition.isShrinked;
  200. GM_setValue("timeTrackerPosition", JSON.stringify(savedPosition));
  201. this.adjustContainerStyle(container, savedPosition.isShrinked);
  202. }
  203. });
  204. }
  205.  
  206. /**
  207. * 设置拖拽事件
  208. */
  209. setupDragEvents(container, dragFrame) {
  210. container.addEventListener("dragstart", (event) => {
  211. const transparentElement = document.createElement("div");
  212. transparentElement.style.opacity = "0";
  213. document.body.appendChild(transparentElement);
  214.  
  215. event.dataTransfer.setDragImage(transparentElement, 0, 0);
  216.  
  217. setTimeout(() => {
  218. document.body.removeChild(transparentElement);
  219. }, 0);
  220.  
  221. event.dataTransfer.effectAllowed = "move";
  222.  
  223. dragFrame.style.width = `${container.offsetWidth}px`;
  224. dragFrame.style.height = `${container.offsetHeight}px`;
  225. dragFrame.style.display = "block";
  226.  
  227. container.style.opacity = "0.8";
  228. });
  229.  
  230. container.addEventListener("drag", (event) => {
  231. event.preventDefault();
  232. const { clientX, clientY } = event;
  233. const { offsetWidth, offsetHeight } = container;
  234. const { innerWidth, innerHeight } = window;
  235.  
  236. const left = clientX - offsetWidth / 2;
  237. const top = clientY - offsetHeight / 2;
  238.  
  239. dragFrame.style.left = `${Math.max(
  240. 0,
  241. Math.min(left, innerWidth - offsetWidth)
  242. )}px`;
  243. dragFrame.style.top = `${Math.max(
  244. 0,
  245. Math.min(top, innerHeight - offsetHeight)
  246. )}px`;
  247.  
  248. container.style.pointerEvents = "auto";
  249. });
  250.  
  251. document.addEventListener("dragover", (event) => {
  252. event.preventDefault();
  253. event.dataTransfer.dropEffect = "move";
  254. });
  255.  
  256. container.addEventListener("dragend", (event) => {
  257. const { clientX, clientY } = event;
  258. const { offsetWidth, offsetHeight } = container;
  259. const { innerWidth, innerHeight } = window;
  260.  
  261. const left = clientX - offsetWidth / 2;
  262. const top = clientY - offsetHeight / 2;
  263.  
  264. container.style.left = `${Math.max(
  265. 0,
  266. Math.min(left, innerWidth - offsetWidth)
  267. )}px`;
  268. container.style.top = `${Math.max(
  269. 0,
  270. Math.min(top, innerHeight - offsetHeight)
  271. )}px`;
  272. container.style.right = "auto";
  273.  
  274. dragFrame.style.display = "none";
  275.  
  276. const savedPosition = JSON.parse(
  277. GM_getValue("timeTrackerPosition", "{}")
  278. );
  279. savedPosition.left = parseInt(container.style.left);
  280. savedPosition.top = parseInt(container.style.top);
  281. GM_setValue("timeTrackerPosition", JSON.stringify(savedPosition));
  282.  
  283. container.style.opacity = "1";
  284. });
  285. }
  286.  
  287. /**
  288. * 调整容器样式
  289. */
  290. adjustContainerStyle(container, isShrinked) {
  291. if (isShrinked) {
  292. container.style.width = "auto";
  293. container.style.padding = "8px 12px";
  294. if (this.statsButton) {
  295. this.statsButton.style.display = "none";
  296. }
  297. } else {
  298. container.style.width = "fit-content";
  299. container.style.padding = "12px 20px";
  300. if (this.statsButton) {
  301. this.statsButton.style.display = "block";
  302. }
  303. }
  304. }
  305.  
  306. /**
  307. * 创建固定元素
  308. */
  309. createFixedElement(tag, options) {
  310. const element = document.createElement(tag);
  311. Object.assign(element.style, options.style);
  312. if (options.text) {
  313. element.textContent = options.text;
  314. }
  315. return element;
  316. }
  317.  
  318. /**
  319. * 创建时间容器
  320. */
  321. createTimeContainer() {
  322. const timeContainer = document.createElement("div");
  323. timeContainer.style.display = "flex";
  324. timeContainer.style.alignItems = "center";
  325. timeContainer.style.gap = "10px";
  326.  
  327. // 创建 SVG 时钟图标
  328. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  329. svg.setAttribute("viewBox", "0 0 24 24");
  330. svg.setAttribute("width", "20");
  331. svg.setAttribute("height", "20");
  332. svg.style.fill = "#666";
  333.  
  334. const path = document.createElementNS(
  335. "http://www.w3.org/2000/svg",
  336. "path"
  337. );
  338. path.setAttribute(
  339. "d",
  340. "M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M12.5,7H11V13L16.2,16.2L17,14.9L12.5,12.2V7Z"
  341. );
  342.  
  343. svg.appendChild(path);
  344. timeContainer.appendChild(svg);
  345.  
  346. this.timeDisplay = document.createElement("div");
  347. this.timeDisplay.style.cursor = "pointer";
  348.  
  349. this.timeLabel = document.createElement("div");
  350. this.timeLabel.style.fontSize = "12px";
  351. this.timeLabel.style.color = "#666";
  352. this.timeLabel.textContent = "今日在线";
  353.  
  354. this.timeValue = document.createElement("div");
  355. this.timeValue.style.fontSize = "15px";
  356. this.timeValue.style.color = "#333";
  357. this.timeValue.style.fontWeight = "600";
  358.  
  359. this.timeDisplay.appendChild(this.timeLabel);
  360. this.timeDisplay.appendChild(this.timeValue);
  361.  
  362. timeContainer.appendChild(this.timeDisplay);
  363.  
  364. return timeContainer;
  365. }
  366.  
  367. /**
  368. * 创建分隔线
  369. */
  370. createDivider() {
  371. const divider = document.createElement("div");
  372. divider.style.width = "1px";
  373. divider.style.height = "24px";
  374. divider.style.backgroundColor = "#eee";
  375. return divider;
  376. }
  377.  
  378. /**
  379. * 创建统计按钮
  380. */
  381. createStatsButton() {
  382. const statsButton = document.createElement("button");
  383. statsButton.textContent = "查看统计";
  384. Object.assign(statsButton.style, {
  385. padding: "6px 12px",
  386. fontSize: "14px",
  387. fontWeight: "500",
  388. color: "#2196F3",
  389. backgroundColor: "rgba(33, 150, 243, 0.1)",
  390. border: "1px solid rgba(33, 150, 243, 0.2)",
  391. borderRadius: "6px",
  392. cursor: "pointer",
  393. transition: "all 0.2s ease",
  394. outline: "none",
  395. whiteSpace: "nowrap",
  396. });
  397. return statsButton;
  398. }
  399.  
  400. /**
  401. * 添加按钮悬停效果
  402. */
  403. addButtonHoverEffects(button) {
  404. const hoverStyle = {
  405. backgroundColor: "rgba(33, 150, 243, 0.15)",
  406. borderColor: "rgba(33, 150, 243, 0.4)",
  407. };
  408. const defaultStyle = {
  409. backgroundColor: "rgba(33, 150, 243, 0.1)",
  410. borderColor: "rgba(33, 150, 243, 0.2)",
  411. };
  412.  
  413. button.addEventListener("mouseover", () => {
  414. Object.assign(button.style, hoverStyle);
  415. });
  416.  
  417. button.addEventListener("mouseout", () => {
  418. Object.assign(button.style, defaultStyle);
  419. });
  420. }
  421.  
  422. /**
  423. * 添加按钮点击效果
  424. */
  425. addButtonClickEffect(button) {
  426. button.style.transform = "scale(0.95)";
  427. setTimeout(() => {
  428. button.style.transform = "scale(1)";
  429. }, 100);
  430. }
  431.  
  432. /**
  433. * 添加容器悬停效果
  434. */
  435. addContainerHoverEffects(container) {
  436. const hoverStyle = {
  437. transform: "translateY(-1px)",
  438. boxShadow: "0 4px 15px rgba(0, 0, 0, 0.08)",
  439. };
  440. const defaultStyle = {
  441. transform: "translateY(0)",
  442. boxShadow: "0 2px 12px rgba(0, 0, 0, 0.1)",
  443. };
  444.  
  445. container.addEventListener("mouseover", () => {
  446. Object.assign(container.style, hoverStyle);
  447. });
  448.  
  449. container.addEventListener("mouseout", () => {
  450. Object.assign(container.style, defaultStyle);
  451. });
  452. }
  453.  
  454. /**
  455. * 更新显示
  456. */
  457. updateDisplay() {
  458. const today = this.getBeijingDate();
  459. const currentDomain = window.location.hostname
  460. .split(".")
  461. .slice(-2)
  462. .join(".");
  463. const data = this.getStorageData();
  464. const todayTime =
  465. data[currentDomain] && data[currentDomain][today]
  466. ? data[currentDomain][today]
  467. : 0;
  468. const elapsedSinceLastUpdate =
  469. this.startTime && this.isTabActive
  470. ? Math.floor((Date.now() - this.startTime) / 1000)
  471. : 0;
  472. const totalTime = todayTime + elapsedSinceLastUpdate;
  473. this.timeValue.textContent = this.formatTime(totalTime);
  474. this.animationFrameId = requestAnimationFrame(() => this.updateDisplay());
  475. }
  476.  
  477. /**
  478. * 获取存储数据
  479. */
  480. getStorageData() {
  481. return JSON.parse(GM_getValue("websiteTimeTracker", "{}"));
  482. }
  483.  
  484. /**
  485. * 设置存储数据
  486. */
  487. setStorageData(data) {
  488. GM_setValue("websiteTimeTracker", JSON.stringify(data));
  489. }
  490.  
  491. /**
  492. * 展示统计信息
  493. */
  494. showStats() {
  495. if (this.isStatsShown) return;
  496. this.isStatsShown = true;
  497. const hasSecurityRestrictions = () => {
  498. try {
  499. const script = document.createElement("script");
  500. script.textContent = 'console.log("test")';
  501. document.head.appendChild(script);
  502. document.head.removeChild(script);
  503. return false;
  504. } catch (e) {
  505. return true;
  506. }
  507. };
  508.  
  509. const overlay = this.createOverlay();
  510. const modal = this.createModal();
  511. const title = this.createTitle("在线时间统计");
  512. modal.appendChild(title);
  513.  
  514. const useTableView =
  515. hasSecurityRestrictions() || typeof echarts === "undefined";
  516.  
  517. if (useTableView) {
  518. const tableContainer = document.createElement("div");
  519. tableContainer.style.padding = "20px";
  520. tableContainer.style.overflow = "auto";
  521. this.createDataTable(tableContainer);
  522. modal.appendChild(tableContainer);
  523. } else {
  524. const chartContainer = this.createChartContainer();
  525. modal.appendChild(chartContainer);
  526. setTimeout(() => {
  527. try {
  528. const myChart = echarts.init(chartContainer);
  529. this.updateChart(myChart);
  530. window.addEventListener("resize", () => {
  531. myChart.resize();
  532. });
  533. } catch (error) {
  534. modal.removeChild(chartContainer);
  535. const tableContainer = document.createElement("div");
  536. tableContainer.style.padding = "20px";
  537. tableContainer.style.overflow = "auto";
  538. this.createDataTable(tableContainer);
  539. modal.appendChild(tableContainer);
  540. }
  541. }, 100);
  542. }
  543.  
  544. overlay.addEventListener("click", () => {
  545. document.body.removeChild(modal);
  546. document.body.removeChild(overlay);
  547. this.isStatsShown = false;
  548. this.updateGMStorage(0);
  549. });
  550.  
  551. document.body.appendChild(overlay);
  552. document.body.appendChild(modal);
  553. }
  554.  
  555. /**
  556. * 创建数据表格
  557. */
  558. createDataTable(container) {
  559. const data = this.getStorageData();
  560. const currentDomain = window.location.hostname
  561. .split(".")
  562. .slice(-2)
  563. .join(".");
  564. const domainData = data[currentDomain] || {};
  565. const today = this.getBeijingDate();
  566.  
  567. const table = document.createElement("table");
  568. Object.assign(table.style, {
  569. width: "100%",
  570. borderCollapse: "collapse",
  571. textAlign: "center",
  572. fontSize: "14px",
  573. });
  574.  
  575. const thead = document.createElement("thead");
  576. const headerRow = document.createElement("tr");
  577. ["日期", "在线时长"].forEach((text) => {
  578. const th = document.createElement("th");
  579. th.textContent = text;
  580. Object.assign(th.style, {
  581. padding: "10px",
  582. backgroundColor: "#f5f5f5",
  583. borderBottom: "2px solid #ddd",
  584. });
  585. headerRow.appendChild(th);
  586. });
  587. thead.appendChild(headerRow);
  588. table.appendChild(thead);
  589.  
  590. const tbody = document.createElement("tbody");
  591. const dates = Object.keys(domainData).sort().reverse();
  592. dates.forEach((date) => {
  593. const tr = document.createElement("tr");
  594.  
  595. const dateTd = document.createElement("td");
  596. dateTd.textContent = date;
  597. dateTd.style.padding = "10px";
  598. dateTd.style.borderBottom = "1px solid #ddd";
  599. tr.appendChild(dateTd);
  600.  
  601. const timeTd = document.createElement("td");
  602. let seconds = domainData[date];
  603. if (date === today) {
  604. seconds += this.startTime
  605. ? Math.floor((Date.now() - this.startTime) / 1000)
  606. : 0;
  607. }
  608. timeTd.textContent = this.formatTime(seconds);
  609. timeTd.style.padding = "10px";
  610. timeTd.style.borderBottom = "1px solid #ddd";
  611. tr.appendChild(timeTd);
  612.  
  613. tbody.appendChild(tr);
  614. });
  615. table.appendChild(tbody);
  616. container.appendChild(table);
  617. }
  618.  
  619. /**
  620. * 创建遮罩层
  621. */
  622. createOverlay() {
  623. return this.createFixedElement("div", {
  624. style: {
  625. position: "fixed",
  626. top: "0",
  627. left: "0",
  628. width: "100%",
  629. height: "100%",
  630. backgroundColor: "rgba(0, 0, 0, 0.5)",
  631. zIndex: 9998,
  632. },
  633. });
  634. }
  635.  
  636. /**
  637. * 创建弹窗
  638. */
  639. createModal() {
  640. return this.createFixedElement("div", {
  641. style: {
  642. ...DEFAULT_STYLES,
  643. top: "50%",
  644. left: "50%",
  645. transform: "translate(-50%, -50%)",
  646. width: "80%",
  647. maxWidth: "1200px",
  648. height: "600px",
  649. backgroundColor: "white",
  650. zIndex: 9999,
  651. padding: "20px",
  652. boxSizing: "border-box",
  653. display: "flex",
  654. flexDirection: "column",
  655. },
  656. });
  657. }
  658.  
  659. /**
  660. * 创建标题
  661. */
  662. createTitle(text) {
  663. const title = document.createElement("div");
  664. title.textContent = text;
  665. Object.assign(title.style, {
  666. fontSize: "18px",
  667. fontWeight: "bold",
  668. textAlign: "center",
  669. marginBottom: "20px",
  670. });
  671. return title;
  672. }
  673.  
  674. /**
  675. * 创建图表容器
  676. */
  677. createChartContainer() {
  678. const chartContainer = document.createElement("div");
  679. Object.assign(chartContainer.style, {
  680. flex: 1,
  681. width: "100%",
  682. });
  683. return chartContainer;
  684. }
  685.  
  686. /**
  687. * 更新图表数据
  688. */
  689. updateChart(chart) {
  690. const data = this.getStorageData();
  691. const currentDomain = window.location.hostname
  692. .split(".")
  693. .slice(-2)
  694. .join(".");
  695. const domainData = data[currentDomain] || {};
  696. const today = this.getBeijingDate();
  697. const { factor } = this.determineUnit();
  698.  
  699. const currentTimeInSeconds = this.startTime
  700. ? Math.floor((Date.now() - this.startTime) / 1000)
  701. : 0;
  702.  
  703. // 获取并排序日期
  704. const dates = Object.keys(domainData).sort();
  705. const values = dates.map((date) => {
  706. const value = domainData[date];
  707. return date === today
  708. ? Math.floor((value + currentTimeInSeconds) / factor)
  709. : Math.floor(value / factor);
  710. });
  711.  
  712. // 格式化日期显示
  713. const formatDates = dates.map((date, index) => {
  714. const [year, month, day] = date.split("-");
  715. if (index === 0 || day === "01") {
  716. return `${month}-${day}`;
  717. } else {
  718. return day;
  719. }
  720. });
  721.  
  722. const option = {
  723. grid: {
  724. top: 50,
  725. right: 50,
  726. bottom: 50,
  727. left: 70,
  728. },
  729. tooltip: {
  730. trigger: "axis",
  731. formatter: (params) => {
  732. const date = dates[params[0].dataIndex];
  733. return `${date}<br/>在线时长:${params[0].value} 分钟`;
  734. },
  735. },
  736. dataZoom: [
  737. {
  738. type: "slider",
  739. show: true,
  740. xAxisIndex: 0,
  741. startValue: Math.max(0, dates.length - 30),
  742. endValue: dates.length - 1,
  743. },
  744. ],
  745. xAxis: {
  746. type: "category",
  747. data: formatDates,
  748. axisLabel: {
  749. interval: 0,
  750. rotate: 0,
  751. margin: 15,
  752. color: "#666",
  753. fontSize: 12,
  754. formatter: (value) => value,
  755. },
  756. axisTick: {
  757. alignWithLabel: true,
  758. },
  759. axisLine: {
  760. lineStyle: {
  761. color: "#999",
  762. },
  763. },
  764. },
  765. yAxis: {
  766. type: "value",
  767. name: "时间(分钟)",
  768. nameLocation: "middle",
  769. nameGap: 50,
  770. splitLine: {
  771. lineStyle: {
  772. type: "dashed",
  773. color: "#eee",
  774. },
  775. },
  776. axisLabel: {
  777. color: "#666",
  778. },
  779. },
  780. series: [
  781. {
  782. name: "在线时长",
  783. type: "line",
  784. data: values,
  785. smooth: true,
  786. showSymbol: true,
  787. symbolSize: 6,
  788. lineStyle: {
  789. width: 2,
  790. color: "#2196F3",
  791. },
  792. itemStyle: {
  793. color: "#2196F3",
  794. },
  795. areaStyle: {
  796. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  797. {
  798. offset: 0,
  799. color: "rgba(33, 150, 243, 0.3)",
  800. },
  801. {
  802. offset: 1,
  803. color: "rgba(33, 150, 243, 0.1)",
  804. },
  805. ]),
  806. },
  807. },
  808. ],
  809. };
  810.  
  811. chart.setOption(option);
  812. }
  813.  
  814. /**
  815. * 确定时间单位
  816. */
  817. determineUnit() {
  818. return { unit: "minutes", factor: 60 };
  819. }
  820.  
  821. /**
  822. * 初始化
  823. */
  824.  
  825. initialize() {
  826. if (window !== window.top) {
  827. return;
  828. }
  829.  
  830. this.startTime = Date.now();
  831. this.setupEventListeners();
  832. this.createUIElements();
  833.  
  834. this.animationFrameId = requestAnimationFrame(() => this.updateDisplay());
  835. const savedPosition = JSON.parse(
  836. GM_getValue("timeTrackerPosition", "{}")
  837. );
  838. if (savedPosition && savedPosition.isShrinked) {
  839. this.adjustContainerStyle(
  840. document.querySelector('div[draggable="true"]'),
  841. true
  842. );
  843. }
  844. }
  845.  
  846. /**
  847. * 设置事件监听
  848. */
  849. setupEventListeners() {
  850. window.addEventListener("focus", () => {
  851. this.startTime = Date.now();
  852. });
  853.  
  854. window.addEventListener("blur", () => this.logTime());
  855. window.addEventListener("beforeunload", () => this.logTime());
  856. document.addEventListener("visibilitychange", () => {
  857. if (document.visibilityState === "visible") {
  858. this.isTabActive = true;
  859. this.startTime = Date.now();
  860. } else {
  861. this.isTabActive = false;
  862. this.logTime();
  863. }
  864. });
  865. }
  866. }
  867.  
  868. // 启动应用
  869. new TimeTracker();
  870. })();