55 Commits
V3.1.1 ... main

Author SHA1 Message Date
855695bc13 chore: 更新版本信息(V3.1.5) 2025-04-06 13:29:26 +08:00
d9869fcb87 chore(工具箱/二维码解析和生成): 更新版本信息 2025-02-23 22:57:02 +08:00
2ecddb17aa feat(工具箱/二维码解析和生成): 优化解析功能,在二维码所在位置添加矩形标记 2025-02-23 22:56:48 +08:00
73837d517d feat(工具箱/二维码解析和生成): 支持读取剪贴板中的图片 2025-02-23 21:33:44 +08:00
8e6a00f610 fix(工具箱/新窗口中打开): 移除未使用的导入模块 2025-02-23 17:31:38 +08:00
ffbf926c9f feat(工具箱): 添加新的工具项信息 2025-02-23 17:29:58 +08:00
93fea94c3a fix(工具箱): 优化工具项描述内容 2025-02-23 17:21:29 +08:00
eeb72097e5 fix(工具箱/二维码解析和生成): 优化界面样式,解决图片显示超出的问题 2025-02-23 17:16:04 +08:00
c2a496b228 fix(工具箱/新窗口中打开): 优化界面布局 2025-02-23 17:15:27 +08:00
f27a869d6a feat(工具箱/二维码解析和生成): 添加生成二维码功能 2025-02-22 15:23:31 +08:00
084afc0cef fix(app): 优化阻止默认右键菜单处理逻辑,处理每个元素 2025-02-21 22:34:47 +08:00
abb1fed1ef feat(工具箱): 添加“二维码解析和生成”工具 2025-02-21 22:33:34 +08:00
85a7f66af4 feat(utils): 添加二维码处理逻辑 2025-02-21 22:30:56 +08:00
d616564a55 fix(工具箱): 优化阻止默认右键菜单处理逻辑 2025-02-09 22:54:30 +08:00
ba238a44d9 fix(工具箱/Unix 时间戳转换): 移除未使用的导入模块 2025-02-09 18:51:11 +08:00
55f3e74cbf chore(工具箱): 调整工具列表项顺序 2025-02-08 17:35:06 +08:00
173cada6a4 chore: 更新版本信息(V3.1.4) 2025-02-08 10:53:58 +08:00
fb655552b3 feat(工具箱): 添加在新窗口中打开当前工具功能 2025-02-07 17:36:09 +08:00
9aa47a6b3b fix(工具箱/新窗口中打开): 统一变量声明方式 2025-02-07 17:33:00 +08:00
b338b91e5a fix(工具箱/JSON 格式化): 优化“输出内容”显示样式,解决内容较多时行号显示不全的问题 2025-02-07 16:51:51 +08:00
9034790421 chore: 更新版本信息(V3.1.3) 2025-02-07 15:55:34 +08:00
204930e9e5 feat(工具箱): 实现“Unix 时间戳转换”工具 2025-02-05 22:34:15 +08:00
b46b707329 fix(工具箱/新窗口中打开): 移除未使用的导入模块 2025-02-04 18:16:44 +08:00
3e526045bf feat(工具箱): 实现“新窗口中打开”工具 2025-02-04 18:05:32 +08:00
dab7d938f7 fix(工具箱/WebSocket 测试): 统一表单样式 2025-02-04 18:04:41 +08:00
6e60f4a67c feat(工具箱): 添加“JSON 格式化”工具 2025-02-04 17:36:21 +08:00
6fdc510f60 chore(app): 安装 highlight.js 2025-02-04 17:35:14 +08:00
3b8a119ab0 fix(工具箱/生成随机字符串): 统一函数返回值 2025-02-04 17:35:02 +08:00
cd1c82082e fix(工具箱/WebSocket 测试): 统一模块导入方式 2025-02-04 17:34:15 +08:00
d836cecb91 fix(工具箱): 优化界面样式 2025-02-04 16:21:37 +08:00
5857af36dd chore: 更新导航链接列表 2025-02-03 22:43:11 +08:00
9c8712ae26 feat(工具箱): 实现“Minecraft 聊天记录查看”工具 2025-02-03 22:33:07 +08:00
bc35da29af feat(utils): 添加 UUID 生成函数 2025-02-03 21:51:29 +08:00
7291dedeaf fix(工具箱): 优化界面样式 2025-02-03 21:50:49 +08:00
0631f3ae24 fix(工具箱): 修正工具组件路径 2025-01-31 22:22:23 +08:00
3452dc748a fix(工具箱/原神时钟): 解决特定情况下显示的小时值大于或等于 24 的问题 2025-01-31 22:16:55 +08:00
e1c02b68a3 feat(工具箱): 添加“Minecraft 聊天记录查看”工具 2025-01-31 20:40:22 +08:00
a789486823 feat(工具箱): 添加“frp 配置文件生成”工具 2025-01-31 20:39:47 +08:00
0228151136 chore: 更新依赖项的版本 2025-01-31 20:37:02 +08:00
56ba451325 feat(工具箱): 实现“WebSocket 测试”工具 2024-12-01 19:58:48 +08:00
c4537b1103 refactor(app): 修改本地储存数据处理逻辑,统一管理 2024-12-01 17:02:24 +08:00
5ddb70e1ab chore: 更新依赖项的版本 2024-12-01 16:19:54 +08:00
b90611e310 fix(app): 优化全局 CSS 变量处理逻辑 2024-10-13 23:23:40 +08:00
23d0b08242 chore: 更新版本信息(V3.1.2) 2024-10-13 22:06:04 +08:00
744c054c1c feat(工具箱): 启用“原神时钟”项 2024-10-13 18:49:43 +08:00
2a76490656 feat(工具箱): 完善“原神时钟”,添加背景图片 2024-10-13 18:49:26 +08:00
53299b83b3 feat(工具箱): 完善“原神时钟”的交互逻辑 2024-10-13 17:25:05 +08:00
865ef1e383 chore(工具箱): 调整页面路由生成逻辑,不跳过未启用的工具 2024-10-13 12:11:02 +08:00
0310eee39e fix(工具箱): 优化“保持亮屏”工具界面样式,背景添加圆角 2024-10-13 12:07:57 +08:00
fcda974626 feat(工具箱): 完善“原神时钟”,添加时间信息和提示文本 2024-10-13 12:00:08 +08:00
81b714333a feat(工具箱): 完善“原神时钟”,使用 CSS 计算实现图标高亮效果 2024-10-13 00:33:03 +08:00
6516fe4905 feat(工具箱): 完善“原神时钟”,添加动画效果,支持旋转指针 2024-10-12 22:41:52 +08:00
e37d12a5f2 chore(app): 添加 svg-arc.js 2024-10-12 22:41:12 +08:00
331e037714 feat(工具箱): 添加“原神时钟”工具 2024-10-12 22:41:08 +08:00
ef95e5ce73 feat(工具箱): 添加“保持亮屏”工具 2024-10-11 00:12:11 +08:00
34 changed files with 6271 additions and 2053 deletions

View File

@@ -1,5 +1,54 @@
# 更新日志 # 更新日志
## [3.1.5] - 2025-04-06
### Added
- `工具箱` 添加“二维码解析和生成”工具。
### Changed
- `工具箱` 调整工具列表项顺序。
## [3.1.4] - 2025-02-08
### Added
- `工具箱` 添加在新窗口中打开当前工具功能。
### Fixed
- `工具箱/JSON 格式化` 优化“输出内容”显示样式,解决内容较多时行号显示不全的问题。
## [3.1.3] - 2025-02-07
### Added
- `工具箱` 添加“JSON 格式化”工具。
- `工具箱` 添加“Minecraft 聊天记录查看”工具。
- `工具箱` 添加“Unix 时间戳转换”工具。
- `工具箱` 添加“WebSocket 测试”工具。
- `工具箱` 添加“新窗口中打开”工具。
### Changed
- `网址导航` 更新导航链接列表2025-02-03
### Fixed
- `工具箱/原神时钟` 解决特定情况下显示的小时值大于或等于 24 的问题。
## [3.1.2] - 2024-10-13
### Added
- `工具箱` 添加“保持亮屏”。
- `工具箱` 添加“原神时钟”。
### Changed
- `工具箱` 调整页面路由生成逻辑,不跳过未启用的工具。
## [3.1.1] - 2024-09-08 ## [3.1.1] - 2024-09-08
### Fixed ### Fixed

View File

@@ -1,7 +1,7 @@
{ {
"name": "frost-navigation", "name": "frost-navigation",
"description": "Frost Navigation", "description": "Frost Navigation",
"version": "3.1.1", "version": "3.1.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -12,29 +12,30 @@
"dependencies": { "dependencies": {
"@frost-utils/javascript": "^2.1.3", "@frost-utils/javascript": "^2.1.3",
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"@vueuse/core": "^11.0.3", "@vueuse/core": "^12.5.0",
"axios": "^1.7.5", "axios": "^1.7.9",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"lunisolar": "^2.5.0", "highlight.js": "^11.11.1",
"mathjs": "^13.1.1", "lunisolar": "^2.5.1",
"naive-ui": "^2.39.0", "mathjs": "^14.2.0",
"naive-ui": "^2.41.0",
"radash": "^12.1.0", "radash": "^12.1.0",
"uuid": "^10.0.0", "uuid": "^11.0.5",
"vue": "^3.4.29", "vue": "^3.5.13",
"vue-router": "^4.3.3", "vue-router": "^4.5.0",
"zxing-wasm": "^1.2.12" "zxing-wasm": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/node": "^20.14.5", "@types/node": "^20.17.16",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-legacy": "^5.4.2", "@vitejs/plugin-legacy": "^6.0.0",
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.0.0", "@vitejs/plugin-vue-jsx": "^4.1.1",
"@vue/tsconfig": "^0.5.1", "@vue/tsconfig": "^0.7.0",
"eslint": "^8.57.0", "eslint": "^9.19.0",
"eslint-plugin-vue": "^9.23.0", "eslint-plugin-vue": "^9.32.0",
"less": "^4.2.0", "less": "^4.2.2",
"vite": "^5.3.1" "vite": "^6.0.11"
} }
} }

