22 Commits

Author SHA1 Message Date
383280031c feat(工具箱): 添加“工作时间可视化”工具 2025-06-16 00:08:31 +08:00
bca8602a3a refactor(app): 调整本地储存 key 命名 2025-06-15 18:44:29 +08:00
04c76dc347 docs: 更新 README 2025-06-14 20:55:31 +08:00
d75232e5e0 feat(工具箱): 添加工具信息和工具更新日志显示 2025-06-04 23:38:16 +08:00
ca27b2bbcc chore: 更新 package.json,添加 packageManager 配置项 2025-06-01 22:52:55 +08:00
6486a90812 chore: 更新依赖项的版本 2025-06-01 19:59:23 +08:00
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
27 changed files with 2766 additions and 1369 deletions

View File

@@ -1,5 +1,15 @@
# 更新日志
## [3.1.5] - 2025-04-06
### Added
- `工具箱` 添加“二维码解析和生成”工具。
### Changed
- `工具箱` 调整工具列表项顺序。
## [3.1.4] - 2025-02-08
### Added

View File

@@ -2,7 +2,15 @@
## 简介
一个多功能的网导航,绿色无广告。
一个使用 Vue 3 开发的网导航和工具箱,绿色无广告。
![](./screenshots/image_01.png)
![](./screenshots/image_02.png)
![](./screenshots/image_03.png)
![](./screenshots/image_04.png)
## 使用方法

View File

@@ -1,7 +1,7 @@
{
"name": "frost-navigation",
"description": "Frost Navigation",
"version": "3.1.4",
"version": "3.1.5",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,33 +9,34 @@
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"packageManager": "pnpm@10.11.0",
"dependencies": {
"@frost-utils/javascript": "^2.1.3",
"@mdi/font": "^7.4.47",
"@vueuse/core": "^12.5.0",
"axios": "^1.7.9",
"@vueuse/core": "^13.3.0",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"highlight.js": "^11.11.1",
"lunisolar": "^2.5.1",
"mathjs": "^14.2.0",
"lunisolar": "^2.5.2",
"mathjs": "^14.5.2",
"naive-ui": "^2.41.0",
"radash": "^12.1.0",
"uuid": "^11.0.5",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"zxing-wasm": "^2.0.1"
"uuid": "^11.1.0",
"vue": "^3.5.16",
"vue-router": "^4.5.1",
"zxing-wasm": "^2.1.2"
},
"devDependencies": {
"@tsconfig/node20": "^20.1.4",
"@types/node": "^20.17.16",
"@tsconfig/node20": "^20.1.5",
"@types/node": "^20.17.57",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-legacy": "^6.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitejs/plugin-legacy": "^6.1.1",
"@vitejs/plugin-vue": "^5.2.4",
"@vitejs/plugin-vue-jsx": "~4.1.2",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.19.0",
"eslint-plugin-vue": "^9.32.0",
"less": "^4.2.2",
"vite": "^6.0.11"
"eslint": "^9.28.0",
"eslint-plugin-vue": "^9.33.0",
"less": "^4.3.0",
"vite": "^6.3.5"
}
}

2514
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- core-js
- esbuild

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

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

BIN
public/wasm/zxing_full.wasm Normal file

Binary file not shown.

