Compare commits
43 Commits
Author | SHA1 | Date | |
---|---|---|---|
855695bc13 | |||
d9869fcb87 | |||
2ecddb17aa | |||
73837d517d | |||
8e6a00f610 | |||
ffbf926c9f | |||
93fea94c3a | |||
eeb72097e5 | |||
c2a496b228 | |||
f27a869d6a | |||
084afc0cef | |||
abb1fed1ef | |||
85a7f66af4 | |||
d616564a55 | |||
ba238a44d9 | |||
55f3e74cbf | |||
173cada6a4 | |||
fb655552b3 | |||
9aa47a6b3b | |||
b338b91e5a | |||
9034790421 | |||
204930e9e5 | |||
b46b707329 | |||
3e526045bf | |||
dab7d938f7 | |||
6e60f4a67c | |||
6fdc510f60 | |||
3b8a119ab0 | |||
cd1c82082e | |||
d836cecb91 | |||
5857af36dd | |||
9c8712ae26 | |||
bc35da29af | |||
7291dedeaf | |||
0631f3ae24 | |||
3452dc748a | |||
e1c02b68a3 | |||
a789486823 | |||
0228151136 | |||
56ba451325 | |||
c4537b1103 | |||
5ddb70e1ab | |||
b90611e310 |
38
CHANGELOG.md
38
CHANGELOG.md
@@ -1,5 +1,43 @@
|
||||
# 更新日志
|
||||
|
||||
## [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
|
||||
|
39
package.json
39
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frost-navigation",
|
||||
"description": "Frost Navigation",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,29 +12,30 @@
|
||||
"dependencies": {
|
||||
"@frost-utils/javascript": "^2.1.3",
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@vueuse/core": "^11.0.3",
|
||||
"axios": "^1.7.5",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"lunisolar": "^2.5.0",
|
||||
"mathjs": "^13.1.1",
|
||||
"naive-ui": "^2.39.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lunisolar": "^2.5.1",
|
||||
"mathjs": "^14.2.0",
|
||||
"naive-ui": "^2.41.0",
|
||||
"radash": "^12.1.0",
|
||||
"uuid": "^10.0.0",
|
||||
"vue": "^3.4.29",
|
||||
"vue-router": "^4.3.3",
|
||||
"zxing-wasm": "^1.2.12"
|
||||
"uuid": "^11.0.5",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"zxing-wasm": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/node": "^20.14.5",
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-legacy": "^5.4.2",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.23.0",
|
||||
"less": "^4.2.0",
|
||||
"vite": "^5.3.1"
|
||||
"@vitejs/plugin-legacy": "^6.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vitejs/plugin-vue-jsx": "^4.1.1",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"less": "^4.2.2",
|
||||
"vite": "^6.0.11"
|
||||
}
|
||||
}
|
||||
|
3455
pnpm-lock.yaml
generated
3455
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
window['NAV_LINK_DATE'] = '2024-09-01';
|
||||
window['NAV_LINK_DATE'] = '2025-02-03';
|
||||
|
||||
window['NAV_LINK_LIST'] = [
|
||||
{
|
||||
@@ -2558,8 +2558,8 @@ window['NAV_LINK_LIST'] = [
|
||||
children: [
|
||||
{
|
||||
title: 'Minecraft Wiki',
|
||||
date: '2021-05-09',
|
||||
url: 'https://minecraft.fandom.com/zh/',
|
||||
date: '2025-02-03',
|
||||
url: 'https://zh.minecraft.wiki/',
|
||||
},
|
||||
{
|
||||
title: 'Minecraft Wiki(哔哩哔哩)',
|
||||
@@ -2568,8 +2568,8 @@ window['NAV_LINK_LIST'] = [
|
||||
},
|
||||
{
|
||||
title: 'Minecraft 插件百科',
|
||||
date: '2021-02-08',
|
||||
url: 'http://mineplugin.org/',
|
||||
date: '2025-02-03',
|
||||
url: 'https://mineplugin.org/',
|
||||
},
|
||||
{
|
||||
title: 'Minecraft 光影百科',
|
||||
@@ -2802,6 +2802,7 @@ window['NAV_LINK_LIST'] = [
|
||||
{
|
||||
title: 'Minecraft-HK Community',
|
||||
date: '2021-02-08',
|
||||
isInvalid: true,
|
||||
url: 'http://forum.minecraft-hk.com/',
|
||||
},
|
||||
{
|
||||
@@ -2885,10 +2886,15 @@ window['NAV_LINK_LIST'] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'CSSBattle',
|
||||
date: '2022-03-20',
|
||||
desc: 'CSS 代码高尔夫(code-golfing)游戏',
|
||||
url: 'https://cssbattle.dev/',
|
||||
title: '其他',
|
||||
children: [
|
||||
{
|
||||
title: 'CSSBattle',
|
||||
date: '2022-03-20',
|
||||
desc: 'CSS 代码高尔夫(code-golfing)游戏',
|
||||
url: 'https://cssbattle.dev/',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
2
public/wasm/README.txt
Normal file
2
public/wasm/README.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
zxing_full.wasm
|
||||
zxing-wasm v2.0.1
|
BIN
public/wasm/zxing_full.wasm
Normal file
BIN
public/wasm/zxing_full.wasm
Normal file
Binary file not shown.
119
src/App.vue
119
src/App.vue
@@ -1,25 +1,9 @@
|
||||
<template>
|
||||
<n-config-provider
|
||||
:date-locale="configProviderProps.dateLocale"
|
||||
:hljs="hljs"
|
||||
:inline-theme-disabled="configProviderProps.inlineThemeDisabled"
|
||||
: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"
|
||||
>
|
||||
|
||||
@@ -51,14 +35,15 @@ import {
|
||||
configProviderProps,
|
||||
} 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';
|
||||
|
||||
/** 主题变量配置 */
|
||||
const themeOverrides = configProviderProps.themeOverrides;
|
||||
|
||||
/** 主题变量配置 - common */
|
||||
const themeCommon = themeOverrides.common;
|
||||
|
||||
/** 默认主题变量 */
|
||||
const themeVars = useThemeVars();
|
||||
|
||||
@@ -68,26 +53,94 @@ const themeVars = useThemeVars();
|
||||
*/
|
||||
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 键时
|
||||
if (event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 排除输入框元素
|
||||
if (
|
||||
element instanceof HTMLInputElement &&
|
||||
['password', 'text', 'textarea'].includes(element.type)
|
||||
) {
|
||||
return;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
|
||||
let element = elements[i];
|
||||
|
||||
// 获取元素 class 信息
|
||||
if (element instanceof HTMLElement) {
|
||||
classValue = element.classList.value;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 排除输入框元素
|
||||
if (element instanceof HTMLInputElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 排除指定元素
|
||||
if (classValue && classRegExp.test(classValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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(() => {
|
||||
initCssVars();
|
||||
initHighlightJs();
|
||||
window.addEventListener('contextmenu', handleContextMenu);
|
||||
});
|
||||
|
||||
@@ -97,18 +150,6 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
width: var(--scrollbar-size);
|
||||
|
28
src/assets/js/local-storage.js
Normal file
28
src/assets/js/local-storage.js
Normal 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
412
src/assets/js/qr-code.js
Normal 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 '';
|
||||
});
|
||||
|
||||
}
|
@@ -5,12 +5,8 @@ import {
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
SKEY_SEARCH_ENGINE_NAME,
|
||||
} from '@/config/storage';
|
||||
|
||||
import {
|
||||
useLocalStorage,
|
||||
} from '@vueuse/core';
|
||||
storeSearchView,
|
||||
} from './local-storage';
|
||||
|
||||
import {
|
||||
$message,
|
||||
@@ -37,7 +33,7 @@ import icon_zhihu from '@/assets/website-icon/zhihu.svg';
|
||||
/** 打开搜索结果页面 */
|
||||
export function openSearchResult() {
|
||||
|
||||
let engine = searchEngineName.value;
|
||||
let engine = storeSearchView.searchEngineName.value;
|
||||
let keyword = searchKeyword.value;
|
||||
let baseURL = '';
|
||||
let useURL = '';
|
||||
@@ -221,8 +217,5 @@ export const searchEngineList = [
|
||||
},
|
||||
];
|
||||
|
||||
/** 搜索引擎名称 */
|
||||
export const searchEngineName = useLocalStorage(SKEY_SEARCH_ENGINE_NAME, '必应');
|
||||
|
||||
/** 搜索关键词 */
|
||||
export const searchKeyword = ref('');
|
||||
|
@@ -12,17 +12,6 @@ export const toolList = [
|
||||
title: '计算',
|
||||
enabled: true,
|
||||
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',
|
||||
component: 'Calculation/CalcRatio',
|
||||
@@ -34,35 +23,68 @@ export const toolList = [
|
||||
version: '1',
|
||||
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',
|
||||
title: '转换',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
items: [
|
||||
{
|
||||
id: 'convert-html-entities',
|
||||
component: 'Conversion/ConvertHtmlEntities',
|
||||
title: '转换 HTML 实体',
|
||||
id: 'base64-encode-decode',
|
||||
component: 'Conversion/Base64StringEncodeDecode',
|
||||
title: 'Base64 字符串编码 / 解码',
|
||||
iconClass: 'mdi mdi-swap-horizontal',
|
||||
desc: '',
|
||||
desc: '处理 Base64 编码的字符串。',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
version: '0',
|
||||
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',
|
||||
component: 'Conversion/UrlEncodeDecode',
|
||||
title: 'URL 编码 / 解码',
|
||||
iconClass: 'mdi mdi-swap-horizontal',
|
||||
desc: '',
|
||||
desc: '处理 URL 编码的字符串。',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
version: '0',
|
||||
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',
|
||||
component: 'Conversion/ConvertTextStructure',
|
||||
@@ -75,11 +97,11 @@ export const toolList = [
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'convert-timestamp',
|
||||
component: 'Conversion/ConvertTimestamp',
|
||||
title: 'Unix 时间戳转换',
|
||||
id: 'convert-html-entities',
|
||||
component: 'Conversion/ConvertHtmlEntities',
|
||||
title: '转换 HTML 实体',
|
||||
iconClass: 'mdi mdi-swap-horizontal',
|
||||
desc: '时间戳转时间 / 时间转时间戳',
|
||||
desc: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
version: '0',
|
||||
@@ -90,12 +112,12 @@ export const toolList = [
|
||||
{
|
||||
id: 'edit-tools',
|
||||
title: '编辑',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
items: [
|
||||
{
|
||||
id: 'csv-editor',
|
||||
component: 'Edit/CsvEditor',
|
||||
title: 'CSV 编辑工具',
|
||||
title: 'CSV 编辑',
|
||||
iconClass: 'mdi mdi-table-edit',
|
||||
desc: '查看或编辑 CSV 文件',
|
||||
createdAt: '',
|
||||
@@ -103,6 +125,17 @@ export const toolList = [
|
||||
version: '0',
|
||||
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: '生成',
|
||||
enabled: true,
|
||||
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',
|
||||
component: 'Generator/GenerateUrls',
|
||||
@@ -137,8 +192,30 @@ export const toolList = [
|
||||
{
|
||||
id: 'minecraft-tools',
|
||||
title: 'Minecraft',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
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',
|
||||
component: 'Minecraft/CalcChunkLocation',
|
||||
@@ -161,34 +238,23 @@ export const toolList = [
|
||||
version: '0',
|
||||
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',
|
||||
title: '网络',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
items: [
|
||||
{
|
||||
id: 'websocket-test-tool',
|
||||
component: 'Network/WebSocketTestTool',
|
||||
title: 'WebSocket',
|
||||
title: 'WebSocket 测试',
|
||||
iconClass: 'mdi mdi-connection',
|
||||
desc: 'WebSocket 测试工具',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
version: '0',
|
||||
enabled: false,
|
||||
desc: '连接 WebSocket 服务端,发送和接收消息。',
|
||||
createdAt: '2024-12-01',
|
||||
updatedAt: '2024-12-01',
|
||||
version: '1',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -197,23 +263,12 @@ export const toolList = [
|
||||
title: '其他',
|
||||
enabled: true,
|
||||
items: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: 'keep-screen-on',
|
||||
component: 'Other/KeepScreenOn',
|
||||
title: '保持亮屏',
|
||||
iconClass: 'mdi mdi-monitor',
|
||||
desc: '保持屏幕开启,不息屏,不休眠',
|
||||
desc: '保持屏幕开启,不息屏,不休眠。',
|
||||
createdAt: '2024-10-11',
|
||||
updatedAt: '2024-10-13',
|
||||
version: '2',
|
||||
@@ -222,25 +277,36 @@ export const toolList = [
|
||||
{
|
||||
id: 'open-new-window',
|
||||
component: 'Other/OpenNewWindow',
|
||||
title: '新窗口(小窗)中打开',
|
||||
title: '新窗口中打开',
|
||||
iconClass: 'mdi mdi-window-maximize',
|
||||
desc: '从新的小窗口中打开指定的链接(仅支持 PC 端浏览器)',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
version: '0',
|
||||
enabled: false,
|
||||
desc: '从新的小窗口中打开指定的链接(仅支持 PC 端浏览器)。',
|
||||
createdAt: '2025-02-04',
|
||||
updatedAt: '2025-02-04',
|
||||
version: '1',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'run-javascript',
|
||||
component: 'Other/RunJavaScript',
|
||||
title: '执行 JavaScript',
|
||||
iconClass: 'mdi mdi-code-braces',
|
||||
desc: '执行简单的 JavaScript 代码片段',
|
||||
desc: '执行简单的 JavaScript 代码片段。',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
version: '0',
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@@ -4,6 +4,10 @@ import {
|
||||
description as appDesc,
|
||||
} from '@package-json';
|
||||
|
||||
import {
|
||||
v4 as uuidV4,
|
||||
} from 'uuid';
|
||||
|
||||
/** 将十六进制颜色值转为灰度值 */
|
||||
export function colorHexToGrayLevel(hex = '') {
|
||||
|
||||
@@ -34,6 +38,12 @@ export function colorHexToRgb(hex = '') {
|
||||
|
||||
}
|
||||
|
||||
/** 获取 V4 UUID */
|
||||
export function getUuidV4(noSplit = false) {
|
||||
let uuid = uuidV4();
|
||||
return noSplit ? uuid.replace(/-/g, '') : uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 更新页面标题
|
||||
* @param {string} title
|
||||
|
@@ -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';
|
@@ -27,18 +27,18 @@
|
||||
collapse-mode="width"
|
||||
show-trigger="arrow-circle"
|
||||
:bordered="true"
|
||||
:collapsed="isCollapsed"
|
||||
:collapsed="storeNavView.isAsideCollapsed.value"
|
||||
:collapsed-width="64"
|
||||
:native-scrollbar="false"
|
||||
:scrollbar-props="{ trigger: 'hover' }"
|
||||
:width="240"
|
||||
@collapse="isCollapsed = true"
|
||||
@expand="isCollapsed = false"
|
||||
@collapse="storeNavView.isAsideCollapsed.value = true"
|
||||
@expand="storeNavView.isAsideCollapsed.value = false"
|
||||
>
|
||||
<n-menu
|
||||
v-model:value="navLinksTitle"
|
||||
mode="vertical"
|
||||
:collapsed="isCollapsed"
|
||||
:collapsed="storeNavView.isAsideCollapsed.value"
|
||||
:collapsed-icon-size="24"
|
||||
:collapsed-width="64"
|
||||
:icon-size="24"
|
||||
@@ -53,7 +53,7 @@
|
||||
<div class="right-content-header">
|
||||
<n-input-group>
|
||||
<n-select
|
||||
v-model:value="searchType"
|
||||
v-model:value="storeNavView.searchType.value"
|
||||
class="search-type"
|
||||
:options="searchTypes"
|
||||
/>
|
||||
@@ -144,10 +144,8 @@ import {
|
||||
} from '@/config/modules';
|
||||
|
||||
import {
|
||||
SKEY_NAV_LINK_ASIDE_COLLAPSED,
|
||||
SKEY_NAV_LINK_CATEGORY,
|
||||
SKEY_NAV_LINK_SEARCH_TYPE,
|
||||
} from '@/config/storage';
|
||||
storeNavView,
|
||||
} from '@/assets/js/local-storage';
|
||||
|
||||
import {
|
||||
$dialog, $message,
|
||||
@@ -172,12 +170,6 @@ const detailDrawer = reactive({
|
||||
|
||||
});
|
||||
|
||||
/** 分类列表是否折叠 */
|
||||
const isCollapsed = useLocalStorage(
|
||||
SKEY_NAV_LINK_ASIDE_COLLAPSED,
|
||||
false
|
||||
);
|
||||
|
||||
/** 完整的链接列表 */
|
||||
const navLinksAll = formatNavLinks(true);
|
||||
|
||||
@@ -206,12 +198,6 @@ const navLinksTitle = shallowRef('');
|
||||
/** 搜索关键词 */
|
||||
const searchKeyword = shallowRef('');
|
||||
|
||||
/** 搜索类型 */
|
||||
const searchType = useLocalStorage(
|
||||
SKEY_NAV_LINK_SEARCH_TYPE,
|
||||
'all'
|
||||
);
|
||||
|
||||
/**
|
||||
* @desc 搜索类型列表
|
||||
* @type { import('naive-ui').SelectOption[] }
|
||||
@@ -235,7 +221,7 @@ function changeList(data = null) {
|
||||
if (data) {
|
||||
useData = data;
|
||||
} else {
|
||||
storedKey = localStorage.getItem(SKEY_NAV_LINK_CATEGORY)
|
||||
storedKey = storeNavView.currentCategory.value;
|
||||
}
|
||||
|
||||
if (storedKey) {
|
||||
@@ -249,17 +235,15 @@ function changeList(data = null) {
|
||||
}
|
||||
|
||||
if (useData) {
|
||||
localStorage.setItem(SKEY_NAV_LINK_CATEGORY, useData.title);
|
||||
storeNavView.currentCategory.value = useData.title;
|
||||
navLinksCurr.value = useData.children;
|
||||
navLinksTitle.value = useData.title;
|
||||
} else {
|
||||
localStorage.setItem(SKEY_NAV_LINK_CATEGORY, '');
|
||||
storeNavView.currentCategory.value = '';
|
||||
navLinksCurr.value = [];
|
||||
navLinksTitle.value = '';
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,7 +328,7 @@ function treeDataFilter(pattern = '', node = null) {
|
||||
pattern = pattern.toLowerCase();
|
||||
}
|
||||
|
||||
let type = searchType.value;
|
||||
let type = storeNavView.searchType.value;
|
||||
|
||||
let desc = String(node.desc).toLowerCase();
|
||||
let title = String(node.title).toLowerCase();
|
||||
|
@@ -27,7 +27,7 @@
|
||||
<!-- 搜索引擎列表 -->
|
||||
<div class="search-engines-wrapper">
|
||||
<n-radio-group
|
||||
v-model:value="searchEngineName"
|
||||
v-model:value="storeSearchView.searchEngineName.value"
|
||||
class="search-engines-list"
|
||||
>
|
||||
<!-- 搜索引擎分类 -->
|
||||
@@ -68,9 +68,14 @@ import {
|
||||
NInput, NRadio, NRadioGroup,
|
||||
} from 'naive-ui';
|
||||
|
||||
import {
|
||||
storeSearchView,
|
||||
} from '@/assets/js/local-storage';
|
||||
|
||||
import {
|
||||
openSearchResult,
|
||||
searchEngineList, searchEngineName, searchKeyword,
|
||||
searchEngineList,
|
||||
searchKeyword,
|
||||
} from '@/assets/js/search-engine';
|
||||
|
||||
import {
|
||||
|
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="tool-detail-page"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
</style>
|
@@ -1,9 +1,267 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.form-data {
|
||||
.n-input {
|
||||
max-width: 256px;
|
||||
}
|
||||
|
||||
.n-select {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
421
src/views/ToolboxView/Conversion/QrcodeReaderAndGenerator.vue
Normal file
421
src/views/ToolboxView/Conversion/QrcodeReaderAndGenerator.vue
Normal 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>
|
232
src/views/ToolboxView/Edit/JsonFormatter.vue
Normal file
232
src/views/ToolboxView/Edit/JsonFormatter.vue
Normal 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>
|
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="tool-detail-page"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
</style>
|
@@ -217,7 +217,7 @@ function handleCopy() {
|
||||
});
|
||||
} else {
|
||||
$message.error('复制失败:当前浏览器不支持该操作');
|
||||
return Promise.resolve(false);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
|
9
src/views/ToolboxView/Generator/UuidGenerator.vue
Normal file
9
src/views/ToolboxView/Generator/UuidGenerator.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="tool-detail-page"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
</style>
|
@@ -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>
|
@@ -1,9 +1,553 @@
|
||||
<template>
|
||||
<div class="tool-detail-page"></div>
|
||||
<div class="tool-detail-page">
|
||||
|
||||
<!-- 注意 -->
|
||||
<n-card size="small" title="注意">
|
||||
<n-p>由于浏览器限制,通过 HTTPS 访问网站时只能连接带 SSL 的 WebSocket(WSS)。</n-p>
|
||||
<n-p>若需要连接不带 SSL 的 WebSocket(WS),建议下载到本地后使用。</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
@@ -393,6 +393,14 @@ watch(() => {
|
||||
diffLabel = '+2日';
|
||||
}
|
||||
|
||||
// 注:
|
||||
// 若指针起始位置位于表盘左半边,
|
||||
// 且拖拽指针旋转满 2 圈,
|
||||
// 此时计算出的小时值会大于或等于 24。
|
||||
if (newHour >= 24) {
|
||||
newHour -= 24;
|
||||
}
|
||||
|
||||
// 处理提示信息显示
|
||||
isTimeTooEarly.value = diffAngle < 7.5;
|
||||
isTimeExceeded.value = diffAngle === 720;
|
||||
|
@@ -1,9 +1,89 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.config-inputs .n-form-item {
|
||||
max-width: 480px;
|
||||
}
|
||||
</style>
|
||||
|
@@ -15,6 +15,19 @@
|
||||
<!-- 标题 -->
|
||||
<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 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 打开工具
|
||||
* @param {ToolboxItem} data
|
||||
@@ -136,6 +161,12 @@ function handleOpenTool(data) {
|
||||
margin-right: 0.5em;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.new-window-button {
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-list {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -196,8 +227,14 @@ function handleOpenTool(data) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
> .n-card:not(:first-child) {
|
||||
margin-top: 20px;
|
||||
> .n-card {
|
||||
&:not(:first-child) {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
> .n-card__content {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-no-feedback .n-form-item-feedback-wrapper,
|
||||
|
Reference in New Issue
Block a user