3455
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
window['NAV_LINK_DATE'] = '2024-09-01'; window['NAV_LINK_DATE'] = '2025-02-03';
window['NAV_LINK_LIST'] = [ window['NAV_LINK_LIST'] = [
{ {
@@ -2558,8 +2558,8 @@ window['NAV_LINK_LIST'] = [
children: [ children: [
{ {
title: 'Minecraft Wiki', title: 'Minecraft Wiki',
date: '2021-05-09', date: '2025-02-03',
url: 'https://minecraft.fandom.com/zh/', url: 'https://zh.minecraft.wiki/',
}, },
{ {
title: 'Minecraft Wiki哔哩哔哩', title: 'Minecraft Wiki哔哩哔哩',
@@ -2568,8 +2568,8 @@ window['NAV_LINK_LIST'] = [
}, },
{ {
title: 'Minecraft 插件百科', title: 'Minecraft 插件百科',
date: '2021-02-08', date: '2025-02-03',
url: 'http://mineplugin.org/', url: 'https://mineplugin.org/',
}, },
{ {
title: 'Minecraft 光影百科', title: 'Minecraft 光影百科',
@@ -2802,6 +2802,7 @@ window['NAV_LINK_LIST'] = [
{ {
title: 'Minecraft-HK Community', title: 'Minecraft-HK Community',
date: '2021-02-08', date: '2021-02-08',
isInvalid: true,
url: 'http://forum.minecraft-hk.com/', url: 'http://forum.minecraft-hk.com/',
}, },
{ {
@@ -2885,10 +2886,15 @@ window['NAV_LINK_LIST'] = [
] ]
}, },
{ {
title: 'CSSBattle', title: '其他',
date: '2022-03-20', children: [
desc: 'CSS 代码高尔夫code-golfing游戏', {
url: 'https://cssbattle.dev/', title: 'CSSBattle',
date: '2022-03-20',
desc: 'CSS 代码高尔夫code-golfing游戏',
url: 'https://cssbattle.dev/',
},
],
}, },
], ],
}, },

2
public/wasm/README.txt Normal file
View File

@@ -0,0 +1,2 @@
zxing_full.wasm
zxing-wasm v2.0.1

BIN
public/wasm/zxing_full.wasm Normal file

Binary file not shown.

View File

@@ -1,25 +1,9 @@
<template> <template>
<n-config-provider <n-config-provider
:date-locale="configProviderProps.dateLocale" :date-locale="configProviderProps.dateLocale"
:hljs="hljs"
:inline-theme-disabled="configProviderProps.inlineThemeDisabled" :inline-theme-disabled="configProviderProps.inlineThemeDisabled"
:locale="configProviderProps.locale" :locale="configProviderProps.locale"
:style="{
'--border-radius': themeCommon.borderRadius,
'--border-radius-small': themeCommon.borderRadiusSmall,
'--box-shadow-1': themeVars.boxShadow1,
'--box-shadow-2': themeVars.boxShadow2,
'--box-shadow-3': themeVars.boxShadow3,
'--color-action': themeVars.actionColor,
'--color-border': themeVars.borderColor,
'--color-error': themeCommon.errorColor,
'--color-info': themeCommon.infoColor,
'--color-primary': themeCommon.primaryColor,
'--color-success': themeCommon.successColor,
'--color-text-1': themeVars.textColor1,
'--color-text-2': themeVars.textColor2,
'--color-text-3': themeVars.textColor3,
'--color-warning': themeCommon.warningColor,
}"
:theme-overrides="themeOverrides" :theme-overrides="themeOverrides"
> >
@@ -51,14 +35,15 @@ import {
configProviderProps, configProviderProps,
} from './assets/js/naive-ui'; } from './assets/js/naive-ui';
import hljs from 'highlight.js/lib/core';
import hljsJavascript from 'highlight.js/lib/languages/javascript';
import hljsJson from 'highlight.js/lib/languages/json';
import AppAside from './components/AppAside.vue'; import AppAside from './components/AppAside.vue';
/** 主题变量配置 */ /** 主题变量配置 */
const themeOverrides = configProviderProps.themeOverrides; const themeOverrides = configProviderProps.themeOverrides;
/** 主题变量配置 - common */
const themeCommon = themeOverrides.common;
/** 默认主题变量 */ /** 默认主题变量 */
const themeVars = useThemeVars(); const themeVars = useThemeVars();
@@ -68,26 +53,94 @@ const themeVars = useThemeVars();
*/ */
function handleContextMenu(event) { function handleContextMenu(event) {
let element = event.target; let elements = event.composedPath();
let classValue = '';
let classRegExp = /(n-code|n-input|n-input-number|n-ol|n-select)/;
// 排除按住 Ctrl 键时 // 排除按住 Ctrl 键时
if (event.ctrlKey) { if (event.ctrlKey) {
return; return;
} }
// 排除输入框元素 for (let i = 0; i < elements.length; i++) {
if (
element instanceof HTMLInputElement && let element = elements[i];
['password', 'text', 'textarea'].includes(element.type)
) { // 获取元素 class 信息
return; if (element instanceof HTMLElement) {
classValue = element.classList.value;
} else {
continue;
}
// 排除输入框元素
if (element instanceof HTMLInputElement) {
return;
}
// 排除指定元素
if (classValue && classRegExp.test(classValue)) {
return;
}
} }
event.preventDefault(); event.preventDefault();
} }
/** 初始化 CSS 变量列表 */
function initCssVars() {
let rootStyle = document.documentElement.style;
let overrides = themeOverrides.common;
let variables = themeVars.value;
let cssVars = {
// 主题变量
'--border-radius': overrides.borderRadius,
'--border-radius-small': overrides.borderRadiusSmall,
'--box-shadow-1': variables.boxShadow1,
'--box-shadow-2': variables.boxShadow2,
'--box-shadow-3': variables.boxShadow3,
'--color-action': variables.actionColor,
'--color-border': variables.borderColor,
'--color-error': overrides.errorColor,
'--color-info': overrides.infoColor,
'--color-primary': overrides.primaryColor,
'--color-success': overrides.successColor,
'--color-text-1': variables.textColor1,
'--color-text-2': variables.textColor2,
'--color-text-3': variables.textColor3,
'--color-warning': overrides.warningColor,
// 其他颜色
'--color-bg-dark': 'var(--color-text-2)',
'--color-bg-light': '#F8F8F8',
'--color-black': 'var(--color-text-2)',
'--color-gray': '#E0E0E0',
'--color-red': 'var(--color-error)',
'--color-green': 'var(--color-success)',
'--color-blue': 'var(--color-info)',
'--color-orange': 'var(--color-warning)',
// 滚动条大小
'--scrollbar-size': '8px',
};
for (let key in cssVars) {
rootStyle.setProperty(key, cssVars[key]);
}
}
/** 初始化 highlight.js */
function initHighlightJs() {
hljs.registerLanguage('javascript', hljsJavascript);
hljs.registerLanguage('json', hljsJson);
}
onMounted(() => { onMounted(() => {
initCssVars();
initHighlightJs();
window.addEventListener('contextmenu', handleContextMenu); window.addEventListener('contextmenu', handleContextMenu);
}); });
@@ -97,18 +150,6 @@ onBeforeUnmount(() => {
</script> </script>
<style lang="less"> <style lang="less">
// 全局 CSS 变量
:root {
// 基础颜色
--color-black: rgb(51, 54, 57);
--color-gray: #E0E0E0;
// 分类颜色
--color-bg-dark: rgb(51, 54, 57);
--color-bg-light: #F8F8F8;
// 滚动条大小
--scrollbar-size: 8px;
}
// 滚动条 // 滚动条
::-webkit-scrollbar { ::-webkit-scrollbar {
width: var(--scrollbar-size); width: var(--scrollbar-size);

View File

@@ -0,0 +1,28 @@
// 本地储存
import { useLocalStorage } from '@vueuse/core';
/** 本地储存 key 前缀 */
const PREFIX = 'frost-navigation/';
/** NavView 模块 */
export const storeNavView = {
/** 导航链接侧边栏折叠状态 */
isAsideCollapsed: useLocalStorage(PREFIX + 'nav-view/is-aside-collapsed', false),
/** 导航链接当前选中分类 */
currentCategory: useLocalStorage(PREFIX + 'nav-view/current-category', ''),
/** 导航链接搜索类型 */
searchType: useLocalStorage(PREFIX + 'nav-view/search-type', 'all'),
};
/** SearchView 模块 */
export const storeSearchView = {
/** 当前使用的搜索引擎名称 */
searchEngineName: useLocalStorage(PREFIX + 'search-view/search-engine-name', '必应'),
};

412
src/assets/js/qr-code.js Normal file
View File

@@ -0,0 +1,412 @@
import {
prepareZXingModule,
readBarcodes,
writeBarcode,
} from 'zxing-wasm/full';
/** 默认背景颜色 */
const DEFAULT_BGC = 'transparent';
/** 默认前景颜色 */
const DEFAULT_FGC = '#000000';
/** 模块名称 */
const PREFIX = '[qr-code]';
/**
* @desc 二维码读取配置选项
* @type { import('zxing-wasm').ReaderOptions }
*/
const readerOptions = {
formats: ['QRCode'],
maxNumberOfSymbols: 8,
textMode: 'Plain',
tryDownscale: true,
tryHarder: true,
tryInvert: true,
tryRotate: true,
};
/**
* @desc 二维码生成配置选项
* @type { import('zxing-wasm').WriterOptions }
*/
const writerOptions = {
ecLevel: '',
format: 'QRCode',
scale: 0,
};
// 配置 wasm 文件路径
prepareZXingModule({
overrides: {
locateFile: (path, prefix) => {
if (path.endsWith('.wasm')) {
return `./wasm/${path}`;
} else {
return (prefix + path);
}
},
},
});
/**
* @description 在图片上绘制矩形,返回 DataURL
* @param {Blob} blob 图片二进制
* @param {Rect[]} rects 矩形位置信息列表
*/
function drawRectsOnImage(blob, rects) {
/** @typedef {{ x: number; y: number; w: number; h: number }} Rect */
let canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
return renderImageToCanvas(blob, canvas).then((success) => {
if (!success) {
return '';
}
rects.forEach((rect, index) => {
let { x, y, w, h } = rect;
let text = String(index + 1);
// 绘制矩形
ctx.fillStyle = 'rgba(0, 255, 0, 0.25)';
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(0, 255, 0, 1)';
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x, y, w, h);
// 绘制文本
ctx.font = `bold ${Math.round(w / 2)}px sans-serif`;
ctx.fillStyle = 'rgba(255, 255, 255, 1)';
ctx.lineWidth = Number((w / 100).toFixed(4));
ctx.strokeStyle = 'rgba(0, 0, 0, 1)';
ctx.fillText(text, x, y + h);
ctx.strokeText(text, x, y + h);
});
return canvas.toDataURL('image/png');
});
}
/**
* @description 修改 SVG 内容,获取信息
* @param {object} options
* @param {string} options.background
* @param {string} options.foreground
* @param {string} options.svgString
*/
function modifySvgContent(options) {
let {
background = DEFAULT_BGC,
foreground = DEFAULT_FGC,
svgString,
} = options;
let divElement = document.createElement('div');
let gElement = null;
let pathElement = null;
let rectElement = null;
let svgElement = null;
// 添加 DOM 元素,用于获取位置大小信息
document.body.appendChild(divElement);
divElement.innerHTML = svgString;
gElement = divElement.getElementsByTagName('g')[0] || null;
pathElement = divElement.getElementsByTagName('path')[0] || null;
rectElement = divElement.getElementsByTagName('rect')[0] || null;
svgElement = divElement.getElementsByTagName('svg')[0] || null;
if (!(gElement && pathElement && rectElement && svgElement)) {
return null;
}
// 修改填充颜色
gElement.setAttribute('fill', foreground);
rectElement.setAttribute('fill', background);
// 获取位置大小信息
let rectOfG = gElement.getBoundingClientRect();
let rectOfPath = pathElement.getBoundingClientRect();
let offsetX = Math.round(rectOfPath.left - rectOfG.left);
let offsetY = Math.round(rectOfPath.top - rectOfG.top);
let sizeW = Math.round(rectOfPath.width);
let sizeH = Math.round(rectOfPath.height);
let result = {
offsetX: offsetX,
offsetY: offsetY,
sizeW: sizeW,
sizeH: sizeH,
svgString: divElement.innerHTML,
};
// 输出处理结果
console.debug(PREFIX, '处理 SVG', result);
// 处理完成,移除 DOM 元素
document.body.removeChild(divElement);
return result;
}
/**
* 将图片 Blob 渲染到 Canvas
* @param {Blob} blob
* @param {HTMLCanvasElement} canvas
* @returns {Promise<boolean>}
*/
function renderImageToCanvas(blob, canvas) {
let ctx = canvas.getContext('2d');
if (window.createImageBitmap) {
return createImageBitmap(blob).then((bitmap) => {
canvas.width = bitmap.width;
canvas.height = bitmap.height;
ctx.drawImage(bitmap, 0, 0);
return true;
}).catch((error) => {
console.error('渲染图片失败:');
console.error(error);
return false;
});
} else {
return new Promise((resolve) => {
let image = new Image();
let url = URL.createObjectURL(blob);
image.onload = () => {
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
URL.revokeObjectURL(url);
ctx.drawImage(image, 0, 0);
resolve(true);
};
image.onerror = () => {
console.error(PREFIX, '渲染图片失败:加载图片失败');
URL.revokeObjectURL(url);
resolve(false);
};
image.src = url;
});
}
}
/**
* @description 将 SVG 字符串渲染到 Canvas
* @param {object} options
* @param {HTMLCanvasElement} options.canvas
* @param {string} options.svgString
* @param {string} options.background
* @param {string} options.foreground
* @param {number} options.drawLeft
* @param {number} options.drawTop
* @param {number} options.drawWidth
* @param {number} options.drawHeight
* @returns {Promise<boolean>}
*/
function renderSvgToCanvas(options) {
let {
canvas, svgString,
background = DEFAULT_BGC, foreground = DEFAULT_FGC,
drawLeft = 0, drawTop = 0,
drawWidth = 0, drawHeight = 0,
} = options;
let svgInfo = modifySvgContent({
background: background,
foreground: foreground,
svgString: svgString,
});
if (!svgInfo) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
let ctx = canvas.getContext('2d');
let image = new Image();
let svgBlob = new Blob([svgInfo.svgString], {
type: 'image/svg+xml;charset=utf-8',
});
let svgUrl = URL.createObjectURL(svgBlob);
image.onerror = () => {
console.error(PREFIX, '加载 SVG 失败');
URL.revokeObjectURL(svgUrl);
resolve(false);
};
image.onload = () => {
ctx.drawImage(
image,
svgInfo.offsetX, svgInfo.offsetY, svgInfo.sizeW, svgInfo.sizeH,
drawLeft, drawTop, drawWidth, drawHeight,
);
URL.revokeObjectURL(svgUrl);
resolve(true);
};
image.src = svgUrl;
});
}
/**
* @description 解析二维码图片
* @param {Blob} blob 图片二进制
*/
export function readQrCodeImage(blob) {
/**
* @desc 返回结果
* @type {{ error: string; image: string; textList: string[]; }}
*/
let result = {
error: '',
image: '',
textList: [],
};
return readBarcodes(blob, readerOptions).then((codeList) => {
let rectList = [];
let textList = result.textList;
if (codeList.length === 0) {
console.warn(PREFIX, '解析二维码失败:未识别到内容');
} else {
console.debug(PREFIX, '解析二维码成功:', codeList);
}
for (let i = 0; i < codeList.length; i++) {
let item = codeList[i];
let position = item.position;
let posX0 = position.topLeft.x;
let posX1 = position.bottomRight.x;
let posY0 = position.topLeft.y;
let posY1 = position.bottomRight.y;
// 记录二维码坐标
rectList.push({
x: posX0,
y: posY0,
w: posX1 - posX0,
h: posY1 - posY0,
});
// 记录二维码文本
textList.push(item.text);
}
// 框选二维码区域
return drawRectsOnImage(blob, rectList);
}).then((dataURL) => {
if (dataURL) {
result.image = dataURL;
}
return result;
}).catch((error) => {
console.error(PREFIX, '解析二维码失败:');
console.error(error);
result.error = String(error);
return result;
});
}
/**
* @description 生成二维码图片
* @param {object} options
* @param {string} options.content
* @param {string} options.background
* @param {string} options.foreground
* @param {number} options.width
* @param {number} options.height
* @returns 二维码图片 DataURL
*/
export function writeQrCodeImage(options = {}) {
let {
content = '',
background = DEFAULT_BGC, foreground = DEFAULT_FGC,
width = 256, height = 256,
} = options;
let canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
// 更新画布大小
canvas.width = width;
canvas.height = height;
// 设置背景颜色
ctx.fillStyle = background;
ctx.fillRect(0, 0, width, height);
return writeBarcode(content, writerOptions).then((result) => {
console.debug(PREFIX, '生成二维码', result);
if (result.error) {
console.error(PREFIX, `生成二维码失败:${result.error}`);
return '';
} else {
return result.svg;
}
}).then((svgString) => {
if (svgString) {
return renderSvgToCanvas({
canvas: canvas,
svgString: svgString,
background: background,
foreground: foreground,
drawLeft: 0,
drawTop: 0,
drawWidth: width,
drawHeight: height,
});
} else {
return false;
}
}).then((success) => {
if (success) {
return canvas.toDataURL('image/png');
} else {
return '';
}
}).catch((error) => {
console.error(PREFIX, '生成二维码失败:');
console.error(error);
return '';
});
}

View File

@@ -5,12 +5,8 @@ import {
} from 'vue'; } from 'vue';
import { import {
SKEY_SEARCH_ENGINE_NAME, storeSearchView,
} from '@/config/storage'; } from './local-storage';
import {
useLocalStorage,
} from '@vueuse/core';
import { import {
$message, $message,
@@ -37,7 +33,7 @@ import icon_zhihu from '@/assets/website-icon/zhihu.svg';
/** 打开搜索结果页面 */ /** 打开搜索结果页面 */
export function openSearchResult() { export function openSearchResult() {
let engine = searchEngineName.value; let engine = storeSearchView.searchEngineName.value;
let keyword = searchKeyword.value; let keyword = searchKeyword.value;
let baseURL = ''; let baseURL = '';
let useURL = ''; let useURL = '';
@@ -221,8 +217,5 @@ export const searchEngineList = [
}, },
]; ];
/** 搜索引擎名称 */
export const searchEngineName = useLocalStorage(SKEY_SEARCH_ENGINE_NAME, '必应');
/** 搜索关键词 */ /** 搜索关键词 */
export const searchKeyword = ref(''); export const searchKeyword = ref('');

112
src/assets/js/svg-arc.js Normal file
View File

@@ -0,0 +1,112 @@
// 生成扇形、环形、圆形,或弧形的 SVG 路径
//
// 修改自
// https://github.com/svgcamp/svg-arc
// License
// MIT
const PI = Math.PI;
/**
* @param {number} x
* @param {number} y
* @param {number} r
* @param {number} angle
*/
function point(x, y, r, angle) {
return [
(x + Math.sin(angle) * r).toFixed(2),
(y - Math.cos(angle) * r).toFixed(2),
];
}
/**
* @param {number} x
* @param {number} y
* @param {number} R
* @param {number} r
*/
function full(x, y, R, r) {
if (r <= 0) {
return `M ${x - R} ${y} A ${R} ${R} 0 1 1 ${x + R} ${y} A ${R} ${R} 1 1 1 ${x - R} ${y} Z`;
}
return `M ${x - R} ${y} A ${R} ${R} 0 1 1 ${x + R} ${y} A ${R} ${R} 1 1 1 ${x - R} ${y} M ${x - r} ${y} A ${r} ${r} 0 1 1 ${x + r} ${y} A ${r} ${r} 1 1 1 ${x - r} ${y} Z`;
}
/**
* @param {number} x
* @param {number} y
* @param {number} R
* @param {number} r
* @param {number} start
* @param {number} end
*/
function part(x, y, R, r, start, end) {
let s = (start / 360) * 2 * PI;
let e = (end / 360) * 2 * PI;
let P = [
point(x, y, r, s),
point(x, y, R, s),
point(x, y, R, e),
point(x, y, r, e),
];
let flag = (e - s > PI ? '1' : '0');
return `M ${P[0][0]} ${P[0][1]} L ${P[1][0]} ${P[1][1]} A ${R} ${R} 0 ${flag} 1 ${P[2][0]} ${P[2][1]} L ${P[3][0]} ${P[3][1]} A ${r} ${r} 0 ${flag} 0 ${P[0][0]} ${P[0][1]} Z`;
}
// 关于角度:
// 12 点钟方向 - 0, 360, 720, ...
// 3 点钟方向 - 90, 450, ...
// 6 点钟方向 - 180, 540, ...
// 9 点钟方向 - 270, 630, ...
// 注意事项:
// 绘制环形时,需要将 `fill-rule` 属性的值设置为 `evenodd`,否则 `fill` 可能无法正确填充颜色。
/**
* @description 生成 SVG `<path>` 元素的 d 属性值
* @param {object} opts 配置选项
* @param {number} opts.x 圆心水平坐标
* @param {number} opts.y 圆心垂直坐标
* @param {number} opts.R 内层半径(用于圆环)
* @param {number} opts.r 外层半径(圆形半径)
* @param {number} opts.start 起始角度0 ~ 360
* @param {number} opts.end 结束角度0 ~ 360
*/
function arc(opts = {}) {
let { x = 0, y = 0 } = opts;
let { R = 0, r = 0, start, end } = opts;
[R, r] = [Math.max(R, r), Math.min(R, r)];
if (R <= 0) {
return '';
}
if (start !== +start || end !== +end) {
return full(x, y, R, r);
}
if (Math.abs(start - end) < 0.000001) {
return '';
}
if (Math.abs(start - end) % 360 < 0.000001) {
return full(x, y, R, r);
}
[start, end] = [start % 360, end % 360];
if (start > end) {
end += 360;
}
return part(x, y, R, r, start, end);
}
export default arc;

View File

@@ -12,17 +12,6 @@ export const toolList = [
title: '计算', title: '计算',
enabled: true, enabled: true,
items: [ items: [
{
id: 'calc-download-time',
component: 'Calculation/CalcDownloadTime',
title: '下载用时计算',
iconClass: 'mdi mdi-calculator-variant-outline',
desc: '根据设定的文件大小和下载速度简单计算大约下载完成所需的时间。',
createdAt: '2024-09-08',
updatedAt: '2024-09-08',
version: '1',
enabled: true,
},
{ {
id: 'calc-ratio', id: 'calc-ratio',
component: 'Calculation/CalcRatio', component: 'Calculation/CalcRatio',
@@ -34,35 +23,68 @@ export const toolList = [
version: '1', version: '1',
enabled: true, enabled: true,
}, },
{
id: 'calc-download-time',
component: 'Calculation/CalcDownloadTime',
title: '下载用时计算',
iconClass: 'mdi mdi-calculator-variant-outline',
desc: '根据设定的文件大小和下载速度简单计算大约下载完成所需的时间。',
createdAt: '2024-09-08',
updatedAt: '2024-09-08',
version: '1',
enabled: true,
},
] ]
}, },
{ {
id: 'conversion-tools', id: 'conversion-tools',
title: '转换', title: '转换',
enabled: false, enabled: true,
items: [ items: [
{ {
id: 'convert-html-entities', id: 'base64-encode-decode',
component: 'Conversion/ConvertHtmlEntities', component: 'Conversion/Base64StringEncodeDecode',
title: '转换 HTML 实体', title: 'Base64 字符串编码 / 解码',
iconClass: 'mdi mdi-swap-horizontal', iconClass: 'mdi mdi-swap-horizontal',
desc: '', desc: '处理 Base64 编码的字符串。',
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
version: '0', version: '0',
enabled: false, enabled: false,
}, },
{
id: 'convert-timestamp',
component: 'Conversion/ConvertTimestamp',
title: 'Unix 时间戳转换',
iconClass: 'mdi mdi-swap-horizontal',
desc: '时间戳转时间字符串 / 时间字符串转时间戳',
createdAt: '2025-02-05',
updatedAt: '2025-02-05',
version: '1',
enabled: true,
},
{ {
id: 'url-encode-decode', id: 'url-encode-decode',
component: 'Conversion/UrlEncodeDecode', component: 'Conversion/UrlEncodeDecode',
title: 'URL 编码 / 解码', title: 'URL 编码 / 解码',
iconClass: 'mdi mdi-swap-horizontal', iconClass: 'mdi mdi-swap-horizontal',
desc: '', desc: '处理 URL 编码的字符串。',
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
version: '0', version: '0',
enabled: false, enabled: false,
}, },
{
id: 'qrcode-reader-and-generator',
component: 'Conversion/QrcodeReaderAndGenerator',
title: '二维码解析和生成',
iconClass: 'mdi mdi-qrcode',
desc: '解析二维码 / 生成二维码',
createdAt: '2025-02-21',
updatedAt: '2025-02-23',
version: '2',
enabled: true,
},
{ {
id: 'convert-text-structure', id: 'convert-text-structure',
component: 'Conversion/ConvertTextStructure', component: 'Conversion/ConvertTextStructure',
@@ -75,11 +97,11 @@ export const toolList = [
enabled: false, enabled: false,
}, },
{ {
id: 'convert-timestamp', id: 'convert-html-entities',
component: 'Conversion/ConvertTimestamp', component: 'Conversion/ConvertHtmlEntities',
title: 'Unix 时间戳转换', title: '转换 HTML 实体',
iconClass: 'mdi mdi-swap-horizontal', iconClass: 'mdi mdi-swap-horizontal',
desc: '时间戳转时间 / 时间转时间戳', desc: '',
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
version: '0', version: '0',
@@ -90,12 +112,12 @@ export const toolList = [
{ {
id: 'edit-tools', id: 'edit-tools',
title: '编辑', title: '编辑',
enabled: false, enabled: true,
items: [ items: [
{ {
id: 'csv-editor', id: 'csv-editor',
component: 'Edit/CsvEditor', component: 'Edit/CsvEditor',
title: 'CSV 编辑工具', title: 'CSV 编辑',
iconClass: 'mdi mdi-table-edit', iconClass: 'mdi mdi-table-edit',
desc: '查看或编辑 CSV 文件', desc: '查看或编辑 CSV 文件',
createdAt: '', createdAt: '',
@@ -103,6 +125,17 @@ export const toolList = [
version: '0', version: '0',
enabled: false, enabled: false,
}, },
{
id: 'json-formatter',
component: 'Edit/JsonFormatter',
title: 'JSON 格式化',
iconClass: 'mdi mdi-code-json',
desc: '格式化 / 美化 JSON 字符串',
createdAt: '2025-02-04',
updatedAt: '2025-02-07',
version: '2',
enabled: true,
},
], ],
}, },
{ {
@@ -110,6 +143,28 @@ export const toolList = [
title: '生成', title: '生成',
enabled: true, enabled: true,
items: [ items: [
{
id: 'frp-config-generator',
component: 'Generator/FrpConfigGenerator/FrpConfigGenerator',
title: 'frp 配置文件生成',
iconClass: 'mdi mdi-file-cog-outline',
desc: '生成用于 frpc、frps 的配置文件。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'frp-config-generator',
component: 'Generator/UuidGenerator',
title: 'UUID 生成器',
iconClass: 'mdi mdi-identifier',
desc: '生成 UUID 列表。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{ {
id: 'generate-urls', id: 'generate-urls',
component: 'Generator/GenerateUrls', component: 'Generator/GenerateUrls',
@@ -137,8 +192,30 @@ export const toolList = [
{ {
id: 'minecraft-tools', id: 'minecraft-tools',
title: 'Minecraft', title: 'Minecraft',
enabled: false, enabled: true,
items: [ items: [
{
id: 'minecraft-uuid-converter',
component: 'Minecraft/UuidConverter',
title: 'Minecraft UUID 转换',
iconClass: 'mdi mdi-identifier',
desc: '随机生成或转换 Minecraft 的 UUID。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'minecraft-chat-history-reader',
component: 'Minecraft/ChatHistoryReader/ChatHistoryReader',
title: 'Minecraft 聊天记录查看',
iconClass: 'mdi mdi-format-list-text',
desc: '读取并解析 latest.log 文件,显示聊天记录。',
createdAt: '2025-02-03',
updatedAt: '2025-02-03',
version: '1',
enabled: true,
},
{ {
id: 'calc-minecraft-chunk-location', id: 'calc-minecraft-chunk-location',
component: 'Minecraft/CalcChunkLocation', component: 'Minecraft/CalcChunkLocation',
@@ -161,64 +238,75 @@ export const toolList = [
version: '0', version: '0',
enabled: false, enabled: false,
}, },
{
id: 'minecraft-uuid-converter',
component: 'Minecraft/UuidConverter',
title: 'Minecraft UUID 转换',
iconClass: 'mdi mdi-identifier',
desc: '随机生成或转换 Minecraft 的 UUID。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
] ]
}, },
{ {
id: 'network-tools', id: 'network-tools',
title: '网络', title: '网络',
enabled: false, enabled: true,
items: [ items: [
{ {
id: 'websocket-test-tool', id: 'websocket-test-tool',
component: 'Network/WebSocketTestTool', component: 'Network/WebSocketTestTool',
title: 'WebSocket', title: 'WebSocket 测试',
iconClass: 'mdi mdi-connection', iconClass: 'mdi mdi-connection',
desc: 'WebSocket 测试工具', desc: '连接 WebSocket 服务端,发送和接收消息。',
createdAt: '', createdAt: '2024-12-01',
updatedAt: '', updatedAt: '2024-12-01',
version: '0', version: '1',
enabled: false, enabled: true,
}, },
], ],
}, },
{ {
id: 'other-tools', id: 'other-tools',
title: '其他', title: '其他',
enabled: false, enabled: true,
items: [ items: [
{
id: 'keep-screen-on',
component: 'Other/KeepScreenOn',
title: '保持亮屏',
iconClass: 'mdi mdi-monitor',
desc: '保持屏幕开启,不息屏,不休眠。',
createdAt: '2024-10-11',
updatedAt: '2024-10-13',
version: '2',
enabled: true,
},
{ {
id: 'open-new-window', id: 'open-new-window',
component: 'Other/OpenNewWindow', component: 'Other/OpenNewWindow',
title: '新窗口(小窗)中打开', title: '新窗口中打开',
iconClass: 'mdi mdi-window-maximize', iconClass: 'mdi mdi-window-maximize',
desc: '从新的小窗口中打开指定的链接(仅支持 PC 端浏览器)', desc: '从新的小窗口中打开指定的链接(仅支持 PC 端浏览器)',
createdAt: '', createdAt: '2025-02-04',
updatedAt: '', updatedAt: '2025-02-04',
version: '0', version: '1',
enabled: false, enabled: true,
}, },
{ {
id: 'run-javascript', id: 'run-javascript',
component: 'Other/RunJavaScript', component: 'Other/RunJavaScript',
title: '执行 JavaScript', title: '执行 JavaScript',
iconClass: 'mdi mdi-code-braces', iconClass: 'mdi mdi-code-braces',
desc: '执行简单的 JavaScript 代码片段', desc: '执行简单的 JavaScript 代码片段',
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
version: '0', version: '0',
enabled: false, enabled: false,
}, },
{
id: 'genshin-impact-clock',
component: 'Other/GenshinImpactClock/GenshinImpactClock',
title: '《原神》时钟',
iconClass: 'mdi mdi-clock-outline',
desc: '在网页上实现的《原神》时钟效果。',
createdAt: '2024-10-13',
updatedAt: '2024-10-13',
version: '1',
enabled: true,
},
], ],
}, },
]; ];
@@ -245,10 +333,10 @@ export function getToolboxRoutes() {
toolList.forEach((categoryItem) => { toolList.forEach((categoryItem) => {
categoryItem.items.forEach((toolItem) => { categoryItem.items.forEach((toolItem) => {
// 跳过未启用的工具 // // 跳过未启用的工具
if (!toolItem.enabled) { // if (!toolItem.enabled) {
return; // return;
} // }
routes.push({ routes.push({
path: `/toolbox-view/${toolItem.id}`, path: `/toolbox-view/${toolItem.id}`,

View File

@@ -4,6 +4,46 @@ import {
description as appDesc, description as appDesc,
} from '@package-json'; } from '@package-json';
import {
v4 as uuidV4,
} from 'uuid';
/** 将十六进制颜色值转为灰度值 */
export function colorHexToGrayLevel(hex = '') {
let rgb = colorHexToRgb(hex);
return Math.round(rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114);
}
/** 将十六进制颜色值转为 RGB */
export function colorHexToRgb(hex = '') {
// 去除可能存在的 '#' 字符
hex = hex.replace('#', '');
// 检查十六进制颜色值的长度,并根据长度决定如何处理
if (hex.length === 3) {
// 如果是简写形式,如 #FFF需要将其转换为完整形式 #FFFFFF
hex = hex.split('').map(char => char + char).join('');
}
// 分别解析出红色、绿色和蓝色的值
let r = parseInt(hex.slice(0, 2), 16);
let g = parseInt(hex.slice(2, 4), 16);
let b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
/** 获取 V4 UUID */
export function getUuidV4(noSplit = false) {
let uuid = uuidV4();
return noSplit ? uuid.replace(/-/g, '') : uuid;
}
/** /**
* @description 更新页面标题 * @description 更新页面标题
* @param {string} title * @param {string} title

View File

@@ -1,16 +0,0 @@
// 本地储存 key 信息
/** 储存 key 前缀 */
const PREFIX = 'frost-navigation/';
/** 导航链接侧边栏折叠状态 */
export const SKEY_NAV_LINK_ASIDE_COLLAPSED = PREFIX + 'nav-link-aside-collapsed';
/** 导航链接当前选中分类 */
export const SKEY_NAV_LINK_CATEGORY = PREFIX + 'nav-link-category';
/** 导航链接搜索类型 */
export const SKEY_NAV_LINK_SEARCH_TYPE = PREFIX + 'nav-link-search-type';
/** 当前使用的搜索引擎名称 */
export const SKEY_SEARCH_ENGINE_NAME = PREFIX + 'search-engine-name';

View File

@@ -27,18 +27,18 @@
collapse-mode="width" collapse-mode="width"
show-trigger="arrow-circle" show-trigger="arrow-circle"
:bordered="true" :bordered="true"
:collapsed="isCollapsed" :collapsed="storeNavView.isAsideCollapsed.value"
:collapsed-width="64" :collapsed-width="64"
:native-scrollbar="false" :native-scrollbar="false"
:scrollbar-props="{ trigger: 'hover' }" :scrollbar-props="{ trigger: 'hover' }"
:width="240" :width="240"
@collapse="isCollapsed = true" @collapse="storeNavView.isAsideCollapsed.value = true"
@expand="isCollapsed = false" @expand="storeNavView.isAsideCollapsed.value = false"
> >
<n-menu <n-menu
v-model:value="navLinksTitle" v-model:value="navLinksTitle"
mode="vertical" mode="vertical"
:collapsed="isCollapsed" :collapsed="storeNavView.isAsideCollapsed.value"
:collapsed-icon-size="24" :collapsed-icon-size="24"
:collapsed-width="64" :collapsed-width="64"
:icon-size="24" :icon-size="24"
@@ -53,7 +53,7 @@
<div class="right-content-header"> <div class="right-content-header">
<n-input-group> <n-input-group>
<n-select <n-select
v-model:value="searchType" v-model:value="storeNavView.searchType.value"
class="search-type" class="search-type"
:options="searchTypes" :options="searchTypes"
/> />
@@ -144,10 +144,8 @@ import {
} from '@/config/modules'; } from '@/config/modules';
import { import {
SKEY_NAV_LINK_ASIDE_COLLAPSED, storeNavView,
SKEY_NAV_LINK_CATEGORY, } from '@/assets/js/local-storage';
SKEY_NAV_LINK_SEARCH_TYPE,
} from '@/config/storage';
import { import {
$dialog, $message, $dialog, $message,
@@ -172,12 +170,6 @@ const detailDrawer = reactive({
}); });
/** 分类列表是否折叠 */
const isCollapsed = useLocalStorage(
SKEY_NAV_LINK_ASIDE_COLLAPSED,
false
);
/** 完整的链接列表 */ /** 完整的链接列表 */
const navLinksAll = formatNavLinks(true); const navLinksAll = formatNavLinks(true);
@@ -206,12 +198,6 @@ const navLinksTitle = shallowRef('');
/** 搜索关键词 */ /** 搜索关键词 */
const searchKeyword = shallowRef(''); const searchKeyword = shallowRef('');
/** 搜索类型 */
const searchType = useLocalStorage(
SKEY_NAV_LINK_SEARCH_TYPE,
'all'
);
/** /**
* @desc 搜索类型列表 * @desc 搜索类型列表
* @type { import('naive-ui').SelectOption[] } * @type { import('naive-ui').SelectOption[] }
@@ -235,7 +221,7 @@ function changeList(data = null) {
if (data) { if (data) {
useData = data; useData = data;
} else { } else {
storedKey = localStorage.getItem(SKEY_NAV_LINK_CATEGORY) storedKey = storeNavView.currentCategory.value;
} }
if (storedKey) { if (storedKey) {
@@ -249,17 +235,15 @@ function changeList(data = null) {
} }
if (useData) { if (useData) {
localStorage.setItem(SKEY_NAV_LINK_CATEGORY, useData.title); storeNavView.currentCategory.value = useData.title;
navLinksCurr.value = useData.children; navLinksCurr.value = useData.children;
navLinksTitle.value = useData.title; navLinksTitle.value = useData.title;
} else { } else {
localStorage.setItem(SKEY_NAV_LINK_CATEGORY, ''); storeNavView.currentCategory.value = '';
navLinksCurr.value = []; navLinksCurr.value = [];
navLinksTitle.value = ''; navLinksTitle.value = '';
} }
} }
/** /**
@@ -344,7 +328,7 @@ function treeDataFilter(pattern = '', node = null) {
pattern = pattern.toLowerCase(); pattern = pattern.toLowerCase();
} }
let type = searchType.value; let type = storeNavView.searchType.value;
let desc = String(node.desc).toLowerCase(); let desc = String(node.desc).toLowerCase();
let title = String(node.title).toLowerCase(); let title = String(node.title).toLowerCase();

View File

@@ -27,7 +27,7 @@
<!-- 搜索引擎列表 --> <!-- 搜索引擎列表 -->
<div class="search-engines-wrapper"> <div class="search-engines-wrapper">
<n-radio-group <n-radio-group
v-model:value="searchEngineName" v-model:value="storeSearchView.searchEngineName.value"
class="search-engines-list" class="search-engines-list"
> >
<!-- 搜索引擎分类 --> <!-- 搜索引擎分类 -->
@@ -68,9 +68,14 @@ import {
NInput, NRadio, NRadioGroup, NInput, NRadio, NRadioGroup,
} from 'naive-ui'; } from 'naive-ui';
import {
storeSearchView,
} from '@/assets/js/local-storage';
import { import {
openSearchResult, openSearchResult,
searchEngineList, searchEngineName, searchKeyword, searchEngineList,
searchKeyword,
} from '@/assets/js/search-engine'; } from '@/assets/js/search-engine';
import { import {

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -1,9 +1,267 @@
<template> <template>
<div class="tool-detail-page"></div> <div class="tool-detail-page">
<!-- 控制 -->
<n-card size="small" title="控制">
<n-form
class="form-no-feedback form-data"
label-align="right"
label-placement="left"
label-width="9em"
>
<n-form-item label="转换模式:">
<n-select
v-model:value="data.convertMode"
:options="[
{ label: '本地时间 -> 时间戳', value: 'toTimestamp' },
{ label: '时间戳 -> 本地时间', value: 'toLocalTime' },
]"
/>
</n-form-item>
<n-form-item label="时间戳单位:">
<n-select
v-model:value="data.timestampUnit"
:options="[
{ label: '毫秒ms', value: 'ms' },
{ label: '秒s', value: 's' },
]"
/>
</n-form-item>
<n-form-item label="自动更新:">
<n-switch v-model:value="data.autoUpdate" />
</n-form-item>
<n-form-item label="操作:">
<n-flex>
<n-button
type="primary"
@click="convertTime"
>转换</n-button>
<n-button
type="error"
@click="clearInputs"
>清空</n-button>
</n-flex>
</n-form-item>
</n-form>
</n-card>
<!-- 当前 -->
<n-card size="small" title="当前">
<n-form
class="form-no-feedback form-data"
label-align="right"
label-placement="left"
label-width="9em"
>
<n-form-item label="本地时间:">
<n-input
v-model:value="data.currentLocalTime"
placeholder=""
type="text"
:readonly="true"
></n-input>
</n-form-item>
<n-form-item label="时间戳ms">
<n-input
v-model:value="data.currentTimestamp"
placeholder=""
type="text"
:readonly="true"
></n-input>
</n-form-item>
</n-form>
</n-card>
<!-- 转换 -->
<n-card size="small" title="转换">
<n-form
class="form-no-feedback form-data"
label-align="right"
label-placement="left"
label-width="9em"
>
<n-form-item label="本地时间:">
<n-input
v-model:value="data.convertLocalTime"
:placeholder="`参考格式:年-月-日 时:分:秒`"
:readonly="data.convertMode === 'toLocalTime'"
type="text"
></n-input>
</n-form-item>
<n-form-item label="时间戳:">
<n-input
v-model:value="data.convertTimestamp"
:placeholder="timestampPlaceholder"
:readonly="data.convertMode === 'toTimestamp'"
type="text"
></n-input>
</n-form-item>
</n-form>
<n-p>注意</n-p>
<n-p>本地时间 -> 时间戳的模式中若输入的本地时间不包含时间部分例如 2025-02-01将会加上本地时区与零时区的时差后计算</n-p>
<n-p>即本地时区为北京时间UTC+8时按 08:00 计算本地时区为东京时间UTC+9时按 09:00 计算</n-p>
</n-card>
</div>
</template> </template>
<script setup> <script setup>
import {
NButton, NCard, NFlex,
NForm, NFormItem,
NInput, NP, NSelect, NSwitch,
} from 'naive-ui';
import {
computed, reactive,
onBeforeMount, onBeforeUnmount,
} from 'vue';
import {
$message,
} from '@/assets/js/naive-ui';
import {
getCommonDateTime,
} from '@frost-utils/javascript/common/index';
/** 数据 */
const data = reactive({
autoUpdate: true,
convertLocalTime: '',
convertMode: 'toTimestamp',
convertTimestamp: '',
currentLocalTime: '',
currentTimestamp: '',
timestampUnit: 'ms',
timer: null,
});
/** 时间戳输入占位文本 */
const timestampPlaceholder = computed(() => {
let suffix = (data.timestampUnit === 'ms' ? '000' : '');
return '示例1577808000' + suffix;
});
/** 清空输入内容 */
function clearInputs() {
data.convertLocalTime = '';
data.convertTimestamp = '';
}
/** 转换输入的时间 */
function convertTime() {
let mode = data.convertMode;
let unit = data.timestampUnit;
if (mode === 'toLocalTime') {
let ts = parseInt(data.convertTimestamp);
// 检测输入内容
if (isNaN(ts)) {
$message.warning('请输入时间戳');
return;
}
// 转换时间戳为毫秒
if (unit === 's') {
ts *= 1000;
}
// 更新结果
data.convertLocalTime = getCommonDateTime(ts, 'all');
} else if (mode === 'toTimestamp') {
let localTime = data.convertLocalTime;
let converted = 0;
if (localTime) {
converted = new Date(localTime).getTime();
} else {
$message.warning('请输入本地时间');
return;
}
// 检测输入内容
if (isNaN(converted)) {
$message.warning('请输入有效的本地时间');
return;
}
// 更新结果
data.convertLocalTime = getCommonDateTime(converted, 'all');
// 转换时间戳为秒
if (unit === 's') {
converted = Math.round(converted / 1000);
}
// 更新输入内容
data.convertTimestamp = String(converted);
}
}
/** 处理定时器 */
function setTimer(isStart = false) {
if (data.timer) {
clearInterval(data.timer);
}
if (!isStart) {
data.timer = null;
return;
}
data.timer = setInterval(() => {
if (!data.autoUpdate) {
return;
}
let currTime = Math.round(Date.now() / 1000) * 1000;
let timeText = getCommonDateTime(currTime, 'all');
data.currentLocalTime = timeText;
data.currentTimestamp = String(currTime);
}, 1000);
}
onBeforeMount(() => {
setTimer(true);
});
onBeforeUnmount(() => {
setTimer(false);
});
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.form-data {
.n-input {
max-width: 256px;
}
.n-select {
max-width: 200px;
}
}
</style> </style>

View File

@@ -0,0 +1,421 @@
<template>
<div class="tool-detail-page">
<!-- 解析二维码 -->
<n-card size="small" title="解析二维码">
<template #header-extra>
<n-flex>
<n-button
type="primary"
:text="true"
@click="handlePasteImage"
>粘贴</n-button>
<n-button
type="error"
:disabled="!readerData.dataURL"
:text="true"
@click="handleClearReader"
>清空</n-button>
</n-flex>
</template>
<n-form
class="form-no-feedback"
label-align="left"
label-placement="top"
label-width="auto"
>
<n-form-item label="选择图片">
<n-upload
accept="image/jpeg,image/png"
:default-upload="false"
:file-list="readerData.fileList"
:show-file-list="false"
@change="handleSelectQrImage"
>
<n-upload-dragger>
<span>点击选择图片 / 拖拽图片到此区域</span>
</n-upload-dragger>
</n-upload>
</n-form-item>
<n-form-item label="解析结果">
<n-flex
class="reader-result"
:wrap="true"
>
<div class="reader-result__image-preview">
<n-image
v-show="readerData.dataURL"
object-fit="contain"
width="100%"
height="100%"
:preview-disabled="false"
:src="readerData.dataURL"
/>
</div>
<n-flex
class="reader-result__text-list"
:vertical="true"
>
<n-ol v-if="readerData.results.length > 0">
<n-li
v-for="(value, index) in readerData.results"
:key="index"
>{{ value }}</n-li>
</n-ol>
<span v-else>请选择二维码图片以进行解析</span>
</n-flex>
</n-flex>
</n-form-item>
</n-form>
</n-card>
<!-- 生成二维码 -->
<n-card size="small" title="生成二维码">
<template #header-extra>
<n-flex>
<n-button
type="primary"
:disabled="!writerData.content"
:text="true"
@click="handleGenerateQrCode"
>生成</n-button>
<n-button
type="primary"
:disabled="!writerData.dataURL"
:text="true"
@click="handleDownloadQrCode"
>下载</n-button>
<n-button
type="error"
:disabled="!writerData.dataURL"
:text="true"
@click="handleClearWriter"
>清空</n-button>
</n-flex>
</template>
<n-form
class="form-no-feedback writer-config"
label-align="left"
label-placement="top"
label-width="auto"
>
<n-form-item label="文本内容">
<n-input
v-model:value="writerData.content"
placeholder="请输入需要生成的二维码文本内容"
type="textarea"
:rows="4"
></n-input>
</n-form-item>
<n-form-item label="分辨率">
<n-flex align="center">
<n-input-number
v-model:value="writerData.resolution"
:min="64"
:max="8192"
:step="1"
></n-input-number>
<span>px</span>
</n-flex>
</n-form-item>
<n-form-item label="颜色">
<n-flex align="center">
<span>前景颜色</span>
<n-color-picker
v-model:value="writerData.colorForeground"
:modes="['hex']"
:show-alpha="true"
:show-preview="true"
:swatches="['#00000000', '#000000FF', '#FFFFFFFF']"
/>
<span>背景颜色</span>
<n-color-picker
v-model:value="writerData.colorBackground"
:modes="['hex']"
:show-alpha="true"
:show-preview="true"
:swatches="['#00000000', '#000000FF', '#FFFFFFFF']"
/>
</n-flex>
</n-form-item>
<n-form-item label="二维码预览">
<div class="writer-preview">
<div class="writer-preview__wrapper">
<n-image
v-show="writerData.dataURL"
object-fit="contain"
width="100%"
height="100%"
:preview-disabled="true"
:src="writerData.dataURL"
/>
</div>
</div>
</n-form-item>
</n-form>
</n-card>
</div>
</template>
<script setup>
import {
NButton, NCard, NColorPicker, NFlex,
NForm, NFormItem, NInput, NInputNumber,
NImage, NLi, NOl, NUpload, NUploadDragger,
} from 'naive-ui';
import {
reactive,
} from 'vue';
import {
$message,
} from '@/assets/js/naive-ui';
import {
readQrCodeImage, writeQrCodeImage,
} from '@/assets/js/qr-code';
/** 二维码解析相关数据 */
const readerData = reactive({
/** 图片 DataURL */
dataURL: '',
/** 选择文件列表 */
fileList: [],
/** 解析结果 */
results: [],
});
/** 二维码生成相关数据 */
const writerData = reactive({
/** 背景颜色 */
colorBackground: '#FFFFFF',
/** 前景颜色 */
colorForeground: '#000000',
/** 文本内容 */
content: '',
/** 图片 DataURL */
dataURL: '',
/** 分辨率 */
resolution: 256,
});
/** 清空信息 */
function handleClearReader() {
readerData.dataURL = '';
readerData.results = [];
}
/** 清空信息 */
function handleClearWriter() {
writerData.content = '';
writerData.dataURL = '';
writerData.resolution = 256;
}
/** 处理下载二维码 */
function handleDownloadQrCode() {
let url = writerData.dataURL;
let element = document.createElement('a');
if (url) {
element.download = '二维码.png';
element.href = url;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
}
/** 处理生成二维码 */
function handleGenerateQrCode() {
return writeQrCodeImage({
content: writerData.content,
background: writerData.colorBackground,
foreground: writerData.colorForeground,
width: writerData.resolution,
height: writerData.resolution,
}).then((dataURL) => {
if (dataURL) {
$message.success('生成二维码成功');
} else {
$message.error('生成二维码失败');
}
writerData.dataURL = dataURL;
});
}
/** 从剪贴板中获取图片 */
function handlePasteImage() {
let cb = navigator.clipboard;
if (!cb) {
$message.error('当前浏览器不支持该操作');
return Promise.resolve(false);
}
return cb.read().then((items) => {
console.debug('剪贴板内容', items);
let imageItem = null;
let imageType = '';
for (let i = 0; i < items.length; i++) {
let item = items[i];
let types = item.types;
for (let j = 0; j < types.length; j++) {
let type = types[j];
if (type.startsWith('image/')) {
imageItem = item;
imageType = type;
break;
}
}
if (imageItem) {
break;
}
}
if (imageItem) {
return imageItem.getType(imageType);
} else {
return null;
}
}).then((blob) => {
if (blob) {
return handleParseQrCode(blob);
} else {
$message.warning('未在剪贴板中找到图片');
return false;
}
}).catch((error) => {
console.error('读取剪贴板失败:');
console.error(error);
$message.error('读取剪贴板失败,可能是没有权限。');
return false;
});
}
/**
* @description 处理解析二维码图片
* @param {File} file
*/
function handleParseQrCode(file) {
return readQrCodeImage(file).then((result) => {
let { error, image, textList } = result;
if (error) {
$message.error(error);
readerData.dataURL = '';
readerData.results = [];
return false;
} else {
if (textList.length === 0) {
$message.warning('未识别到有效的二维码');
} else {
$message.success('识别成功');
}
readerData.dataURL = image;
readerData.results = textList;
return true;
}
});
}
/**
* @description 处理选择图片
* @type { import('naive-ui').UploadOnChange }
*/
function handleSelectQrImage(options) {
return handleParseQrCode(options.file.file);
}
</script>
<style lang="less" scoped>
.reader-result {
width: 100%;
.reader-result__image-preview {
display: flex;
padding: 16px;
width: 256px;
height: 256px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
.n-image {
margin: auto;
width: 100%;
height: 100%;
}
}
.reader-result__text-list {
flex-grow: 1;
min-width: 256px;
width: 0;
user-select: text;
}
}
.writer-config {
.n-color-picker {
width: 128px;
}
}
.writer-preview {
width: 100%;
.writer-preview__wrapper {
display: flex;
width: 256px;
height: 256px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
.n-image {
margin: auto;
width: 100%;
height: 100%;
image-rendering: pixelated;
}
}
}
</style>

View File

@@ -0,0 +1,232 @@
<template>
<div class="tool-detail-page">
<!-- 设置 -->
<n-card size="small" title="设置">
<n-flex>
<!-- 缩进空格 -->
<div class="config-item">
<div class="config-item__label">缩进空格</div>
<div class="config-item__content">
<n-input-number
v-model:value="data.indentSize"
:min="0"
:max="8"
:precision="0"
:step="1"
></n-input-number>
</div>
</div>
<!-- 排序属性 -->
<div class="config-item">
<div class="config-item__label">排序属性</div>
<div class="config-item__content">
<n-switch v-model:value="data.enabledSort" />
</div>
</div>
</n-flex>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex>
<n-button
type="primary"
:disabled="!data.jsonInput"
@click="formatJson"
>格式化</n-button>
<n-button
type="primary"
:disabled="!data.jsonOutput"
@click="copyOutputs"
>复制结果</n-button>
<n-button
type="error"
:disabled="!data.jsonInput"
@click="clearInputs"
>清空输入</n-button>
<n-button
type="error"
:disabled="!data.jsonOutput"
@click="clearOutputs"
>清空输出</n-button>
</n-flex>
</n-card>
<!-- 输入内容 -->
<n-card size="small" title="输入内容">
<n-input
v-model:value="data.jsonInput"
class="json-input"
placeholder="请输入 JSON 字符串"
type="textarea"
:rows="8"
></n-input>
</n-card>
<!-- 输出内容 -->
<n-card size="small" title="输出内容">
<n-code
class="json-output"
language="json"
:code="data.jsonOutput"
:show-line-numbers="true"
/>
</n-card>
</div>
</template>
<script setup>
import {
NButton, NCard, NCode, NFlex,
NInput, NInputNumber, NSwitch,
} from 'naive-ui';
import {
reactive,
} from 'vue';
import {
compareString, isArray, isObject,
} from '@frost-utils/javascript/common/index';
import {
useClipboard,
} from '@vueuse/core';
import {
$message, $notification,
} from '@/assets/js/naive-ui';
/** 剪贴板 */
const clipboard = useClipboard({
legacy: true,
read: false,
});
/** 数据 */
const data = reactive({
/** 启用排序 */
enabledSort: true,
/** 缩进空格 */
indentSize: 2,
/** 输入内容 */
jsonInput: '',
/** 输出内容 */
jsonOutput: '',
});
/** 清空输入内容 */
function clearInputs() {
data.jsonInput = '';
}
/** 清空输出内容 */
function clearOutputs() {
data.jsonOutput = '';
}
/** 复制格式化结果 */
function copyOutputs() {
if (clipboard.isSupported) {
return clipboard.copy(data.jsonOutput).then(() => {
$message.success('复制成功');
}).catch((error) => {
console.error('复制失败:');
console.error(error);
$message.error('复制失败:异常');
});
} else {
$message.error('复制失败:当前浏览器不支持该操作');
return Promise.resolve();
}
}
/** 格式化输入的 JSON */
function formatJson() {
try {
let obj = JSON.parse(data.jsonInput);
// 排序
if (data.enabledSort) {
obj = sortObjectKeys(obj);
}
data.jsonOutput = JSON.stringify(obj, null, data.indentSize);
} catch (error) {
console.warn('格式化 JSON 失败:');
console.warn(error);
$notification.create({
content: String(error),
duration: 0,
title: '格式化 JSON 失败',
type: 'error',
});
data.jsonOutput = '';
}
}
/** 排序对象的 key */
function sortObjectKeys(obj) {
// 非对象直接返回
if (!isArray(obj) && !isObject(obj)) {
return obj;
}
// 处理数组,递归处理每个元素
if (isArray(obj)) {
return obj.map((item) => sortObjectKeys(item));
}
// 获取并排序 key
let sortedKeys = Object.keys(obj).sort((a, b) => {
return compareString(a, b);
});
// 排序后的对象
let sortedObj = {};
// 按顺序获取值 & 递归处理
for (let key of sortedKeys) {
sortedObj[key] = sortObjectKeys(obj[key]);
}
return sortedObj;
}
</script>
<style lang="less" scoped>
.config-item {
display: flex;
align-items: center;
.n-input-number {
width: 128px;
}
}
.json-input {
font-family: monospace;
}
.json-output {
user-select: text;
:deep(.__code__) {
flex-grow: 1;
width: 0;
overflow-x: auto;
}
}
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -217,7 +217,7 @@ function handleCopy() {
}); });
} else { } else {
$message.error('复制失败:当前浏览器不支持该操作'); $message.error('复制失败:当前浏览器不支持该操作');
return Promise.resolve(false); return Promise.resolve();
} }
} }

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,556 @@
<template>
<div class="tool-detail-page">
<!-- 说明 -->
<n-card size="small" title="说明">
<n-p>已测试游戏版本1.12.2 ~ 1.21.4</n-p>
<n-p>若内容出现乱码请尝试更改文件编码后重新打开文件</n-p>
</n-card>
<!-- 设置 -->
<n-card size="small" title="设置">
<n-flex>
<!-- 文件编码 -->
<div class="config-item">
<div class="config-item__label">文件编码</div>
<div class="config-item__content">
<n-select
v-model:value="currState.textEncoding"
:options="[
{ label: 'GBK', value: 'gbk' },
{ label: 'UTF-8', value: 'utf-8' },
]"
/>
</div>
</div>
<!-- 读取间隔 -->
<div class="config-item">
<div class="config-item__label">读取间隔</div>
<div class="config-item__content">
<n-input-number
v-model:value="currState.readInterval"
:disabled="currState.isReadingFile"
:min="1"
:max="60"
:precision="0"
:step="1"
></n-input-number>
<span
style="margin-left: 0.5em;"
></span>
</div>
</div>
</n-flex>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex class="action-row">
<n-button
type="success"
@click="selectLogFile"
>选择文件</n-button>
<n-button
type="primary"
:disabled="!currState.isOpenedFile"
@click="parseLogFileData(true)"
>刷新内容</n-button>
<n-button
type="primary"
:disabled="!currState.isOpenedFile || currState.isReadingFile"
@click="setAutoReading(true)"
>开始读取</n-button>
<n-button
type="error"
:disabled="!currState.isOpenedFile || !currState.isReadingFile"
@click="setAutoReading(false)"
>停止读取</n-button>
<n-button
type="error"
@click="clearHistory"
>清空内容</n-button>
</n-flex>
</n-card>
<!-- 信息 -->
<n-card size="small" title="信息">
<n-flex>
<n-tag type="info">文件名称{{ fsFileName || '-' }}</n-tag>
<n-tag type="info">文件大小{{ fileSizeDisplay }}</n-tag>
<n-tag type="info">修改时间{{ fileLastModifiedDisplay }}</n-tag>
</n-flex>
</n-card>
<!-- 内容 -->
<n-card
size="small"
title="内容"
:class="{
'chat-history': true,
'chat-history--is-full': currState.isFullView,
}"
>
<template #header-extra>
<div
class="chat-history__toggle-full"
@click="currState.isFullView = !currState.isFullView"
>
<span
v-if="currState.isFullView"
class="mdi mdi-arrow-collapse"
></span>
<span
v-else
class="mdi mdi-arrow-expand"
></span>
</div>
</template>
<div class="chat-history__wrapper" @contextmenu.stop>
<div ref="chatHistoryListRef" class="chat-history__list">
<div
v-for="item in currState.textRows"
:key="item.id"
class="chat-history__item"
>
<n-tag
class="chat-history__time"
size="small"
type="info"
>{{ item.time }}</n-tag>
<div
class="chat-history__text"
>{{ item.text }}</div>
</div>
</div>
</div>
</n-card>
</div>
</template>
<script setup>
import {
NButton, NCard, NFlex,
NInputNumber, NP, NSelect, NTag,
} from 'naive-ui';
import {
computed, reactive, ref,
nextTick, onBeforeMount, onBeforeUnmount,
} from 'vue';
import {
getCommonDateTime,
} from '@frost-utils/javascript/common/index';
import {
useFileSystemAccess,
} from '@vueuse/core';
import {
$message,
} from '@/assets/js/naive-ui';
import {
getUuidV4,
} from '@/assets/js/utils';
/**
* @typedef TextRowItem
* @property {string} id UUID
* @property {TextRowType} type 文本类型
* @property {string} time 时间信息
* @property {string} text 文本内容
*/
/** @typedef {''|'chat'} TextRowType */
/** 正则表达式列表 */
const REG_EXP = {
LOG_CHAT_MSG_1: /\[CHAT\]\s+(.*)$/,
LOG_TIME: /\[(\d{2}:\d{2}:\d{2})\]/,
};
const {
data: fsData,
fileLastModified: fsFileLastModified,
fileName: fsFileName,
fileSize: fsFileSize,
isSupported: fsIsSupported,
open: fsOpen,
updateData: fsUpdateData,
} = useFileSystemAccess({
dataType: 'ArrayBuffer',
excludeAcceptAllOption: true,
types: [{
accept: { 'text/plain': ['.log'] },
description: 'Minecraft 日志文件',
}],
});
/**
* @desc 聊天内容列表 ref
* @type {VueRef<HTMLElement>}
*/
const chatHistoryListRef = ref(null);
/** 状态信息 */
const currState = reactive({
/** 内容列表是否满屏显示 */
isFullView: false,
/** 是否正在持续读取文件 */
isReadingFile: false,
/** 是否已选择文件 */
isOpenedFile: false,
/** 最后一次读取时的文件大小 */
lastFileSize: 0,
/** 最后一次读取时的文件更新时间 */
LastModifiedTime: 0,
/** 最后一次读取时的行数 */
lastReadLineNumber: 0,
/** 读取间隔秒数 */
readInterval: 2,
/** 自动读取定时器 */
readTimer: null,
/** 文本内容编码类型 */
textEncoding: 'utf-8',
/**
* @desc 显示的文本行列表
* @type {TextRowItem[]}
*/
textRows: [],
});
/** 显示的文件大小 */
const fileSizeDisplay = computed(() => {
let value = fsFileSize.value;
return (value ? `${(value / 1024).toFixed(2)} KB` : '-');
});
/** 显示的文件修改时间 */
const fileLastModifiedDisplay = computed(() => {
let value = fsFileLastModified.value;
return (value ? getCommonDateTime(value, 'all') : '-');
});
/** 自动滚动聊天内容列表到底部 */
function autoScrollChatHistory() {
let element = chatHistoryListRef.value;
if (!element) {
console.error('自动滚动失败:元素不存在');
return;
}
let elRect = element.getBoundingClientRect();
let elHeight = Math.round(elRect.height);
let scrollHeight0 = element.scrollHeight;
let scrollHeight1 = 0;
let scrollTop = element.scrollTop;
// 检测当前是否位于底部
if (scrollHeight0 - scrollTop === elHeight) {
// 渲染新的内容后滚动到底部
nextTick(() => {
scrollHeight1 = element.scrollHeight;
if (scrollHeight1 > elHeight) {
element.scrollTo({
behavior: 'instant',
top: scrollHeight1 - elHeight,
});
}
});
}
}
/** 清空记录内容 */
function clearHistory() {
currState.textRows = [];
}
/**
* @description 解码字符串
* @param {ArrayBuffer} data
*/
function decodeFileData(data = null) {
try {
let decoder = new TextDecoder(currState.textEncoding);
let text = decoder.decode(data);
return text;
} catch (error) {
console.error('解码失败:');
console.error(error);
return '';
}
}
/** 初始化数据 */
function initData(isFirst = false) {
currState.lastFileSize = 0;
currState.LastModifiedTime = 0;
currState.lastReadLineNumber = 0;
currState.textRows = isFirst ? [{
id: getUuidV4(),
type: 'chat',
time: getCommonDateTime(null, 'time'),
text: '请点击按钮选择日志文件以开始。',
}] : [];
}
/** 解析日志文件内容 */
async function parseLogFileData(manualUpdate = false) {
if (manualUpdate) {
await fsUpdateData();
}
let currFileSize = fsFileSize.value;
let currFileTime = fsFileLastModified.value;
let currFileData = fsData.value;
let currFileText = '';
let readLineNumber = currState.lastReadLineNumber;
let readLineStrs = [];
let parsedData = [];
// 若文件内容没有变化,不处理
if (currFileTime === currState.LastModifiedTime) {
return false;
} else {
currState.LastModifiedTime = currFileTime;
}
// 解码字符串
if (currFileData instanceof ArrayBuffer) {
currFileText = decodeFileData(currFileData);
}
if (currFileText) {
// 以 \n 拆分,移除末尾 \r
readLineStrs = currFileText.split('\n').map((text) => {
return text.replace(/\r$/, '');
}).filter((text) => {
return Boolean(text);
});
} else {
console.warn('文件内容为空或解码失败');
return false;
}
// 若已重新创建日志文件,则从头开始读取
if (currFileSize < currState.lastFileSize) {
readLineNumber = 0;
}
// 跳过读取过的行
readLineStrs.splice(0, readLineNumber);
readLineNumber += readLineStrs.length;
readLineStrs.forEach((text) => {
let parsed = parseLogLine(text);
if (parsed.type === 'chat') {
parsedData.push(parsed);
}
});
currState.lastFileSize = currFileSize;
currState.lastReadLineNumber = readLineNumber;
currState.textRows.push.apply(currState.textRows, parsedData);
autoScrollChatHistory();
return true;
}
/**
* @description 解析日志文件行内容
* @returns {TextRowItem}
*/
function parseLogLine(text = '') {
try {
let msgTextMatched = text.match(REG_EXP.LOG_CHAT_MSG_1);
let msgTextStr = msgTextMatched ? msgTextMatched[1] : '';
let timeTextMatched = text.match(REG_EXP.LOG_TIME);
let timeTextStr = timeTextMatched ? timeTextMatched[1] : '';
if (msgTextStr) {
// 处理换行和 §
msgTextStr = msgTextStr.replace(/\\n/g, '\n').replace(/§\w/g, '');
return {
id: getUuidV4(),
type: 'chat',
time: timeTextStr,
text: msgTextStr,
};
}
} catch (error) {
console.error('解析内容失败:');
console.error(error);
}
return {
id: null,
type: '',
time: '',
text: '',
};
}
/** 选择日志文件 */
async function selectLogFile() {
if (!fsIsSupported.value) {
$message.error('当前浏览器不支持该功能');
return false;
}
try {
await fsOpen();
$message.success('打开文件成功');
currState.isOpenedFile = true;
initData(false);
parseLogFileData(false);
setAutoReading(true);
return true;
} catch (error) {
console.error('打开文件失败:');
console.error(error);
$message.error('打开文件失败');
return false;
}
}
/** 开始或停止自动读取 */
function setAutoReading(isStart = false) {
if (currState.readTimer) {
clearInterval(currState.readTimer);
}
if (!isStart) {
currState.isReadingFile = false;
currState.readTimer = null;
return;
}
currState.isReadingFile = true;
currState.readTimer = setInterval(() => {
parseLogFileData(true);
}, currState.readInterval * 1000);
}
onBeforeMount(() => {
initData(true);
});
onBeforeUnmount(() => {
setAutoReading(false);
});
</script>
<style lang="less" scoped>
.config-item {
display: flex;
align-items: center;
.config-item__content {
display: flex;
align-items: center;
}
.n-input-number, .n-select {
width: 128px;
}
}
.chat-history {
position: relative;
background-color: #FFF;
&.chat-history--is-full {
position: fixed;
left: 0;
top: 0;
margin-top: 0 !important;
width: 100%;
height: 100%;
.chat-history__wrapper,
.chat-history__list {
height: 100%;
}
}
.chat-history__toggle-full {
cursor: pointer;
}
.chat-history__wrapper {
--line-margin: 8px;
padding: 16px;
border: 1px solid #F0F0F0;
border-radius: 4px;
font-family: monospace;
line-height: calc(1em + var(--line-margin));
word-break: break-all;
}
.chat-history__list {
height: 320px;
overflow-y: auto;
}
.chat-history__item {
display: flex;
margin: var(--line-margin) 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
.chat-history__time {
margin: auto 0;
margin-right: 0.5em;
}
.chat-history__text {
margin: auto 0;
user-select: text;
white-space: pre-wrap;
}
}
</style>

View File

@@ -1,9 +1,553 @@
<template> <template>
<div class="tool-detail-page"></div> <div class="tool-detail-page">
<!-- 注意 -->
<n-card size="small" title="注意">
<n-p>由于浏览器限制通过 HTTPS 访问网站时只能连接带 SSL WebSocketWSS</n-p>
<n-p>若需要连接不带 SSL WebSocketWS建议下载到本地后使用</n-p>
</n-card>
<!-- 输入 -->
<n-card size="small" title="输入">
<n-form
class="form-no-feedback"
label-align="left"
label-placement="top"
label-width="auto"
>
<n-form-item label="连接地址">
<n-input-group class="address-input">
<n-select
v-model:value="data.address.prefix"
:options="[
{ label: 'ws://', value: 'ws://' },
{ label: 'wss://', value: 'wss://' },
]"
/>
<n-input
v-model:value="data.address.suffix"
type="text"
></n-input>
</n-input-group>
</n-form-item>
<n-form-item label="发送内容(自动移除换行符)">
<n-input
v-model:value="data.inputs"
placeholder="在此处输入要发送的内容"
type="textarea"
:rows="2"
></n-input>
</n-form-item>
</n-form>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex>
<n-button
type="success"
:disabled="data.ws !== null"
@click="wsConnect"
>连接</n-button>
<n-button
type="warning"
:disabled="data.ws === null"
@click="wsClose"
>断开</n-button>
<n-button
type="primary"
:disabled="data.ws === null"
@click="wsSend"
>发送</n-button>
<n-button
type="error"
@click="clearInputs"
>清空输入</n-button>
<n-button
type="error"
@click="clearMessages"
>清空消息</n-button>
</n-flex>
</n-card>
<!-- 日志 -->
<n-card size="small" title="日志">
<div
ref="logsContentRef"
class="logs-content"
:style="{ height: (data.logsHeight + 'px') }"
@contextmenu.stop
>
<div class="message-list">
<div
v-for="item in data.messages"
:key="item.id"
class="message-item"
>
<!-- 时间 -->
<n-tag
:type="item.type === 'send' ? 'primary' : 'success'"
size="small"
>{{ getCommonDateTime(item.time) }}</n-tag>
<!-- 消息内容 -->
<div
v-if="data.parseType === 'html'"
v-html="item.message"
class="message-content"
></div>
<div
v-else
class="message-content"
>{{ item.message }}</div>
</div>
</div>
</div>
</n-card>
<!-- 设置 -->
<n-card size="small" title="设置">
<n-form
class="form-no-feedback"
label-align="left"
label-placement="top"
label-width="auto"
>
<!-- 日志高度 -->
<n-form-item label="日志高度">
<n-input-number
v-model:value="data.logsHeight"
:min="80"
:max="800"
:precision="0"
:step="1"
></n-input-number>
</n-form-item>
<!-- 日志最大行数 -->
<n-form-item label="日志最大行数">
<n-input-number
v-model:value="data.logsMax"
:min="1"
:max="8192"
:precision="0"
:step="1"
></n-input-number>
</n-form-item>
<!-- 解析类型 -->
<n-form-item label="解析类型">
<n-radio-group v-model:value="data.parseType">
<n-radio-button
v-for="item in data.parseTypes"
:key="item.name"
:label="item.label"
:value="item.name"
/>
</n-radio-group>
</n-form-item>
<!-- 自动滚动 -->
<n-form-item label="自动滚动">
<n-radio-group v-model:value="data.autoScroll">
<n-radio-button label="开启" :value="true" />
<n-radio-button label="关闭" :value="false" />
</n-radio-group>
</n-form-item>
</n-form>
</n-card>
</div>
</template> </template>
<script setup> <script setup>
import {
NButton, NCard,
NFlex, NForm, NFormItem,
NInput, NInputGroup, NInputNumber,
NP, NRadioButton, NRadioGroup, NSelect, NTag,
} from 'naive-ui';
import {
reactive, ref, nextTick, onMounted, onBeforeUnmount,
} from 'vue';
import {
getCommonDateTime,
} from '@frost-utils/javascript/common/index';
import {
$dialog, $notification,
} from '@/assets/js/naive-ui';
/** 数据 */
const data = reactive({
/** 连接地址 */
address: {
prefix: 'ws://',
suffix: '',
},
/** 自动滚动结果 */
autoScroll: true,
/** 发送内容 */
inputs: '',
/** 日志高度 */
logsHeight: 320,
/** 日志最大行数 */
logsMax: 100,
/** 接收内容 */
messages: [],
/** 消息 ID */
messageID: 0,
/** 消息解析类型 */
parseType: 'string',
/** 消息解析类型列表 */
parseTypes: [
{ name: 'html', label: 'HTML' },
{ name: 'json', label: 'JSON' },
{ name: 'string', label: '字符串' },
],
/**
* @desc WebSocket 对象
* @type {WebSocket}
*/
ws: null,
});
/**
* @desc 日志内容 ref
* @type {VueRef<HTMLElement>}
*/
const logsContentRef = ref(null);
/** 清空输入 */
function clearInputs() {
$dialog.create({
content: '确定要清空输入的内容吗?',
negativeText: '取消',
positiveText: '确定',
title: '确认',
type: 'default',
onPositiveClick: () => {
data.inputs = '';
},
});
}
/** 清空消息 */
function clearMessages() {
$dialog.create({
content: '确定要清空消息内容吗?',
negativeText: '取消',
positiveText: '确定',
title: '确认',
type: 'default',
onPositiveClick: () => {
data.messages = [];
data.messageID = 0;
},
});
}
/**
* @description 处理 WebSocket 关闭
* @param {CloseEvent} event
*/
function handleClose(event) {
let ws = event.target;
notify({
message: 'WebSocket 已关闭',
type: 'warning',
});
if (ws) {
ws.removeEventListener('close', handleClose);
ws.removeEventListener('error', handleError);
ws.removeEventListener('message', handleMessage);
ws.removeEventListener('open', handleOpen);
}
}
/** 处理 WebSocket 错误 */
function handleError() {
notify({
message: 'WebSocket 发生错误',
type: 'error',
});
}
/**
* @description 处理 WebSocket 消息
* @param {MessageEvent} ev
*/
function handleMessage(ev) {
let msg = ev.data;
let el = logsContentRef.value;
if (!msg) {
return;
}
let parsed = wsParse(false, msg);
let result = (parsed || msg)
console.log('%c%s', 'color: #2196F3;', '[接收]', (parsed || result));
// 记录消息
pushMessage('receive', msg);
// 自动滚动
nextTick(() => {
if (el && data.autoScroll) {
el.scrollTo(0, el.scrollHeight)
}
});
}
/** 处理 WebSocket 打开 */
function handleOpen() {
notify({
message: 'WebSocket 已连接',
type: 'info',
});
}
/** 初始化 */
function init() {
// 检测兼容性
if (typeof WebSocket === 'undefined') {
notify({
duration: 0,
message: '您的浏览器不支持 WebSocket。',
title: '错误',
type: 'error',
});
}
}
/**
* @description 提示信息
* @param {object} options
* @param {number} options.duration
* @param {string} options.message
* @param {string} options.title
* @param {string} options.type
*/
function notify(options) {
let {
duration = 3000,
message = '',
title = '提示',
type = 'info',
} = options;
return $notification.create({
content: message,
duration: duration,
title: title,
type: type,
});
}
/**
* @description 添加消息
* @param {string} type 类型receive、send
* @param {string} msg 消息内容
*/
function pushMessage(type, msg = '') {
let types = ['receive', 'send'];
if (types.indexOf(type) === -1) {
return;
}
let current = data.messages.length;
let max = data.logsMax;
// 最大行数
if (current >= max) {
data.messages.splice(0, (current - max + 1));
}
data.messageID += 1;
data.messages.push({
id: data.messageID,
message: msg,
time: Date.now(),
type: type,
});
}
/** 关闭连接 */
function wsClose() {
if (data.ws) {
data.ws.close();
data.ws = null;
}
}
/** 打开连接 */
function wsConnect() {
let info = data.address;
let address = (info.prefix + info.suffix);
if (!info.suffix) {
notify({
message: '请填写连接地址',
title: '连接失败',
type: 'warning',
});
return;
}
try {
let ws = new WebSocket(address);
// 监听事件
ws.addEventListener('close', handleClose);
ws.addEventListener('error', handleError);
ws.addEventListener('message', handleMessage);
ws.addEventListener('open', handleOpen);
// 保存对象
data.ws = ws;
} catch (error) {
notify({
message: String(error),
title: '连接失败',
type: 'error',
});
}
}
/**
* @description 解析消息
* @param {boolean} isSend 是否为发送,否则为接收
* @param {string} content 消息文本内容
* @returns {null|object|string} 成功则返回解析后的消息,否则返回 null
*/
function wsParse(isSend = false, content = '') {
let parseType = data.parseType;
if (parseType === 'json') {
// JSON
try {
return JSON.parse(content);
} catch (error) {
let s1 = isSend ? '解析发送的 JSON 消息失败' : '解析接收的 JSON 消息失败';
let s2 = String(error);
console.warn(s1);
console.warn(s2);
notify({
message: s2,
title: s1,
type: 'warning',
});
return null;
}
} else {
// 默认不处理
return content;
}
}
/** 发送消息 */
function wsSend() {
let instance = data.ws;
let message = data.inputs.replace(/(\n|\r)/g, '');
let parsed = wsParse(true, message);
if (instance && parsed) {
console.log('%c%s', 'color: #4CAF50;', '[发送]', parsed);
instance.send(message);
pushMessage('send', message);
}
}
onMounted(() => {
init();
});
onBeforeUnmount(() => {
wsClose();
});
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.address-input {
.n-select {
width: 100px;
}
.n-input {
flex-grow: 1;
width: 0;
}
}
.logs-content {
padding: 8px 16px;
border: 1px solid #F0F0F0;
border-radius: 4px;
background-color: #FFF;
line-height: 1.6;
overflow-y: auto;
user-select: text;
.message-item {
display: flex;
margin: 8px 0;
word-break: break-all;
> * {
margin: auto 0;
}
}
.n-tag {
margin-right: 0.5em;
}
.message-content {
position: relative;
}
}
</style> </style>

View File

@@ -0,0 +1,163 @@
<template>
<svg
class="clock-color"
:viewBox="`0 0 ${elSize} ${elSize}`"
>
<!-- 定义 -->
<defs>
<!-- 背景图案 -->
<pattern
id="color-pattern"
x="0"
y="0"
patternUnits="userSpaceOnUse"
patternContentUnits="userSpaceOnUse"
:width="elSize"
:height="elSize"
>
<image
:width="elSize"
:height="elSize"
:href="IMAGE_TIME_ZONE_COLOR"
/>
</pattern>
</defs>
<!-- 外层圆环 -->
<path
class="color-circle"
:class="{ faded: currAngle > 360 }"
:d="state.dOuter"
fill="url(#color-pattern)"
fill-rule="evenodd"
></path>
<!-- 内层圆环 -->
<path
ref="innerCircle"
class="color-circle"
:d="state.dInner"
fill="url(#color-pattern)"
fill-rule="evenodd"
></path>
</svg>
</template>
<script setup>
import {
computed,
onMounted,
reactive, watch,
} from 'vue';
import {
IMAGE_TIME_ZONE_COLOR,
} from './common-data';
import arc from '@/assets/js/svg-arc';
const props = defineProps({
elSize: {
type: Number,
default: 300,
},
currAngle: {
type: Number,
default: 0,
},
startAngle: {
type: Number,
default: 0,
},
radius: {
type: Number,
default: 150,
},
thickness: {
type: Number,
default: 8,
},
});
const state = reactive({
dInner: '',
dOuter: '',
});
const halfSize = computed(() => {
return (props.elSize / 2);
});
// 角度变化时更新状态
watch(() => (props.currAngle), () => {
updateCircle();
});
watch(() => (props.startAngle), () => {
updateCircle();
});
/** 更新圆环状态 */
function updateCircle() {
const { currAngle, startAngle, thickness } = props;
const radius = halfSize.value;
const size = thickness;
const offset = size / 4;
const endAngleInner = Math.max(0, currAngle - 360) + startAngle;
const endAngleOuter = Math.min(360, currAngle) + startAngle;
// 内层圆环
state.dInner = arc({
x: radius,
y: radius,
R: radius - size * 2 - offset,
r: radius - size - offset,
start: startAngle,
end: endAngleInner,
});
// 外层圆环
state.dOuter = arc({
x: radius,
y: radius,
R: radius - size,
r: radius,
start: startAngle,
end: endAngleOuter,
});
}
onMounted(() => {
updateCircle();
});
</script>
<style lang="less">
.clock-color {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.color-circle {
filter: brightness(1);
transition: filter 0.25s;
}
.faded {
filter: brightness(0.6);
}
}
</style>

View File

@@ -0,0 +1,736 @@
<template>
<div
ref="clockElement"
:class="{
'clock-element': true,
'clock-rotation': clockState.isRotation,
}"
:style="elStyle"
@touchmove.prevent
>
<!-- 外部 -->
<div class="clock-outer bg-contain"></div>
<!-- 内部 -->
<div class="clock-inner">
<!-- 背景 -->
<div class="inner-bg bg-cover"></div>
<div class="inner-star bg-cover"></div>
<!-- 齿轮 -->
<div class="clock-gear">
<div class="gear-6"></div>
<div class="gear-5"></div>
<div class="gear-4"></div>
<div class="gear-3"></div>
<div class="gear-2"></div>
<div class="gear-1"></div>
</div>
<!-- 色环 -->
<clock-color
:curr-angle="upperRealAngle"
:start-angle="lowerPointer.viewAngle"
/>
<!-- 下层指针 -->
<div class="pointer-wrapper pointer-lower bg-contain">
<div class="pointer-content"></div>
</div>
<!-- 上层指针 -->
<div class="pointer-wrapper pointer-upper bg-contain">
<div
class="pointer-content"
@pointerdown="handleDragPointer('upper')"
></div>
</div>
</div>
<!-- 表盘 -->
<div class="clock-dial bg-contain">
<div class="time-icons">
<div
class="time-morning bg-contain"
style="--self-angle-1: 270; --self-angle-2: 270;"
></div>
<div
class="time-noon bg-contain"
style="--self-angle-1: 0; --self-angle-2: 360;"
></div>
<div
class="time-dusk bg-contain"
style="--self-angle-1: 90; --self-angle-2: 90;"
></div>
<div
class="time-night bg-contain"
style="--self-angle-1: 180; --self-angle-2: 180;"
></div>
</div>
</div>
<!-- 遮罩层用于阻止操作 -->
<div v-show="isAutoRotating" class="clock-mask"></div>
</div>
</template>
<script setup>
import {
computed,
onBeforeUnmount, onMounted,
reactive, ref,
watch,
} from 'vue';
import {
IMAGE_CLOCK_BG_INNER, IMAGE_CLOCK_BG_OUTER, IMAGE_CLOCK_DIAL,
IMAGE_CLOCK_GEAR_1, IMAGE_CLOCK_GEAR_4,
IMAGE_CLOCK_GEAR_5, IMAGE_CLOCK_GEAR_6,
IMAGE_CLOCK_PARTICLES, IMAGE_POINTER_LOWER, IMAGE_POINTER_UPPER,
IMAGE_TIME_ICON_DUSK, IMAGE_TIME_ICON_MORNING,
IMAGE_TIME_ICON_NIGHT, IMAGE_TIME_ICON_NOON,
isAutoRotating, isTimeExceeded, isTimeTooEarly,
timeCurrHour, timeCurrMinute, timeCurrValue,
timeDiffLabel, timeDiffLabelStill,
timeNewHour, timeNewMinute, timeNewValue,
} from './common-data';
import ClockColor from './ClockColor.vue';
const clockElement = ref(null);
/** 时钟状态 */
const clockState = reactive({
/** 指针是否正在旋转 */
isRotation: false,
/** 最新一次获取到的指针角度 */
lastAngle: 0,
/** 定时器 ID */
rotationWatcher: null,
});
/** 下层指针状态 */
const lowerPointer = reactive({
/** 视图角度,用于显示 */
viewAngle: 0,
});
/** 上层指针状态 */
const upperPointer = reactive({
/** 是否为第二圈 */
isSecond: false,
/** 上层指针与下层指针相差的角度 */
dataAngle: 0,
/** 视图角度,用于显示和交互 */
viewAngle: 0,
});
window.upperPointer = upperPointer;
/** 元素 CSS */
const elStyle = computed(() => {
return {
'--image-clock-bg-inner': IMAGE_CLOCK_BG_INNER,
'--image-clock-bg-outer': IMAGE_CLOCK_BG_OUTER,
'--image-clock-dial': IMAGE_CLOCK_DIAL,
'--image-clock-gear-1': IMAGE_CLOCK_GEAR_1,
'--image-clock-gear-4': IMAGE_CLOCK_GEAR_4,
'--image-clock-gear-5': IMAGE_CLOCK_GEAR_5,
'--image-clock-gear-6': IMAGE_CLOCK_GEAR_6,
'--image-clock-particles': IMAGE_CLOCK_PARTICLES,
'--image-pointer-lower': IMAGE_POINTER_LOWER,
'--image-pointer-upper': IMAGE_POINTER_UPPER,
'--image-time-icon-dusk': IMAGE_TIME_ICON_DUSK,
'--image-time-icon-morning': IMAGE_TIME_ICON_MORNING,
'--image-time-icon-night': IMAGE_TIME_ICON_NIGHT,
'--image-time-icon-noon': IMAGE_TIME_ICON_NOON,
'--pointer-lower-angle': `${lowerPointer.viewAngle}deg`,
'--pointer-lower-angle-value': lowerPointer.viewAngle,
'--pointer-upper-angle': `${upperPointer.viewAngle}deg`,
'--pointer-upper-angle-value': upperPointer.viewAngle,
};
});
/** 上层指针实际已旋转角度 */
const upperRealAngle = computed(() => {
const { dataAngle, isSecond } = upperPointer;
return (isSecond ? dataAngle + 360 : dataAngle);
});
/** 初始化定时器 */
function timerInit() {
// 指针旋转检测
clockState.rotationWatcher = setInterval(function () {
const currAngle = upperRealAngle.value;
const lastAngle = clockState.lastAngle;
clockState.isRotation = (Math.abs(currAngle - lastAngle) >= 5);
clockState.lastAngle = currAngle;
}, 250);
}
/** 重置定时器 */
function timerReset() {
clearInterval(clockState.rotationWatcher);
}
/**
* @description 获取元素中心坐标
* @param {HTMLElement} el
*/
function getCenterPoint(el) {
if (el) {
const rect = el.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
} else {
return null;
}
}
/** 处理拖拽上层指针 */
function handleDragPointer() {
const center = getCenterPoint(clockElement.value);
if (!center) {
return;
}
const centerX = center.x;
const centerY = center.y;
// 节流
let last = 0;
/**
* @description 处理光标移动
* @param {PointerEvent} ev
*/
let handleMove = function (ev) {
let curr = Date.now();
if (curr - last >= 20) {
last = curr;
} else {
return;
}
let { pageX, pageY } = ev;
let numX = pageX - centerX;
let numY = pageY - centerY;
// 计算两点弧度 & 转换为角度(-180 ~ 180
let calcAngle = Math.round(Math.atan2(numY, numX) * (180 / Math.PI));
// 转换为视图角度0 ~ 359
let viewAngle = calcAngle + (calcAngle >= -90 ? 90 : 450);
// 用于数据处理的角度
let dataAngle = 0;
// 起始角度偏移值
let offsetAngle = lowerPointer.viewAngle;
// 处理偏移,获取数据角度
if (viewAngle >= offsetAngle) {
dataAngle = viewAngle - offsetAngle;
} else if (viewAngle < offsetAngle) {
dataAngle = viewAngle + (360 - offsetAngle);
}
// 新角度与原角度的差值
let diff = dataAngle - upperPointer.dataAngle;
// 顺时针越过起始点
if (diff <= -180) {
if (upperPointer.isSecond) {
// 当前为第二圈,阻止移动
dataAngle = 360;
viewAngle = 360 + offsetAngle;
} else {
// 当前为第一圈,进入第二圈
upperPointer.isSecond = true;
}
}
// 逆时针越过起始点
if (diff >= 180) {
if (upperPointer.isSecond) {
// 当前为第二圈,返回第一圈
upperPointer.isSecond = false;
} else {
// 当前为第一圈,阻止移动
dataAngle = 0;
viewAngle = offsetAngle;
}
}
upperPointer.dataAngle = dataAngle;
upperPointer.viewAngle = viewAngle;
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', function () {
window.removeEventListener('pointermove', handleMove);
}, { once: true });
}
/** 确定当前选择的时间,下层指针旋转至上层指针的位置 */
function handleSubmitTime() {
let lowerAngleStart = lowerPointer.viewAngle;
let upperAngleStart = upperRealAngle.value;
let upperAngleCurr = upperAngleStart;
let timer = setInterval(() => {
// 每次 -2最小值为 0
upperAngleCurr = Math.max(0, upperAngleCurr - 2);
// 结束
if (upperAngleCurr === 0) {
clearInterval(timer);
isAutoRotating.value = false;
timeDiffLabelStill.value = '';
}
let upperAngleDiff = upperAngleStart - upperAngleCurr;
let lowerAngleCurr = lowerAngleStart + upperAngleDiff;
lowerPointer.viewAngle = lowerAngleCurr % 360;
upperPointer.dataAngle = upperAngleCurr % 360;
upperPointer.isSecond = (upperAngleCurr >= 360);
}, 50);
/** 更新状态 */
isAutoRotating.value = true;
// 固定时间差文本
timeDiffLabelStill.value = timeDiffLabel.value;
}
defineExpose({
handleSubmitTime,
});
// 检测角度变化,计算时间信息(自动旋转时)
watch(() => {
return lowerPointer.viewAngle;
}, (viewAngle) => {
// 转换为对应 24 小时的角度
let timeAngle = viewAngle + (viewAngle < 180 ? 180 : -180);
let timeValue = timeAngle / 15;
let currHour = Math.floor(timeValue);
let currMinute = Math.round((timeValue - currHour) * 60);
// 计算时间值
timeCurrHour.value = String(currHour).padStart(2, '0');
timeCurrMinute.value = String(currMinute).padStart(2, '0');
timeCurrValue.value = timeValue;
}, { immediate: true });
// 检测角度变化,计算时间信息(用户操作时)
watch(() => {
return upperPointer.dataAngle;
}, (dataAngle) => {
// 注15° / 小时
let isSecond = upperPointer.isSecond;
let currAngle = lowerPointer.viewAngle;
let viewAngle = upperPointer.viewAngle;
let diffAngle = dataAngle + (isSecond ? 360 : 0);
let diffAngle1 = 0; // +1 日角度差
let diffAngle2 = 0; // +2 日角度差
let diffLabel = '';
// 转换为对应 24 小时的角度
let timeAngle = viewAngle + (viewAngle < 180 ? 180 : -180);
let timeValue = timeAngle / 15;
let newHour = Math.floor(timeValue);
let newMinute = Math.round((timeValue - newHour) * 60);
if (currAngle < 180) {
diffAngle1 = 180 - currAngle;
diffAngle2 = diffAngle1 + 360;
} else {
diffAngle1 = 540 - currAngle; // 360 + 180
diffAngle2 = diffAngle1 + 360;
}
// 处理时间差信息
if (diffAngle < diffAngle1) {
diffLabel = '今日';
} else if (diffAngle < diffAngle2) {
diffLabel = '次日';
} else {
diffLabel = '+2日';
}
// 注:
// 若指针起始位置位于表盘左半边,
// 且拖拽指针旋转满 2 圈,
// 此时计算出的小时值会大于或等于 24。
if (newHour >= 24) {
newHour -= 24;
}
// 处理提示信息显示
isTimeTooEarly.value = diffAngle < 7.5;
isTimeExceeded.value = diffAngle === 720;
// 更新时间差信息
timeDiffLabel.value = diffLabel;
// 计算时间值
timeNewHour.value = String(newHour).padStart(2, '0');
timeNewMinute.value = String(newMinute).padStart(2, '0');
timeNewValue.value = timeValue;
}, { immediate: true });
onMounted(() => {
timerInit();
});
onBeforeUnmount(() => {
timerReset();
});
</script>
<style lang="less" scoped>
.bg-contain {
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.bg-cover {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.clock-element {
--pointer-lower-angle: 0deg;
--pointer-upper-angle: 0deg;
position: relative;
width: 32em;
height: 32em;
border-radius: 50%;
font-size: 12px;
overflow: hidden;
filter: brightness(1.1) saturate(0.9);
}
.clock-outer {
position: absolute;
top: -1%;
left: -1%;
width: 102%;
height: 102%;
background-image: var(--image-clock-bg-outer);
}
.clock-inner {
--size: 46%;
position: absolute;
top: calc(50% - var(--size) / 2);
left: calc(50% - var(--size) / 2);
width: var(--size);
height: var(--size);
border-radius: 50%;
background-color: #000;
overflow: hidden;
.inner-bg, .inner-star {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.inner-bg {
background-image: var(--image-clock-bg-inner);
animation: rotation-backward 60s linear infinite;
}
.inner-star {
background-image: var(--image-clock-particles);
}
}
.clock-gear {
--size: 120%;
--ratio: 1.55;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.6;
> div {
--ani-duration: 2s;
position: absolute;
animation-duration: var(--ani-duration);
animation-iteration-count: infinite;
animation-timing-function: linear;
transform: rotate(0);
will-change: transform;
&::after {
display: block;
content: "";
width: 100%;
height: 100%;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
animation-duration: calc(var(--ani-duration) / 12);
animation-iteration-count: infinite;
animation-name: inherit;
animation-play-state: paused;
animation-timing-function: linear;
}
}
.gear-1 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(50% - var(--size) / 2);
left: calc(50% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-1);
}
}
.gear-2 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(0% - var(--size) / 2);
left: calc(72% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-1);
}
}
.gear-3 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(77% - var(--size) / 2);
left: calc(100% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
opacity: 0.5;
&::after {
background-image: var(--image-clock-gear-1);
}
}
.gear-4 {
--angle-offset: 4deg;
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(77% - var(--size) / 2);
left: calc(100% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-forward;
&::after {
background-image: var(--image-clock-gear-4);
}
}
// 正常:约 74s / 圈
// 加速:约 7s / 圈
.gear-5 {
--ani-duration: calc(1s * (var(--gear-teeth) - 1) * var(--ratio));
--gear-teeth: 49;
top: calc(60% - var(--size) / 2);
left: calc(33% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-5);
}
}
.gear-6 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 49;
top: calc(42% - var(--size) / 2);
left: calc(62% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-6);
}
}
}
// 齿轮加速转动
.clock-rotation .clock-gear > div::after {
animation-play-state: running;
}
.pointer-wrapper {
--size: 180%;
position: absolute;
top: 50%;
left: 50%;
width: var(--size);
height: var(--size);
&.pointer-lower {
background-image: var(--image-pointer-lower);
transform: translate(-50%, -50%) rotate(var(--pointer-lower-angle));
}
&.pointer-upper {
background-image: var(--image-pointer-upper);
transform: translate(-50%, -50%) rotate(var(--pointer-upper-angle));
}
.pointer-content {
--width: 5%;
position: absolute;
left: calc(50% - var(--width) / 2);
width: var(--width);
height: 50%;
cursor: grab;
}
}
.clock-dial {
--size: 88%;
position: absolute;
top: calc(50% - var(--size) / 2);
left: calc(50% - var(--size) / 2);
width: var(--size);
height: var(--size);
background-image: var(--image-clock-dial);
pointer-events: none;
.time-icons {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
> div {
// [角度 1 相关计算]
// 计算指针角度与图标对应角度的差值(可能为负数)
--angle-1-diff: calc(var(--self-angle-1) - var(--pointer-lower-angle-value));
// 计算角度差值的绝对值
--angle-1-abs: ~"max(var(--angle-1-diff), var(--angle-1-diff) * -1)";
// 限制角度最大差值为 90
--angle-1-use: ~"min(90, var(--angle-1-abs))";
// [角度 2 相关计算,主要用于 360°]
// 计算指针角度与图标所在角度的差值(可能为负数)
--angle-2-diff: calc(var(--self-angle-2) - var(--pointer-lower-angle-value));
// 计算角度差值的绝对值
--angle-2-abs: ~"max(var(--angle-2-diff), var(--angle-2-diff) * -1)";
// 限制角度最大差值为 90
--angle-2-use: ~"min(90, var(--angle-2-abs))";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
// 根据角度计算透明度
// 注:计算两个角度的透明度,取最大值
opacity: ~"calc((90 - min(var(--angle-1-use), var(--angle-2-use))) / 90)";
}
.time-morning {
background-image: var(--image-time-icon-morning);
}
.time-noon {
background-image: var(--image-time-icon-noon);
}
.time-dusk {
background-image: var(--image-time-icon-dusk);
}
.time-night {
background-image: var(--image-time-icon-night);
}
}
}
.clock-mask {
position: absolute;
left: 0;
top: 0;
z-index: 100;
width: 100%;
height: 100%;
cursor: wait;
}
// 顺时针旋转
@keyframes rotation-forward {
0% {
transform: rotate(calc(0deg + var(--angle-offset, 0deg)));
}
100% {
transform: rotate(calc(360deg + var(--angle-offset, 0deg)));
}
}
// 逆时针旋转
@keyframes rotation-backward {
0% {
transform: rotate(calc(360deg + var(--angle-offset, 0deg)));
}
100% {
transform: rotate(calc(0deg + var(--angle-offset, 0deg)));
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="genshin-button">
<span
v-if="hasIcon"
class="btn-icon mdi"
:class="iconName"
:style="{ color: iconColor }"
></span>
<span class="btn-label">
<slot></slot>
</span>
</div>
</template>
<script setup>
defineProps({
hasIcon: {
type: Boolean,
default: true,
},
iconColor: {
type: String,
default: '#FFFFFF',
},
iconName: {
type: String,
default: 'mdi-help',
},
});
</script>
<style lang="less" scoped>
.genshin-button {
display: inline-block;
position: relative;
padding: 8px;
border-radius: 24px;
background-color: #ECE3D6;
color: #494246;
font-size: 16px;
font-weight: bold;
line-height: 1;
white-space: nowrap;
opacity: 1;
transition: opacity 0.25s;
cursor: pointer;
&:hover {
opacity: 0.5;
}
span {
vertical-align: middle;
}
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 50%;
background-color: #2D2D2D;
color: #FFFFFF;
}
.btn-label {
display: inline-block;
padding: 0 56px;
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="tool-detail-page has-radius">
<!-- 背景图片 -->
<div
class="page-bg-wrapper"
:style="{ '--curr-time': Number(timeCurrValue) }"
>
<div
:style="{
'--bg-url': IMAGE_BG_DUSK,
'--self-hour-1': 18,
'--self-hour-2': 18,
}"
></div>
<div
:style="{
'--bg-url': IMAGE_BG_MORNING,
'--self-hour-1': 6,
'--self-hour-2': 6,
}"
></div>
<div
:style="{
'--bg-url': IMAGE_BG_NIGHT,
'--self-hour-1': 0,
'--self-hour-2': 24,
}"
></div>
<div
:style="{
'--bg-url': IMAGE_BG_NOON,
'--self-hour-1': 12,
'--self-hour-2': 12,
}"
></div>
<div class="page-bg-mask"></div>
</div>
<!-- -->
<div class="page-column">
<time-info />
</div>
<!-- -->
<div class="page-column">
<!-- 时钟 -->
<clock-element ref="clockRef" />
<!-- 上限提示 -->
<div
:class="{
'is-hide': isAutoRotating,
'time-notice': true,
}"
>
<span
v-show="isTimeExceeded"
class="time-notice-text"
>时间到达上限</span>
</div>
<!-- 确认时间 -->
<div
:class="{
'is-hide': isAutoRotating,
'time-submit': true,
}"
>
<span
v-if="isTimeTooEarly"
class="time-notice-text"
>时间少于30分钟</span>
<genshin-button
v-else
icon-color="#FFC107"
icon-name="mdi-circle-outline"
@click="handleConfirm"
>确认</genshin-button>
</div>
</div>
</div>
</template>
<script setup>
import {
ref,
} from 'vue';
import {
IMAGE_BG_DUSK,
IMAGE_BG_MORNING,
IMAGE_BG_NIGHT,
IMAGE_BG_NOON,
isAutoRotating,
isTimeTooEarly,
isTimeExceeded,
timeCurrValue,
} from './common-data';
import ClockElement from './ClockElement.vue';
import GenshinButton from './GenshinButton.vue';
import TimeInfo from './TimeInfo.vue';
/**
* @desc 时钟元素 ref
* @type {VueRef<InstanceType<ClockElement>>}
*/
const clockRef = ref(null);
/** 处理点击确认按钮 */
function handleConfirm() {
let el = clockRef.value;
if (el) {
el.handleSubmitTime();
}
}
</script>
<style lang="less" scoped>
.tool-detail-page {
position: relative;
background-color: #000;
font-size: 16px;
text-align: center;
white-space: nowrap;
}
.page-bg-wrapper {
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 100%;
> div {
// [小时 1 相关计算]
// 计算当前小时与背景对应小时的差值(可能为负数)
--hour-1-diff: calc(var(--self-hour-1) - var(--curr-time));
// 计算小时差值的绝对值
--hour-1-abs: ~"max(var(--hour-1-diff), var(--hour-1-diff) * -1)";
// 限制小时最大差值为 6
--hour-1-use: ~"min(6, var(--hour-1-abs))";
// [小时 2 相关计算,主要用于 0 点]
// 计算当前小时与背景所在小时的差值(可能为负数)
--hour-2-diff: calc(var(--self-hour-2) - var(--curr-time));
// 计算小时差值的绝对值
--hour-2-abs: ~"max(var(--hour-2-diff), var(--hour-2-diff) * -1)";
// 限制小时最大差值为 6
--hour-2-use: ~"min(6, var(--hour-2-abs))";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-image: var(--bg-url);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
// 根据小时计算透明度
// 注:计算两个小时的透明度,取最大值
opacity: ~"calc((6 - min(var(--hour-1-use), var(--hour-2-use))) / 6)";
}
.page-bg-mask {
background-color: #000;
opacity: 0.75;
}
}
.page-column {
display: inline-flex;
align-items: center;
flex-direction: column;
justify-content: center;;
position: relative;
z-index: 10;
vertical-align: middle;
height: 100%;
white-space: initial;
&:not(:first-child) {
margin-left: 64px;
}
}
.time-notice, .time-submit {
display: flex;
height: 40px;
opacity: 1;
visibility: visible;
transition: opacity 0.25s, visibility 0s 0s;
// 注:
// visibility 动画时长用于等待 opacity 动画过渡完毕
&.is-hide {
visibility: hidden;
opacity: 0;
transition: opacity 0.25s, visibility 0.25s 0s;
}
}
.time-notice {
margin-top: 16px;
}
.time-notice-text {
margin: auto 0;
font-weight: bold;
color: #FFF;
opacity: 0.5;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<div
class="time-info"
:style="{ '--image-time-info-arrow': IMAGE_TIME_INFO_ARROW }"
>
<!-- 装饰元素 -->
<div class="arrow-element"></div>
<!-- 标题 -->
<div class="time-title time-title-current">当前时间</div>
<!-- 当前时间值 -->
<div class="time-value time-value-current">
<span>{{ timeCurrHour }}</span>
<span>:</span>
<span>{{ timeCurrMinute }}</span>
</div>
<!-- 三角形图标 -->
<div class="triangle-icon">
<span class="mdi mdi-triangle-small-down"></span>
</div>
<!-- 标题 -->
<div class="time-title time-title-target">调整到</div>
<!-- 三角形图标 -->
<div class="triangle-icon">
<span class="mdi mdi-triangle-small-down"></span>
</div>
<!-- 目标时间值 -->
<div class="time-value time-value-target">
<span>{{ timeNewHour }}</span>
<span>:</span>
<span>{{ timeNewMinute }}</span>
</div>
<!-- 时间差 -->
<div class="time-diff">
<span>{{ timeDiffLabelStill || timeDiffLabel }}</span>
</div>
<!-- 装饰元素 -->
<div class="arrow-element"></div>
</div>
</template>
<script setup>
import {
IMAGE_TIME_INFO_ARROW,
timeCurrHour, timeCurrMinute,
timeDiffLabel, timeDiffLabelStill,
timeNewHour, timeNewMinute,
} from './common-data';
</script>
<style lang="less" scoped>
.time-info {
display: flex;
align-items: center;
flex-direction: column;
width: 8em;
font-size: 16px;
font-weight: bold;
line-height: 1;
}
.arrow-element {
margin: 1.75em 0;
width: 100%;
height: 1.5em;
background-image: var(--image-time-info-arrow);
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.time-diff {
display: flex;
width: 3.5em;
height: 1.75em;
border-radius: 1.75em;
background-color: #282C33;
color: rgba(255, 255, 255, 0.75);
font-size: 0.75em;
span {
margin: auto;
}
}
.time-title {
color: #AFA189;
}
.time-title-target {
margin: 0.25em 0;
}
.time-value {
color: #ECE3D6;
font-size: 1.25em;
}
.time-value-current {
margin-top: 1.25em;
margin-bottom: 0.25em;
}
.time-value-target {
margin-top: 0.25em;
margin-bottom: 0.5em;
}
.triangle-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1em;
height: 1em;
color: #FFF;
font-size: 1.5em;
opacity: 0.2;
}
</style>

View File

@@ -0,0 +1,56 @@
import { ref } from "vue";
export const IMAGE_BASE = `https://c.frost-zx.top/data/static/image/genshin-impact-clock`;
export const IMAGE_BG_DUSK = `url("${IMAGE_BASE}/bg_dusk.png")`;
export const IMAGE_BG_MORNING = `url("${IMAGE_BASE}/bg_morning.png")`;
export const IMAGE_BG_NIGHT = `url("${IMAGE_BASE}/bg_night.png")`;
export const IMAGE_BG_NOON = `url("${IMAGE_BASE}/bg_noon.png")`;
export const IMAGE_CLOCK_BG_INNER = `url("${IMAGE_BASE}/clock_bg_inner.png")`;
export const IMAGE_CLOCK_BG_OUTER = `url("${IMAGE_BASE}/clock_bg_outer.png")`;
export const IMAGE_CLOCK_DIAL = `url("${IMAGE_BASE}/clock_dial.png")`;
export const IMAGE_CLOCK_GEAR_1 = `url("${IMAGE_BASE}/clock_gear_1.png")`;
export const IMAGE_CLOCK_GEAR_4 = `url("${IMAGE_BASE}/clock_gear_4.png")`;
export const IMAGE_CLOCK_GEAR_5 = `url("${IMAGE_BASE}/clock_gear_5.png")`;
export const IMAGE_CLOCK_GEAR_6 = `url("${IMAGE_BASE}/clock_gear_6.png")`;
export const IMAGE_CLOCK_PARTICLES = `url("${IMAGE_BASE}/clock_particles.gif")`;
export const IMAGE_POINTER_LOWER = `url("${IMAGE_BASE}/pointer_lower.png")`;
export const IMAGE_POINTER_UPPER = `url("${IMAGE_BASE}/pointer_upper.png")`;
export const IMAGE_TIME_ICON_DUSK = `url("${IMAGE_BASE}/time_icon_dusk.png")`;
export const IMAGE_TIME_ICON_MORNING = `url("${IMAGE_BASE}/time_icon_morning.png")`;
export const IMAGE_TIME_ICON_NIGHT = `url("${IMAGE_BASE}/time_icon_night.png")`;
export const IMAGE_TIME_ICON_NOON = `url("${IMAGE_BASE}/time_icon_noon.png")`;
export const IMAGE_TIME_INFO_ARROW = `url("${IMAGE_BASE}/time_info_arrow.png")`;
export const IMAGE_TIME_ZONE_COLOR = `${IMAGE_BASE}/time_zone_color.png`;
/** 是否正在自动旋转 */
export const isAutoRotating = ref(false);
/** 是否时间少于 30 分钟 */
export const isTimeTooEarly = ref(false);
/** 是否时间到达上限 */
export const isTimeExceeded = ref(false);
/** 当前时 */
export const timeCurrHour = ref('00');
/** 当前分 */
export const timeCurrMinute = ref('00');
/** 当前时分 */
export const timeCurrValue = ref(0);
/** 时间差(动态)*/
export const timeDiffLabel = ref('');
/** 时间差(固定)*/
export const timeDiffLabelStill = ref('');
/** 新的时 */
export const timeNewHour = ref('00');
/** 新的分 */
export const timeNewMinute = ref('00');
/** 新的时分 */
export const timeNewValue = ref(0);

View File

@@ -0,0 +1,222 @@
<template>
<div
ref="selfRef"
class="tool-detail-page"
:class="{
'has-radius': !fullscreen.isFullscreen.value,
'is-dark-color': isDarkColor,
'is-faded': isFaded,
'is-on': wakeLock.isActive.value,
}"
:style="{
'--bg-color': bgColor,
}"
@click="isFaded = false"
>
<div class="controls">
<div class="title">开关 / Switch</div>
<n-switch
:value="wakeLock.isActive.value"
size="medium"
:round="false"
@update:value="handleToggleWakeLock"
>
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
<div class="title">背景颜色 / Background Color</div>
<n-color-picker
v-model:value="bgColor"
size="medium"
:modes="['hex']"
:show-preview="true"
:swatches="[
'#000000',
'#252525',
'#505050',
'#808080',
'#FFFFFF',
]"
/>
<div
v-show="fullscreen.isSupported"
class="title"
>切换全屏 / Toggle Fullscreen</div>
<n-switch
v-show="fullscreen.isSupported"
size="medium"
:round="false"
:value="fullscreen.isFullscreen.value"
@update:value="handleToggleFullscreen"
>
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
<div class="title">隐藏界面 / Hide UI</div>
<n-switch
v-model:value="isFaded"
size="medium"
:round="false"
@click.stop
>
<template #checked>隐藏</template>
<template #unchecked>显示</template>
</n-switch>
</div>
</div>
</template>
<script setup>
import {
NColorPicker, NSwitch,
} from 'naive-ui';
import {
computed, ref, onBeforeUnmount,
} from 'vue';
import {
useFullscreen, useWakeLock,
} from '@vueuse/core';
import {
$message,
} from '@/assets/js/naive-ui';
import {
colorHexToGrayLevel,
} from '@/assets/js/utils';
/** 背景颜色 */
const bgColor = ref('#505050');
/** 是否为深色 */
const isDarkColor = computed(() => {
let color = bgColor.value;
let level = colorHexToGrayLevel(color);
return level < 192;
});
/** 是否隐藏内容 */
const isFaded = ref(false);
/**
* @desc 自身元素 ref
* @type {VueRef<HTMLElement>}
*/
const selfRef = ref(null);
/** 全屏控制 */
const fullscreen = useFullscreen(selfRef, {
autoExit: true,
});
/** 唤醒锁 */
const wakeLock = useWakeLock();
/** 处理切换全屏 */
function handleToggleFullscreen() {
fullscreen.toggle().then(() => {
if (fullscreen.isFullscreen.value) {
$message.success('进入全屏');
} else {
$message.success('退出全屏');
}
}).catch((error) => {
console.error('切换全屏失败:',);
console.error(error);
$message.error('切换全屏失败');
});
}
/** 处理切换唤醒锁 */
function handleToggleWakeLock(isActive = false) {
if (!wakeLock.isSupported) {
$message.warning('当前浏览器不支持该功能');
return Promise.resolve(false);
}
if (isActive) {
return wakeLock.request('screen').then(() => {
$message.success('开启');
}).catch((error) => {
console.error('请求唤醒锁失败:',);
console.error(error);
$message.error('请求唤醒锁失败');
});
} else {
return wakeLock.release().then(() => {
$message.success('关闭');
}).catch((error) => {
console.error('释放唤醒锁失败:',);
console.error(error);
$message.error('释放唤醒锁失败');
});
}
}
onBeforeUnmount(() => {
if (wakeLock.isActive.value) {
handleToggleWakeLock(false);
}
});
</script>
<style lang="less" scoped>
.tool-detail-page {
display: flex;
background-color: inherit;
transition: all 0.25s;
user-select: none;
&.is-faded > * {
opacity: 0;
}
&.is-on {
background-color: var(--bg-color);
&.is-dark-color {
color: #FFF;
}
}
> * {
transition: opacity 0.25s;
}
}
.controls {
margin: auto;
padding-bottom: 48px;
text-align: center;
}
.title {
margin-top: 48px;
margin-bottom: 16px;
font-size: 24px;
}
:deep(.n-color-picker) {
width: 36px;
}
:deep(.n-color-picker-trigger__value) {
color: transparent !important;
}
</style>

View File

@@ -1,9 +1,89 @@
<template> <template>
<div class="tool-detail-page"></div> <div class="tool-detail-page">
<!-- 设置 -->
<n-card size="small" title="设置">
<n-form
class="form-no-feedback config-inputs"
label-align="left"
label-placement="top"
label-width="auto"
>
<n-form-item label="目标链接">
<n-input
v-model:value="data.url"
placeholder="请输入 URL需要包含协议部分https://"
type="text"
></n-input>
</n-form-item>
<n-form-item label="窗口宽度">
<n-input-number
v-model:value="data.width"
:min="0"
:max="9999999"
:precision="0"
:step="1"
></n-input-number>
</n-form-item>
<n-form-item label="窗口高度">
<n-input-number
v-model:value="data.height"
:min="0"
:max="9999999"
:precision="0"
:step="1"
></n-input-number>
</n-form-item>
</n-form>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-button
type="primary"
@click="openWindow"
>打开窗口</n-button>
</n-card>
</div>
</template> </template>
<script setup> <script setup>
import {
NButton, NCard,
NForm, NFormItem, NInput, NInputNumber,
} from 'naive-ui';
import {
reactive,
} from 'vue';
/** 数据 */
const data = reactive({
url: '',
width: 400,
height: 300,
});
/** 打开窗口 */
function openWindow() {
let link = data.url || 'https://github.com/Frost-ZX';
let width = data.width ?? 400;
let height = data.height ?? 300;
let features = `height=${height}, width=${width}, toolbar=no, menubar=no, scrollbars=yes, resizable=yes, location=yes, status=yes`;
window.open(link, '_blank', features);
}
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.config-inputs .n-form-item {
max-width: 480px;
}
</style> </style>

View File

@@ -15,6 +15,19 @@
<!-- 标题 --> <!-- 标题 -->
<span>{{ routeTitle }}</span> <span>{{ routeTitle }}</span>
<!-- 占位 -->
<div class="placeholder"></div>
<!-- 新窗口打开 -->
<n-button
v-show="isToolDetail"
class="back-button"
:text="true"
@click="handleOpenNewWindow"
>
<span class="mdi mdi-open-in-new"></span>
</n-button>
</div> </div>
<div class="app-view-content is-transparent"> <div class="app-view-content is-transparent">
@@ -120,6 +133,18 @@ function handleCloseTool() {
}); });
} }
/** 在新窗口中打开当前工具 */
function handleOpenNewWindow() {
let width = window.innerWidth ?? 400;
let height = window.innerHeight ?? 300;
let url = location.href;
let features = `height=${height}, width=${width}, toolbar=no, menubar=no, scrollbars=yes, resizable=yes, location=yes, status=yes`;
window.open(url, '_blank', features);
}
/** /**
* @description 打开工具 * @description 打开工具
* @param {ToolboxItem} data * @param {ToolboxItem} data
@@ -136,6 +161,12 @@ function handleOpenTool(data) {
margin-right: 0.5em; margin-right: 0.5em;
font-size: 24px; font-size: 24px;
} }
.new-window-button {
font-size: 24px;
cursor: pointer;
}
.tool-list { .tool-list {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -192,8 +223,18 @@ function handleOpenTool(data) {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
> .n-card:not(:first-child) { &.has-radius {
margin-top: 20px; border-radius: 8px;
}
> .n-card {
&:not(:first-child) {
margin-top: 20px;
}
> .n-card__content {
height: 0;
}
} }
.form-no-feedback .n-form-item-feedback-wrapper, .form-no-feedback .n-form-item-feedback-wrapper,