BIN
screenshots/image_01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
screenshots/image_02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
screenshots/image_03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
screenshots/image_04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -53,21 +53,38 @@ 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;
}
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 &&
['password', 'text', 'textarea'].includes(element.type)
) {
if (element instanceof HTMLInputElement) {
return;
}
// 排除指定元素
if (classValue && classRegExp.test(classValue)) {
return;
}
}
event.preventDefault();
}
@@ -105,7 +122,8 @@ function initCssVars() {
'--color-green': 'var(--color-success)',
'--color-blue': 'var(--color-info)',
'--color-orange': 'var(--color-warning)',
// 滚动条大小
// 元素大小
'--dialog-content-max-height': 'calc(100vh - 160px)',
'--scrollbar-size': '8px',
};
@@ -248,6 +266,11 @@ html {
// -- Naive UI --
.n-dialog-content--with-max-height {
max-height: var(--dialog-content-max-height);
overflow-y: auto;
}
.n-drawer--right-placement {
.n-drawer-body {
height: 0;

View File

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

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

@@ -0,0 +1,15 @@
/** 工具更新日志 */
export const CHANGE_LOGS = {
'json-formatter': [
'[2] - 2025-02-07\n优化“输出内容”显示样式解决内容较多时行号显示不全的问题。',
'[1] - 2025-02-04\n初始版本。',
],
'keep-screen-on': [
'[2] - 2024-10-13\n优化界面样式背景添加圆角。',
'[1] - 2024-10-11\n初始版本。',
],
'qrcode-reader-and-generator': [
'[2] - 2025-02-23\n支持生成二维码。\n支持解析剪贴板中的二维码图片。\n优化解析功能在二维码所在位置添加矩形标记。',
'[1] - 2025-02-21\n初始版本。',
],
};

View File

@@ -1,5 +1,7 @@
// 工具箱
import { CHANGE_LOGS } from './toolbox-changelogs';
const MODULES = import.meta.glob('../../views/ToolboxView/**/*.vue');
/**
@@ -13,22 +15,22 @@ export const toolList = [
enabled: true,
items: [
{
id: 'calc-download-time',
component: 'Calculation/CalcDownloadTime',
title: '下载用时计算',
id: 'calc-ratio',
component: 'Calculation/CalcRatio',
title: '比例计算',
iconClass: 'mdi mdi-calculator-variant-outline',
desc: '根据设定的文件大小和下载速度简单计算大约下载完成所需的时间。',
desc: '设定的比例计算给出的数值所对应的数值。',
createdAt: '2024-09-08',
updatedAt: '2024-09-08',
version: '1',
enabled: true,
},
{
id: 'calc-ratio',
component: 'Calculation/CalcRatio',
title: '比例计算',
id: 'calc-download-time',
component: 'Calculation/CalcDownloadTime',
title: '下载用时计算',
iconClass: 'mdi mdi-calculator-variant-outline',
desc: '设定的比例计算给出的数值所对应的数值。',
desc: '根据设定的文件大小和下载速度简单计算大约下载完成所需的时间。',
createdAt: '2024-09-08',
updatedAt: '2024-09-08',
version: '1',
@@ -42,27 +44,50 @@ export const toolList = [
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,
changelogs: CHANGE_LOGS['qrcode-reader-and-generator'],
},
{
id: 'convert-text-structure',
component: 'Conversion/ConvertTextStructure',
@@ -75,15 +100,15 @@ 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: '时间戳转时间 / 时间转时间戳',
createdAt: '2025-02-05',
updatedAt: '2025-02-05',
version: '1',
enabled: true,
desc: '',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
]
},
@@ -113,6 +138,7 @@ export const toolList = [
updatedAt: '2025-02-07',
version: '2',
enabled: true,
changelogs: CHANGE_LOGS['json-formatter'],
},
],
},
@@ -132,6 +158,17 @@ export const toolList = [
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',
@@ -162,22 +199,11 @@ export const toolList = [
enabled: true,
items: [
{
id: 'calc-minecraft-chunk-location',
component: 'Minecraft/CalcChunkLocation',
title: 'Minecraft 区块位置计算',
iconClass: 'mdi mdi-calculator-variant-outline',
desc: '',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'generate-minecraft-dynmap-renderdata',
component: 'Minecraft/GenerateDynmapRenderdata',
title: '生成 Dynmap renderdata',
iconClass: 'mdi mdi-file-outline',
desc: '生成用于 Minecraft Dynmap 插件或模组的 renderdata 数据。',
id: 'minecraft-uuid-converter',
component: 'Minecraft/UuidConverter',
title: 'Minecraft UUID 转换',
iconClass: 'mdi mdi-identifier',
desc: '随机生成或转换 Minecraft 的 UUID。',
createdAt: '',
updatedAt: '',
version: '0',
@@ -195,11 +221,22 @@ export const toolList = [
enabled: true,
},
{
id: 'minecraft-uuid-converter',
component: 'Minecraft/UuidConverter',
title: 'Minecraft UUID 转换',
iconClass: 'mdi mdi-identifier',
desc: '随机生成或转换 Minecraft 的 UUID。',
id: 'calc-minecraft-chunk-location',
component: 'Minecraft/CalcChunkLocation',
title: 'Minecraft 区块位置计算',
iconClass: 'mdi mdi-calculator-variant-outline',
desc: '',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'generate-minecraft-dynmap-renderdata',
component: 'Minecraft/GenerateDynmapRenderdata',
title: '生成 Dynmap renderdata',
iconClass: 'mdi mdi-file-outline',
desc: '生成用于 Minecraft Dynmap 插件或模组的 renderdata 数据。',
createdAt: '',
updatedAt: '',
version: '0',
@@ -230,34 +267,24 @@ 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',
enabled: true,
changelogs: CHANGE_LOGS['keep-screen-on'],
},
{
id: 'open-new-window',
component: 'Other/OpenNewWindow',
title: '新窗口中打开',
iconClass: 'mdi mdi-window-maximize',
desc: '从新的小窗口中打开指定的链接(仅支持 PC 端浏览器)',
desc: '从新的小窗口中打开指定的链接(仅支持 PC 端浏览器)',
createdAt: '2025-02-04',
updatedAt: '2025-02-04',
version: '1',
@@ -268,7 +295,29 @@ export const toolList = [
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,
},
{
id: 'visualized-working-hours',
component: 'Other/VisualizedWorkingHours/VisualizedWorkingHours',
title: '工作时间可视化',
iconClass: 'mdi mdi-clock-digital',
desc: '用趣味化的方式呈现工作收益与时间进度,让薪资进度和下班期待看得见。',
createdAt: '',
updatedAt: '',
version: '0',

View File

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

View File

@@ -57,7 +57,6 @@
label-align="right"
label-placement="left"
label-width="9em"
@contextmenu.stop
>
<n-form-item label="本地时间:">
@@ -88,7 +87,6 @@
label-align="right"
label-placement="left"
label-width="9em"
@contextmenu.stop
>
<n-form-item label="本地时间:">
@@ -120,9 +118,9 @@
<script setup>
import {
NButton, NCard, NCode, NFlex,
NButton, NCard, NFlex,
NForm, NFormItem,
NInput, NInputNumber, NP, NSelect, NSwitch,
NInput, NP, NSelect, NSwitch,
} from 'naive-ui';
import {

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

@@ -61,7 +61,6 @@
placeholder="请输入 JSON 字符串"
type="textarea"
:rows="8"
@contextmenu.stop
></n-input>
</n-card>
@@ -72,7 +71,6 @@
language="json"
:code="data.jsonOutput"
:show-line-numbers="true"
@contextmenu.stop
/>
</n-card>

View File

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

View File

@@ -14,7 +14,6 @@
label-align="left"
label-placement="top"
label-width="auto"
@contextmenu.stop
>
<n-form-item label="连接地址">

View File

@@ -6,22 +6,19 @@
<n-form
class="form-no-feedback config-inputs"
label-align="left"
label-placement="left"
label-placement="top"
label-width="auto"
@contextmenu.stop
>
<n-form-item label="目标链接">
<n-form-item label="目标链接">
<n-input
v-model:value="data.url"
placeholder="请输入需要打开的 URL需要包含协议部分https://"
placeholder="请输入 URL需要包含协议部分https://"
type="text"
></n-input>
</n-form-item>
<n-form-item label="窗口大小:">
<n-flex align="center">
<span>宽度</span>
<n-form-item label="窗口宽度">
<n-input-number
v-model:value="data.width"
:min="0"
@@ -29,7 +26,9 @@
:precision="0"
:step="1"
></n-input-number>
<span>高度</span>
</n-form-item>
<n-form-item label="窗口高度">
<n-input-number
v-model:value="data.height"
:min="0"
@@ -37,7 +36,6 @@
:precision="0"
:step="1"
></n-input-number>
</n-flex>
</n-form-item>
</n-form>
@@ -45,12 +43,10 @@
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex>
<n-button
type="primary"
@click="openWindow"
>打开窗口</n-button>
</n-flex>
</n-card>
</div>
@@ -58,7 +54,7 @@
<script setup>
import {
NButton, NCard, NFlex,
NButton, NCard,
NForm, NFormItem, NInput, NInputNumber,
} from 'naive-ui';
@@ -87,18 +83,7 @@ function openWindow() {
</script>
<style lang="less" scoped>
.config-inputs {
.n-input-number {
flex-grow: 1;
width: 0;
}
.n-flex {
width: 100%;
}
:deep(.n-form-item-blank) {
.config-inputs .n-form-item {
max-width: 480px;
}
}
</style>

View File

@@ -0,0 +1,243 @@
<template>
<div class="tool-detail-page">
<!-- 配置选项 -->
<n-card size="small" style="--color: #2196F3;">
<template #header>
<div class="card-title">
<span class="card-title__icon mdi mdi-cog-outline"></span>
<span class="card-title__label">配置选项</span>
</div>
</template>
<n-form
class="form-no-feedback config-inputs"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-form-item label="日薪">
<n-input-number
v-model:value="configData.dailyWage.value"
:min="0"
:max="99999999"
:precision="2"
:step="1"
></n-input-number>
</n-form-item>
<n-form-item label="收入币种">
<n-input
v-model:value="configData.currencyOfIncome.value"
placeholder="用于显示,例如:¥"
type="text"
:maxlength="8"
></n-input>
</n-form-item>
<n-form-item label="工作时间">
<n-input-group>
<n-time-picker
v-model:formatted-value="configData.workTimeStart.value"
format="HH:mm"
/>
<n-time-picker
v-model:formatted-value="configData.workTimeStop.value"
format="HH:mm"
/>
</n-input-group>
</n-form-item>
</n-form>
</n-card>
<!-- 实时进度 -->
<n-card size="small" style="--color: #F44336;">
<template #header>
<div class="card-title">
<span class="card-title__icon mdi mdi-chart-line"></span>
<span class="card-title__label">实时进度</span>
</div>
</template>
<div class="progress-item">
<div class="progress-item__row">
<span>本月已赚</span>
<span>1000 </span>
</div>
<div class="progress-item__row">
<n-progress
color="var(--color)"
type="line"
:height="16"
:percentage="35"
:processing="true"
:show-indicator="false"
/>
</div>
<div class="progress-item__row">
<span>35%</span>
<span>剩余 20.5 </span>
</div>
</div>
<div class="progress-item">
<div class="progress-item__row">
<span>今日已赚</span>
<span>100 </span>
</div>
<div class="progress-item__row">
<n-progress
color="var(--color)"
type="line"
:height="16"
:percentage="60"
:processing="true"
:show-indicator="false"
/>
</div>
<div class="progress-item__row">
<span>60%</span>
<span>剩余 5.6 小时</span>
</div>
</div>
</n-card>
<!-- 下班冲刺 -->
<n-card size="small" style="--color: #4CAF50;">
<template #header>
<div class="card-title">
<span class="card-title__icon mdi mdi-clock-outline"></span>
<span class="card-title__label">下班冲刺</span>
</div>
</template>
<div class="time-info">
<div class="time-info__row">
<span>距离下班还剩</span>
</div>
<div class="time-info__row">
<span>05:30:20</span>
</div>
<div class="time-info__row">
<span>漫长的一天才过一半加油吧</span>
</div>
</div>
</n-card>
<!-- 获得成就 -->
<!-- <n-card size="small" style="--color: #FF9800;">
<template #header>
<div class="card-title">
<span class="card-title__icon mdi mdi-trophy-outline"></span>
<span class="card-title__label">获得成就</span>
</div>
</template>
</n-card> -->
</div>
</template>
<script setup>
import {
NButton, NCard, NForm, NFormItem,
NInput, NInputGroup, NInputNumber,
NProgress, NTimePicker,
} from 'naive-ui';
import {
onBeforeMount,
} from 'vue';
import {
configData, initData,
} from './data';
onBeforeMount(() => {
initData();
});
</script>
<style lang="less" scoped>
.card-title > span {
vertical-align: middle;
}
.card-title__icon {
color: var(--color, #252525);
font-size: 1.2em;
}
.card-title__label {
margin-left: 0.5em;
}
.config-inputs {
.n-form-item {
max-width: 480px;
}
:deep(.n-form-item-blank) > * {
width: 100%;
}
:deep(.n-input-group) > * {
flex-grow: 1;
width: 0;
}
}
.progress-item :not(:first-child) {
margin-top: 12px;
}
.progress-item__row {
display: flex;
align-items: center;
justify-content: space-between;
&:not(:first-child) {
margin-top: 4px;
}
&:first-child, &:last-child {
color: #505050;
}
&:first-child span:last-child {
color: var(--color);
font-size: 1.2em;
font-weight: bold;
}
&:last-child {
font-size: 0.8em;
}
}
.time-info {
padding: 16px 0;
background-color: var(--color-action);
}
.time-info__row {
text-align: center;
&:first-child, &:last-child {
color: #505050;
}
&:nth-child(1) {
font-size: 1em;
}
&:nth-child(2) {
font-size: 2em;
}
&:nth-child(3) {
font-size: 0.8em;
}
}
</style>

View File

@@ -0,0 +1,50 @@
import { useLocalStorage } from '@vueuse/core';
import { KEY_PREFIX } from '@/assets/js/local-storage';
/** 模块名称 */
const STORAGE_PREFIX = KEY_PREFIX + 'visualized-working-hours/';
/** 配置选项 */
export const configData = {
/** 收入币种 */
currencyOfIncome: useLocalStorage(STORAGE_PREFIX + 'currencyOfIncome', ''),
/** 日薪 */
dailyWage: useLocalStorage(STORAGE_PREFIX + 'dailyWage', 100),
/** 午休时长 */
lunchBreakDuration: useLocalStorage(STORAGE_PREFIX + 'lunchBreakDuration', 1),
/** 工作开始时间 */
workTimeStart: useLocalStorage(STORAGE_PREFIX + 'workTimeStart', ''),
/** 工作结束时间 */
workTimeStop: useLocalStorage(STORAGE_PREFIX + 'workTimeStop', ''),
};
/** 初始化数据 */
export function initData() {
let {
currencyOfIncome,
workTimeStart,
workTimeStop,
} = configData;
let timeRegExp = new RegExp(/^\d{2}:\d{2}$/);
if (!currencyOfIncome.value) {
currencyOfIncome.value = '¥';
}
if (!workTimeStart.value.match(timeRegExp)) {
workTimeStart.value = '09:00';
}
if (!workTimeStop.value.match(timeRegExp)) {
workTimeStop.value = '18:00';
}
}

View File

@@ -5,7 +5,7 @@
<!-- 返回上一级 -->
<n-button
v-show="isToolDetail"
class="back-button"
class="header-button"
:text="true"
@click="handleCloseTool"
>
@@ -18,10 +18,20 @@
<!-- 占位 -->
<div class="placeholder"></div>
<!-- 查看信息 -->
<n-button
v-show="isToolDetail"
class="header-button"
:text="true"
@click="showToolItemInfo"
>
<span class="mdi mdi-information-slab-circle-outline"></span>
</n-button>
<!-- 新窗口打开 -->
<n-button
v-show="isToolDetail"
class="back-button"
class="header-button"
:text="true"
@click="handleOpenNewWindow"
>
@@ -89,17 +99,47 @@
<router-view></router-view>
</div>
<!-- 工具信息 -->
<n-modal
v-model:show="toolInfo.show"
content-class="n-dialog-content--with-max-height"
preset="dialog"
title="工具信息"
:show-icon="false"
>
<template v-if="true">
<n-h4>版本信息</n-h4>
<n-ul>
<n-li>创建日期:{{ toolInfo.dateCreated }}</n-li>
<n-li>更新日期:{{ toolInfo.dateUpdated }}</n-li>
<n-li>当前版本:{{ toolInfo.currVersion }}</n-li>
</n-ul>
</template>
<template v-if="toolInfo.changelogs.length > 0">
<n-h4>更新日志</n-h4>
<n-ul>
<n-li
v-for="(text, index) in toolInfo.changelogs"
:key="index"
class="changelogs-row"
>{{ text }}</n-li>
</n-ul>
</template>
</n-modal>
</div>
</div>
</template>
<script setup>
import {
NButton, NCollapse, NCollapseItem, NEllipsis, NTooltip,
NButton, NCollapse, NCollapseItem,
NEllipsis, NModal, NTooltip,
NH4, NUl, NLi,
} from 'naive-ui';
import {
computed,
computed, reactive,
} from 'vue';
import {
@@ -112,7 +152,7 @@ import {
/** 是否为工具页面 */
const isToolDetail = computed(() => {
return route.meta.isToolDetail;
return Boolean(route.meta.isToolDetail);
});
/** 路由 */
@@ -126,6 +166,15 @@ const routeTitle = computed(() => {
return route.meta.title;
});
/** 工具信息 */
const toolInfo = reactive({
changelogs: [],
currVersion: '',
dateCreated: '',
dateUpdated: '',
show: false,
});
/** 关闭工具 */
function handleCloseTool() {
return router.push({
@@ -154,10 +203,53 @@ function handleOpenTool(data) {
name: `Toolbox/${data.component}`,
});
}
/** 查看当前工具信息 */
function showToolItemInfo() {
let routePath = route.path;
let toolIdMatch = routePath.match(/\/([^/]*)$/);
let toolIdStr = toolIdMatch ? toolIdMatch[1] : null;
let toolItem = null;
if (toolIdStr) {
for (let i = 0; i < toolList.length; i++) {
let category = toolList[i];
let list = category.items;
if (!category.enabled || !list) {
continue;
}
for (let j = 0; j < list.length; j++) {
let item = list[j];
if (item.id === toolIdStr) {
toolItem = item;
break;
}
}
if (toolItem) {
break;
}
}
}
if (toolItem) {
toolInfo.changelogs = toolItem.changelogs || [];
toolInfo.currVersion = toolItem.version || '';
toolInfo.dateCreated = toolItem.createdAt || '';
toolInfo.dateUpdated = toolItem.updatedAt || '';
toolInfo.show = true;
}
}
</script>
<style lang="less" scoped>
.back-button {
.header-button {
margin-right: 0.5em;
font-size: 24px;
}
@@ -243,4 +335,8 @@ function handleOpenTool(data) {
}
}
}
.changelogs-row {
white-space: pre-wrap;
}
</style>

2
types/web.d.ts vendored
View File

@@ -65,6 +65,8 @@ declare global {
version: string;
/** 是否启用 */
enabled: boolean;
/** 更新日志 */
changelogs: string[];
}
// window