29 Commits

Author SHA1 Message Date
760a457410 feat(工具箱/MSU2 USB 小屏幕控制): 增加“音频可视化 - 3” 2026-05-21 23:41:36 +08:00
58116ff311 fix(工具箱/MSU2 USB 小屏幕控制): 优化 renderAudioSpectrum 的算法 2026-05-21 23:40:54 +08:00
b2d715a1b1 feat(工具箱/MSU2 USB 小屏幕控制): 支持设置屏幕亮度 2026-03-14 13:43:14 +08:00
f31020d31c feat(工具箱/MSU2 USB 小屏幕控制): 支持设置屏幕捕获帧率,自动获取画布大小作为屏幕捕获分辨率 2026-03-13 22:29:59 +08:00
7e5100fc5c fix(工具箱/MSU2 USB 小屏幕控制): 修正 onBeforeUnmount 调用 2026-03-09 15:01:17 +08:00
9ca0619373 feat(工具箱/MSU2 USB 小屏幕控制): 添加屏幕捕获功能 2026-03-09 14:41:20 +08:00
02237e3e30 fix(app): 统一 gap 样式,移除无效的属性 2026-03-09 08:18:59 +08:00
2d805cf02d feat(工具箱/MSU2 USB 小屏幕控制): 完善功能
- 添加旧算法
- 支持设置显示方向
- 支持配置渲染间隔和发送间隔
2026-02-24 23:40:03 +08:00
266a1764be chore: 更新 package.json,“scripts”添加“dev-host”项 2026-02-22 22:40:02 +08:00
52d154e1a3 chore(工具箱/工作时间可视化): 调整界面文本,与其他模块统一 2026-02-22 17:44:15 +08:00
7deefc8ea3 feat(工具箱): 添加“MSU2 USB 小屏幕控制”工具 2026-02-22 17:43:22 +08:00
31b6a6ce2d chore: 安装 @types/w3c-web-serial 2026-02-22 17:42:37 +08:00
7b76410df8 chore: 更新 package.json,添加“license”项 2026-02-21 22:07:17 +08:00
c836302f99 feat(工具箱): 添加“PNG 转 ICO 图标”工具 2026-01-26 00:11:38 +08:00
ecadb6ce2a docs: 添加 project_rules.md 2026-01-26 00:11:06 +08:00
1ff42aa911 fix(工具箱/执行 JavaScript): 优化界面样式 2026-01-03 23:41:12 +08:00
a666679ebd feat(工具箱): 添加“生成批量下载链接”工具 2026-01-03 23:40:47 +08:00
6cbbbe8541 chore(工具箱/保持亮屏): 调整界面样式,与其他模块统一 2026-01-03 19:43:22 +08:00
dcd46b9afe fix(工具箱): 优化文本内容 2026-01-03 19:23:10 +08:00
13ed3a8fe4 refactor(工具箱): 优化逻辑,提取 MonacoEnvironment 配置 2026-01-03 19:04:10 +08:00
69e64fb08c feat(工具箱): 添加“执行 JavaScript”工具 2026-01-03 18:55:31 +08:00
e6c35d25a5 feat(工具箱): 添加“UUID 生成器”工具 2026-01-03 17:22:32 +08:00
26947ee678 chore(工具箱): 调整工具项顺序 2026-01-03 17:07:45 +08:00
fe8f1606fa feat(工具箱): 添加“计时器”工具 2025-12-30 19:12:22 +08:00
b8113725bf feat(工具箱): 添加“文本编辑器”工具 2025-12-21 23:07:26 +08:00
66e1170c58 chore(app): 隐藏“MINECRAFT 联动控制”模块 2025-11-30 19:24:42 +08:00
359acb41d1 chore: 更新版本信息(V3.1.6) 2025-10-26 22:33:42 +08:00
6883e54a1b feat(工具箱): 添加“JSON 编辑器”工具 2025-10-26 22:31:46 +08:00
84273c3689 chore(app): 安装 monaco-editor 2025-10-26 22:23:20 +08:00
31 changed files with 4083 additions and 127 deletions

View File

@@ -0,0 +1,3 @@
1. 项目采用 Vite + Vue 3 + Naive UI 组件库开发,图标库使用 Material Design Icons@mdi/font)。
2. 项目使用 pnpm 作为包管理器。
3. 项目开发服务已启动,首页访问地址是 http://localhost:9000/,不需要重复启动。

View File

@@ -1,5 +1,12 @@
# 更新日志
## [3.1.6] - 2025-10-26
### Added
- `工具箱` 添加工具信息和工具更新日志显示。
- `工具箱` 添加“JSON 编辑器”工具。
## [3.1.5] - 2025-04-06
### Added

View File

@@ -7,7 +7,10 @@
"paths": {
"@/*": ["./src/*"],
"@package-json": ["./package.json"],
}
},
"types": [
"@types/w3c-web-serial"
]
},
"exclude": [],
"include": [

View File

@@ -6,7 +6,9 @@
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
"types": [
"@types/node"
]
},
"exclude": [],
"include": [

View File

@@ -1,10 +1,12 @@
{
"name": "frost-navigation",
"description": "Frost Navigation",
"version": "3.1.5",
"version": "3.1.6",
"license": "GPL-3.0-only",
"type": "module",
"scripts": {
"dev": "vite",
"dev-host": "vite dev --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
@@ -19,6 +21,7 @@
"highlight.js": "^11.11.1",
"lunisolar": "^2.5.2",
"mathjs": "^14.5.2",
"monaco-editor": "^0.54.0",
"naive-ui": "^2.41.0",
"radash": "^12.1.0",
"uuid": "^11.1.0",
@@ -30,6 +33,7 @@
"@tsconfig/node20": "^20.1.5",
"@types/node": "^20.17.57",
"@types/uuid": "^10.0.0",
"@types/w3c-web-serial": "^1.0.8",
"@vitejs/plugin-legacy": "^6.1.1",
"@vitejs/plugin-vue": "^5.2.4",
"@vitejs/plugin-vue-jsx": "~4.1.2",

33
pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
mathjs:
specifier: ^14.5.2
version: 14.5.2
monaco-editor:
specifier: ^0.54.0
version: 0.54.0
naive-ui:
specifier: ^2.41.0
version: 2.41.0(vue@3.5.16)
@@ -60,6 +63,9 @@ importers:
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@types/w3c-web-serial':
specifier: ^1.0.8
version: 1.0.8
'@vitejs/plugin-legacy':
specifier: ^6.1.1
version: 6.1.1(terser@5.40.0)(vite@6.3.5(@types/node@20.17.57)(less@4.3.0)(terser@5.40.0))
@@ -972,6 +978,9 @@ packages:
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/w3c-web-serial@1.0.8':
resolution: {integrity: sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@@ -1231,6 +1240,9 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dompurify@3.1.7:
resolution: {integrity: sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1461,7 +1473,7 @@ packages:
engines: {node: '>= 4'}
image-size@0.5.5:
resolution: {integrity: sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=}
resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==}
engines: {node: '>=0.10.0'}
hasBin: true
@@ -1566,6 +1578,11 @@ packages:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1595,6 +1612,9 @@ packages:
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
monaco-editor@0.54.0:
resolution: {integrity: sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2869,6 +2889,8 @@ snapshots:
'@types/uuid@10.0.0': {}
'@types/w3c-web-serial@1.0.8': {}
'@types/web-bluetooth@0.0.21': {}
'@vitejs/plugin-legacy@6.1.1(terser@5.40.0)(vite@6.3.5(@types/node@20.17.57)(less@4.3.0)(terser@5.40.0))':
@@ -3158,6 +3180,8 @@ snapshots:
delayed-stream@1.0.0: {}
dompurify@3.1.7: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -3513,6 +3537,8 @@ snapshots:
semver: 5.7.2
optional: true
marked@14.0.0: {}
math-intrinsics@1.1.0: {}
mathjs@14.5.2:
@@ -3542,6 +3568,11 @@ snapshots:
dependencies:
brace-expansion: 1.1.11
monaco-editor@0.54.0:
dependencies:
dompurify: 3.1.7
marked: 14.0.0
ms@2.1.3: {}
naive-ui@2.41.0(vue@3.5.16):

View File

@@ -281,4 +281,8 @@ html {
line-height: 1;
}
}
.n-flex {
gap: 10px !important;
}
</style>

View File

@@ -0,0 +1,21 @@
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
/** 初始化 MonacoEnvironment */
export function initMonacoEnvironment() {
if (!self.MonacoEnvironment) {
// 配置编辑器环境Service Worker 等)
self.MonacoEnvironment = {
getWorker(workerId, label) {
if (label === 'javascript' || label === 'typescript') {
return new tsWorker();
} else if (label === 'json') {
return new jsonWorker();
} else {
return new editorWorker();
}
},
};
}
}

207
src/assets/js/png-to-ico.js Normal file
View File

@@ -0,0 +1,207 @@
/** PNG 转 ICO 图标,支持生成多尺寸 ICO */
class PNGtoICOConverter {
/**
* @description 将 PNG 图像转换为 ICO 格式
* @param {File|Blob|string} source PNG 文件、Blob 或 DataURL
* @param {number[]} sizes 需要生成的尺寸数组,默认 `[32, 128, 256]`
* @returns {Promise<Blob>} ICO 文件的 Blob 对象
*/
static async convert(source, sizes = [32, 128, 256]) {
try {
// 1. 加载 PNG 图像
let pngBlob = await this._preparePNG(source);
let imageBitmap = await createImageBitmap(pngBlob);
// 2. 创建各尺寸的图像
let imageDatas = await this._createMultiSizeImages(imageBitmap, sizes);
// 3. 生成 ICO 文件
let icoBlob = await this._generateICO(imageDatas);
return icoBlob;
} catch (error) {
throw new Error(`PNG 转 ICO 失败: ${error.message}`);
}
}
/**
* @description 准备 PNG 源数据
* @private
*/
static async _preparePNG(source) {
if (source instanceof File || source instanceof Blob) {
return source;
}
if (typeof source === 'string') {
if (source.startsWith('data:image/png')) {
const response = await fetch(source);
return await response.blob();
} else {
throw new Error('不支持的数据格式,仅支持 PNG 文件、Blob 或 DataURL');
}
}
throw new Error('无效的输入类型');
}
/**
* @description 创建多尺寸图像数据
* @private
*/
static async _createMultiSizeImages(imageBitmap, sizes) {
let results = [];
for (let size of sizes) {
// 按尺寸创建 Canvas
let canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
let ctx = canvas.getContext('2d');
// 设置高质量缩放
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 绘制缩放后的图像
ctx.drawImage(imageBitmap, 0, 0, size, size);
// 获取 PNG 数据
let pngBlob = await new Promise(resolve => {
canvas.toBlob(resolve, 'image/png');
});
let arrayBuffer = await pngBlob.arrayBuffer();
results.push({
size,
width: size,
height: size,
data: new Uint8Array(arrayBuffer),
pngSize: arrayBuffer.byteLength
});
}
return results;
}
/**
* @description 生成 ICO 文件
* @private
*/
static async _generateICO(imageDatas) {
// ICO 文件结构:
// 1. ICONDIR 头 (6 字节)
// 2. ICONDIRENTRY 数组 (每个 16 字节)
// 3. PNG 数据
let numImages = imageDatas.length;
// 计算总大小
let totalSize = 6; // ICONDIR 头
totalSize += numImages * 16; // ICONDIRENTRY 数组
totalSize += imageDatas.reduce((sum, img) => sum + img.pngSize, 0);
// 创建 ArrayBuffer
let buffer = new ArrayBuffer(totalSize);
let view = new DataView(buffer);
let offset = 0;
// 1. 写入 ICONDIR 头
view.setUint16(offset, 0, true); // Reserved (0)
offset += 2;
view.setUint16(offset, 1, true); // Type (1 for ICO)
offset += 2;
view.setUint16(offset, numImages, true); // Number of images
offset += 2;
// 2. 写入 ICONDIRENTRY 数组
let dataOffset = 6 + (numImages * 16);
for (let img of imageDatas) {
// 宽度和高度 (0 表示 256 像素)
view.setUint8(offset, img.width === 256 ? 0 : img.width);
offset += 1;
view.setUint8(offset, img.height === 256 ? 0 : img.height);
offset += 1;
view.setUint8(offset, 0); // Color palette (0 for no palette)
offset += 1;
view.setUint8(offset, 0); // Reserved
offset += 1;
view.setUint16(offset, 1, true); // Color planes
offset += 2;
view.setUint16(offset, 32, true); // Bits per pixel
offset += 2;
view.setUint32(offset, img.pngSize, true); // PNG data size
offset += 4;
view.setUint32(offset, dataOffset, true); // PNG data offset
offset += 4;
dataOffset += img.pngSize;
}
// 3. 写入 PNG 数据
for (let img of imageDatas) {
let uint8View = new Uint8Array(buffer, offset, img.pngSize);
uint8View.set(img.data);
offset += img.pngSize;
}
return new Blob([buffer], { type: 'image/x-icon' });
}
/**
* @description 下载 ICO 文件
* @param {Blob} icoBlob ICO 文件的 Blob 对象
* @param {string} filename 下载的文件名
*/
static download(icoBlob, filename = 'icon.ico') {
let url = URL.createObjectURL(icoBlob);
let a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
/**
* @description 将 PNG 转换为 ICO 并下载
* @param {File|Blob|string} source PNG 文件、Blob 或 DataURL
* @param {number[]} sizes 需要生成的尺寸数组
* @param {string} filename 下载的文件名
*/
export async function convertPNGtoICO(source, sizes, filename) {
try {
let blob = await PNGtoICOConverter.convert(source, sizes);
PNGtoICOConverter.download(blob, filename);
return { blob: blob, error: '', success: true };
} catch (error) {
console.error('转换失败:', error);
return { blob: null, error: error.message, success: false };
}
}

View File

@@ -5,9 +5,14 @@ export const CHANGE_LOGS = {
'[1] - 2025-02-04\n初始版本。',
],
'keep-screen-on': [
'[3] - 2026-01-03\n调整界面样式与其他模块统一。',
'[2] - 2024-10-13\n优化界面样式背景添加圆角。',
'[1] - 2024-10-11\n初始版本。',
],
'msu2-usb-monitor-controller': [
'[2] - 2026-02-24\n完善功能添加旧算法支持设置显示方向支持配置渲染间隔和发送间隔。',
'[1] - 2026-02-21\n初始版本。',
],
'qrcode-reader-and-generator': [
'[2] - 2025-02-23\n支持生成二维码。\n支持解析剪贴板中的二维码图片。\n优化解析功能在二维码所在位置添加矩形标记。',
'[1] - 2025-02-21\n初始版本。',

View File

@@ -54,6 +54,17 @@ export const toolList = [
version: '0',
enabled: false,
},
{
id: 'png-to-ico',
component: 'Conversion/PngToIco',
title: 'PNG 转 ICO 图标',
iconClass: 'mdi mdi-image-outline',
desc: '将 PNG 图片转换为 ICO 图标。',
createdAt: '2026-01-26',
updatedAt: '2026-01-26',
version: '1',
enabled: true,
},
{
id: 'convert-timestamp',
component: 'Conversion/ConvertTimestamp',
@@ -128,6 +139,17 @@ export const toolList = [
version: '0',
enabled: false,
},
{
id: 'json-editor',
component: 'Edit/JsonEditor',
title: 'JSON 编辑器',
iconClass: 'mdi mdi-code-json',
desc: '基于 Monaco Editor 实现的 JSON 编辑器。',
createdAt: '2025-10-26',
updatedAt: '2025-10-26',
version: '1',
enabled: true,
},
{
id: 'json-formatter',
component: 'Edit/JsonFormatter',
@@ -140,6 +162,17 @@ export const toolList = [
enabled: true,
changelogs: CHANGE_LOGS['json-formatter'],
},
{
id: 'text-editor',
component: 'Edit/TextEditor',
title: '文本编辑器',
iconClass: 'mdi mdi-text-long',
desc: '基于 Monaco Editor 实现的文本编辑器。',
createdAt: '2025-12-21',
updatedAt: '2025-12-21',
version: '1',
enabled: true,
},
],
},
{
@@ -159,15 +192,15 @@ export const toolList = [
enabled: false,
},
{
id: 'frp-config-generator',
id: 'uuid-generator',
component: 'Generator/UuidGenerator',
title: 'UUID 生成器',
iconClass: 'mdi mdi-identifier',
desc: '生成 UUID 列表。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
createdAt: '2026-01-03',
updatedAt: '2026-01-03',
version: '1',
enabled: true,
},
{
id: 'generate-urls',
@@ -175,10 +208,10 @@ export const toolList = [
title: '生成批量下载链接',
iconClass: 'mdi mdi-link-variant',
desc: '根据设置,生成有一定规律的用于批量下载的链接。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
createdAt: '2026-01-03',
updatedAt: '2026-01-03',
version: '1',
enabled: true,
},
{
id: 'generate-random-string',
@@ -274,11 +307,33 @@ export const toolList = [
iconClass: 'mdi mdi-monitor',
desc: '保持屏幕开启,不息屏,不休眠。',
createdAt: '2024-10-11',
updatedAt: '2024-10-13',
version: '2',
updatedAt: '2026-01-03',
version: '3',
enabled: true,
changelogs: CHANGE_LOGS['keep-screen-on'],
},
{
id: 'visualized-working-hours',
component: 'Other/VisualizedWorkingHours/VisualizedWorkingHours',
title: '工作时间可视化',
iconClass: 'mdi mdi-clock-digital',
desc: '用趣味化的方式呈现工作收益与时间进度,让薪资进度和下班期待看得见。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'timer-tool',
component: 'Other/TimerTool',
title: '计时器',
iconClass: 'mdi mdi-timer-outline',
desc: '正计时、倒计时工具。',
createdAt: '2025-12-29',
updatedAt: '2025-12-29',
version: '1',
enabled: true,
},
{
id: 'open-new-window',
component: 'Other/OpenNewWindow',
@@ -296,10 +351,22 @@ export const toolList = [
title: '执行 JavaScript',
iconClass: 'mdi mdi-code-braces',
desc: '执行简单的 JavaScript 代码片段。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
createdAt: '2026-01-03',
updatedAt: '2026-01-03',
version: '1',
enabled: true,
},
{
id: 'msu2-usb-monitor-controller',
component: 'Other/Msu2UsbMonitorController/Msu2UsbMonitorController',
title: 'MSU2 USB 小屏幕控制',
iconClass: 'mdi mdi-usb',
desc: 'MSU2 USB 小屏幕控制工具,参考了原 Python 程序的代码。',
createdAt: '2026-02-21',
updatedAt: '2026-02-24',
version: '2',
enabled: true,
changelogs: CHANGE_LOGS['msu2-usb-monitor-controller'],
},
{
id: 'genshin-impact-clock',
@@ -312,17 +379,6 @@ export const toolList = [
version: '1',
enabled: true,
},
{
id: 'visualized-working-hours',
component: 'Other/VisualizedWorkingHours/VisualizedWorkingHours',
title: '工作时间可视化',
iconClass: 'mdi mdi-clock-digital',
desc: '用趣味化的方式呈现工作收益与时间进度,让薪资进度和下班期待看得见。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
],
},
];

View File

@@ -1,6 +1,6 @@
// 模块显示配置
import { IS_DEV } from './env';
// import { IS_DEV } from './env';
/** 启用模块 */
export const ABOUT_MODULE_ENABLED = true;
@@ -9,7 +9,7 @@ export const ABOUT_MODULE_ENABLED = true;
export const ABOUT_MODULE_TITLE = '关于';
/** 启用模块 */
export const MC_CTRL_MODULE_ENABLED = IS_DEV;
export const MC_CTRL_MODULE_ENABLED = false;
/** 模块标题 */
export const MC_CTRL_MODULE_TITLE = 'MINECRAFT 联动控制';

View File

@@ -1,8 +1,8 @@
<template>
<div class="tool-detail-page">
<!-- 参数 -->
<n-card size="small" title="参数">
<!-- 配置参数 -->
<n-card size="small" title="配置参数">
<n-form
class="form-no-feedback"
label-align="right"

View File

@@ -1,8 +1,8 @@
<template>
<div class="tool-detail-page">
<!-- 控制 -->
<n-card size="small" title="控制">
<!-- 操作 -->
<n-card size="small" title="操作">
<n-form
class="form-no-feedback form-data"
label-align="right"
@@ -80,8 +80,8 @@
</n-form>
</n-card>
<!-- 转换 -->
<n-card size="small" title="转换">
<!-- 结果 -->
<n-card size="small" title="结果">
<n-form
class="form-no-feedback form-data"
label-align="right"

View File

@@ -0,0 +1,216 @@
<template>
<div class="tool-detail-page">
<!-- 文件选择 -->
<n-card size="small" title="文件选择">
<div class="file-description">
<p>支持格式PNGJPEGGIFBMP</p>
<p>建议图片大小小于 8MB</p>
<p>建议图片比例1:1正方形</p>
</div>
<n-upload
:max="1"
:on-before-upload="handleFileSelect"
:on-error="handleFileError"
:on-remove="handleFileRemove"
:show-file-list="false">
<n-button type="primary">选择文件</n-button>
</n-upload>
<div class="file-status">
<span>已选择文件</span>
<span v-if="formInfo.selectedFile">{{ formInfo.selectedFile.name }}</span>
<span v-else></span>
</div>
</n-card>
<!-- 分辨率配置 -->
<n-card size="small" title="分辨率配置">
<div class="section-description">
<p>一个 ICO 文件可以存储多种分辨率的图像</p>
</div>
<div class="selection-items">
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[16]"
>16px</n-checkbox>
</p>
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[32]"
>32px</n-checkbox>
</p>
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[48]"
>48px</n-checkbox>
</p>
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[64]"
>64px</n-checkbox>
</p>
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[128]"
>128px</n-checkbox>
</p>
<p class="senection-item">
<n-checkbox
v-model:checked="formInfo.sizes[256]"
>256px 32 位深度支持</n-checkbox>
</p>
</div>
</n-card>
<!-- 位深度配置 -->
<n-card size="small" title="位深度配置">
<n-radio-group v-model:value="formInfo.bitDepth">
<p>
<n-radio :value="8">8 256 调色板</n-radio>
</p>
<p>
<n-radio :value="32">32 1670 万色 & 透明度</n-radio>
</p>
</n-radio-group>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex>
<n-button
size="large"
type="primary"
@click="handleConvert"
>转换为 ICO 图标</n-button>
</n-flex>
</n-card>
</div>
</template>
<script setup>
import {
NButton, NCard, NFlex, NForm, NFormItem,
NCheckbox, NRadio, NRadioGroup, NUpload,
} from 'naive-ui';
import {
reactive,
} from 'vue';
import {
$message, $notification,
} from '@/assets/js/naive-ui';
import {
convertPNGtoICO,
} from '@/assets/js/png-to-ico';
/** 数据 */
const formInfo = reactive({
/** 选择的文件 */
selectedFile: null,
/** 选择的分辨率 */
sizes: {
16: true,
32: true,
48: true,
64: false,
128: false,
256: false
},
/** 选择的位深度 */
bitDepth: 32,
});
/** 文件上传错误时的处理 */
function handleFileError() {
$message.error('文件上传失败,请重试');
}
/** 文件选择时的处理 */
function handleFileSelect(uploadData) {
formInfo.selectedFile = uploadData?.file?.file;
// 返回 false 阻止自动上传(只需要获取文件对象)
return false;
}
/** 文件移除时的处理 */
function handleFileRemove() {
formInfo.selectedFile = null;
}
/** 转换按钮点击处理 */
function handleConvert() {
if (!formInfo.selectedFile) {
$message.warning('请先选择文件');
return;
}
// 检查是否选择了至少一个分辨率
let selectedSizes = Object.keys(formInfo.sizes).filter((size) => {
return formInfo.sizes[size];
}).map((value) => {
return Number(value);
});
if (selectedSizes.length === 0) {
$message.warning('请至少选择一个分辨率');
return;
}
// 检查如果选择了 256 像素,是否位深度为 32 位
if (formInfo.sizes[256] && formInfo.bitDepth !== 32) {
$message.warning('256px 分辨率仅支持 32 位深度');
return;
}
convertPNGtoICO(
formInfo.selectedFile,
selectedSizes,
'icon.ico'
).then((result) => {
console.log('转换结果', result);
if (result.success) {
$message.success('文件转换成功');
} else {
$message.error('文件转换失败');
}
});
}
</script>
<style lang="less" scoped>
.file-description {
margin-bottom: 12px;
color: #666;
font-size: 14px;
line-height: 1.4;
}
.file-status {
margin-top: 12px;
font-size: 14px;
color: #666;
}
.section-description {
margin-bottom: 16px;
color: #666;
font-size: 14px;
line-height: 1.4;
}
:deep(.n-form-item) {
margin-bottom: 8px;
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<div class="tool-detail-page">
<n-card size="small" title="编辑器">
<div ref="editorContainer" class="editor-container"></div>
</n-card>
</div>
</template>
<script setup>
import {
NCard,
} from 'naive-ui';
import {
ref, shallowRef,
onBeforeUnmount, onMounted,
} from 'vue';
import {
initMonacoEnvironment,
} from '@/assets/js/monaco-editor';
import * as monaco from 'monaco-editor';
/** 模块名称 */
const PREFIX = '[JsonEditor]';
/**
* @desc 编辑器实例(注:内部不能为响应式,防止出现部分操作会导致界面卡死等异常)
* @type {VueRef<monaco.editor.IStandaloneCodeEditor>}
*/
const editorInstance = shallowRef(null);
/** @type {VueRef<HTMLElement>} */
const editorContainer = ref(null);
/** 防抖定时器 */
const resizeTimer = ref(null);
/** 处理窗口大小变化 */
function handleWindowResize() {
clearTimeout(resizeTimer.value);
resizeTimer.value = setTimeout(() => {
if (editorInstance.value) {
editorInstance.value.layout();
}
}, 200);
}
/** 初始化编辑器 */
function initEditor() {
console.log(PREFIX, 'initEditor');
let container = editorContainer.value;
let editor = null;
let valueStr = '';
if (!container) {
console.error(PREFIX, '初始化失败:元素不存在');
return;
}
editor = monaco.editor.create(container, {
autoDetectHighContrast: false,
automaticLayout: false,
contextmenu: true,
find: {
cursorMoveOnType: false,
},
fontFamily: 'monospace',
fontSize: 14,
language: 'json',
minimap: {
enabled: true,
renderCharacters: false,
},
mouseWheelScrollSensitivity: 2,
stickyScroll: {
enabled: false,
},
tabSize: 2,
theme: 'vs',
value: valueStr,
});
// 注:
// 若使用自定义字体,且字体的加载时间比编辑器加载时间长,
// 需要在字体加载完成后调用 monaco.editor.remeasureFonts()
// 防止光标位置异常。
editorInstance.value = editor;
}
/** 销毁编辑器 */
function resetEditor() {
console.log(PREFIX, 'resetEditor');
try {
if (editorInstance.value) {
editorInstance.value.getModel().dispose()
editorInstance.value.dispose();
editorInstance.value = null;
}
return true;
} catch (error) {
console.error(PREFIX, '销毁失败:');
console.error(error);
return false;
}
}
onMounted(() => {
initMonacoEnvironment();
initEditor();
window.addEventListener('resize', handleWindowResize);
});
onBeforeUnmount(() => {
resetEditor();
window.removeEventListener('resize', handleWindowResize);
});
</script>
<style lang="less" scoped>
.n-card, .editor-container {
width: 100%;
height: 100%;
}
.editor-container {
outline: 1px solid var(--n-border-color);
}
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div class="tool-detail-page">
<!-- 设置 -->
<n-card size="small" title="设置">
<!-- 配置参数 -->
<n-card size="small" title="配置参数">
<n-flex>
<!-- 缩进空格 -->
<div class="config-item">

View File

@@ -0,0 +1,245 @@
<template>
<div class="tool-detail-page">
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex>
<n-flex align="center">
<span>文件格式</span>
<n-select
v-model:value="textEncoding"
:options="[
{ label: 'GBK', value: 'gbk' },
{ label: 'UTF-8', value: 'utf-8' },
]"
style="width: 160px;"
/>
</n-flex>
<n-button
type="primary"
@click="handleOpenFile"
>打开文件</n-button>
<n-button
type="error"
@click="handleClearContent"
>清空内容</n-button>
</n-flex>
</n-card>
<n-card size="small" title="编辑器">
<div ref="editorContainer" class="editor-container"></div>
</n-card>
</div>
</template>
<script setup>
import {
NButton, NCard, NFlex, NSelect,
} from 'naive-ui';
import {
ref, shallowRef,
onBeforeUnmount, onMounted,
} from 'vue';
import {
useFileDialog,
} from '@vueuse/core';
import {
initMonacoEnvironment,
} from '@/assets/js/monaco-editor';
import {
$dialog, $message,
} from '@/assets/js/naive-ui';
import * as monaco from 'monaco-editor';
/** 模块名称 */
const PREFIX = '[TextEditor]';
const {
onChange: fileOnChange,
open: fileOpen,
reset: fileReset,
} = useFileDialog({
accept: '*',
directory: false,
multiple: false,
});
/**
* @desc 编辑器实例(注:内部不能为响应式,防止出现部分操作会导致界面卡死等异常)
* @type {VueRef<monaco.editor.IStandaloneCodeEditor>}
*/
const editorInstance = shallowRef(null);
/** @type {VueRef<HTMLElement>} */
const editorContainer = ref(null);
/** 防抖定时器 */
const resizeTimer = ref(null);
/** 文件编码 */
const textEncoding = ref('utf-8');
/** 处理清空内容操作 */
function handleClearContent() {
$dialog.create({
content: '确定要清空编辑器内容吗?',
negativeText: '取消',
positiveText: '确定',
title: '确认',
type: 'default',
onPositiveClick: () => {
updateEditorContent('');
},
});
}
/** 处理打开文件操作 */
function handleOpenFile() {
fileReset();
fileOpen();
}
/** 处理窗口大小变化 */
function handleWindowResize() {
clearTimeout(resizeTimer.value);
resizeTimer.value = setTimeout(() => {
if (editorInstance.value) {
editorInstance.value.layout();
}
}, 200);
}
/** 初始化编辑器 */
function initEditor() {
console.log(PREFIX, 'initEditor');
let container = editorContainer.value;
let editor = null;
let valueStr = '';
if (!container) {
console.error(PREFIX, '初始化失败:元素不存在');
return;
}
editor = monaco.editor.create(container, {
autoDetectHighContrast: false,
automaticLayout: false,
contextmenu: true,
find: {
cursorMoveOnType: false,
},
fontFamily: 'monospace',
fontSize: 14,
language: 'plaintext',
minimap: {
enabled: true,
renderCharacters: false,
},
mouseWheelScrollSensitivity: 2,
stickyScroll: {
enabled: false,
},
tabSize: 2,
theme: 'vs',
value: valueStr,
});
// 注:
// 若使用自定义字体,且字体的加载时间比编辑器加载时间长,
// 需要在字体加载完成后调用 monaco.editor.remeasureFonts()
// 防止光标位置异常。
editorInstance.value = editor;
}
/** 销毁编辑器 */
function resetEditor() {
console.log(PREFIX, 'resetEditor');
try {
if (editorInstance.value) {
editorInstance.value.getModel().dispose()
editorInstance.value.dispose();
editorInstance.value = null;
}
return true;
} catch (error) {
console.error(PREFIX, '销毁失败:');
console.error(error);
return false;
}
}
/** 更新编辑器内容 */
function updateEditorContent(text = '') {
if (editorInstance.value) {
editorInstance.value.setValue(text);
}
}
fileOnChange((files) => {
let file = files ? files[0] : null;
file && file.arrayBuffer().then((buffer) => {
try {
let decoder = new TextDecoder(textEncoding.value);
let text = decoder.decode(buffer);
updateEditorContent(text);
} catch (error) {
console.error('解码失败:');
console.error(error);
$message.error('打开文件失败:解码失败');
}
}).catch((error) => {
console.error('打开文件失败:');
console.error(error);
$message.error('打开文件失败:读取失败');
});
});
onMounted(() => {
initMonacoEnvironment();
initEditor();
window.addEventListener('resize', handleWindowResize);
});
onBeforeUnmount(() => {
resetEditor();
window.removeEventListener('resize', handleWindowResize);
});
</script>
<style lang="less" scoped>
.n-card:nth-child(1) {
height: auto;
}
.n-card:nth-child(2) {
height: 100%;
}
.n-card, .editor-container {
width: 100%;
}
.editor-container {
height: 100%;
outline: 1px solid var(--n-border-color);
}
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div class="tool-detail-page">
<!-- 配置选项 -->
<n-card size="small" title="配置选项">
<!-- 配置参数 -->
<n-card size="small" title="配置参数">
<n-form
class="form-no-feedback"
label-align="right"

View File

@@ -1,9 +1,488 @@
<template>
<div class="tool-detail-page"></div>
<div class="tool-detail-page">
<!-- 链接模板 -->
<n-card size="small" title="链接模板">
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-form-item label="链接模板:">
<n-input
v-model:value="linkBase"
placeholder="使用 {n} 表示变量例如https://example.com/file_{n}.jpg"
clearable
></n-input>
</n-form-item>
</n-form>
</n-card>
<!-- 配置参数 -->
<n-card size="small" title="参数配置">
<n-radio-group v-model:value="mode">
<!-- 等差数列 -->
<div class="mode-item">
<n-flex vertical>
<n-radio value="as">等差数列</n-radio>
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-flex>
<n-form-item label="首项:">
<n-input-number
v-model:value="modes.as.first"
:min="0"
:step="1"
></n-input-number>
</n-form-item>
<n-form-item label="公差:">
<n-input-number
v-model:value="modes.as.diff"
:min="1"
:step="1"
></n-input-number>
</n-form-item>
<n-form-item label="项数:">
<n-input-number
v-model:value="modes.as.count"
:min="1"
:max="1000"
:step="1"
></n-input-number>
</n-form-item>
</n-flex>
<n-flex>
<n-form-item label="选项:">
<n-checkbox
v-model:checked="modes.as.zero"
>补零</n-checkbox>
</n-form-item>
<n-form-item>
<n-checkbox
v-model:checked="modes.as.reverse"
>倒序</n-checkbox>
</n-form-item>
</n-flex>
</n-form>
</n-flex>
</div>
<!-- 等比数列 -->
<div class="mode-item">
<n-flex vertical>
<n-radio value="ps">等比数列</n-radio>
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-flex>
<n-form-item label="首项:">
<n-input-number
v-model:value="modes.ps.first"
:min="1"
:step="1"
></n-input-number>
</n-form-item>
<n-form-item label="公比:">
<n-input-number
v-model:value="modes.ps.diff"
:min="2"
:step="1"
></n-input-number>
</n-form-item>
<n-form-item label="项数:">
<n-input-number
v-model:value="modes.ps.count"
:min="1"
:max="1000"
:step="1"
></n-input-number>
</n-form-item>
</n-flex>
<n-flex>
<n-form-item label="选项:">
<n-checkbox
v-model:checked="modes.ps.zero"
>补零</n-checkbox>
</n-form-item>
<n-form-item>
<n-checkbox
v-model:checked="modes.ps.reverse"
>倒序</n-checkbox>
</n-form-item>
</n-flex>
</n-form>
</n-flex>
</div>
<!-- 字母变化 -->
<div class="mode-item">
<n-flex vertical>
<n-radio value="lc">字母变化</n-radio>
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-flex align-items="center">
<n-form-item label="从:">
<n-input
v-model:value="modes.lc.start"
:maxlength="1"
placeholder="a"
></n-input>
</n-form-item>
<n-form-item label="到:">
<n-input
v-model:value="modes.lc.end"
:maxlength="1"
placeholder="z"
></n-input>
</n-form-item>
<n-form-item>
<n-checkbox
v-model:checked="modes.lc.reverse"
>倒序</n-checkbox>
</n-form-item>
</n-flex>
</n-form>
</n-flex>
</div>
</n-radio-group>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex>
<n-button
type="primary"
@click="handleGenerate"
>生成链接</n-button>
<n-button
type="primary"
:disabled="!linkResult"
@click="handleCopy"
>复制结果</n-button>
<n-button
@click="handleClear"
>清空结果</n-button>
</n-flex>
</n-card>
<!-- 生成结果 -->
<n-card size="small" title="生成结果">
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-form-item>
<n-input
v-model:value="linkResult"
placeholder="生成的链接将显示在这里"
type="textarea"
:readonly="true"
:rows="10"
></n-input>
</n-form-item>
</n-form>
</n-card>
</div>
</template>
<script setup>
import {
NButton, NCard, NCheckbox, NFlex,
NForm, NFormItem, NInput, NInputNumber,
NRadio, NRadioGroup,
} from 'naive-ui';
import {
reactive, ref,
} from 'vue';
import {
$message,
} from '@/assets/js/naive-ui';
import {
useClipboard,
} from '@vueuse/core';
// 链接模板
const linkBase = ref('');
// 生成结果
const linkResult = ref('');
// 正则,匹配链接变量
const linkRegExp = /\{n\}/g;
// 当前模式
const mode = ref('as');
// 模式配置
const modes = reactive({
// 等差数列Arithmetic Sequence
as: {
first: 0,
diff: 1,
count: 10,
zero: false,
reverse: false,
},
// 等比数列Proportional Sequence
ps: {
first: 1,
diff: 2,
count: 10,
zero: false,
reverse: false,
},
// 字母变化Letter Change
lc: {
start: 'a',
end: 'z',
reverse: false,
},
});
// 剪贴板功能
const clipboard = useClipboard({
legacy: true,
read: false,
});
/** 处理生成链接 */
function handleGenerate() {
if (!linkBase.value) {
$message.warning('请输入链接模板');
return;
}
switch (mode.value) {
case 'as':
linkResult.value = generateSeq('as');
break;
case 'ps':
linkResult.value = generateSeq('ps');
break;
case 'lc':
linkResult.value = generateLetter();
break;
default:
break;
}
}
/**
* @description 生成数列
* @param {string} type 类型as - 等差数列ps - 等比数列)
*/
function generateSeq(type) {
let linkBaseVal = linkBase.value;
if (!linkBaseVal) {
return '';
}
let data = modes[type];
if (!data) {
return '';
}
let nFirst = data.first;
let nCount = data.count;
let nDiff = data.diff;
let nResult = {
digits: 0, // 最大位数
numbers: [], // 生成的数值
};
let links = [];
// 等差数列公式
let expAS = (i) => {
return (nFirst + (i - 1) * nDiff);
};
// 等比数列公式
let expPS = (i) => {
return (nFirst * Math.pow(nDiff, (i - 1)));
};
// 实际使用的公式
let exp = (type === 'as' ? expAS : expPS);
// 生成数值
for (let i = 1; i <= nCount; i++) {
// 等差数列 / 等比数列
let n = exp(i);
let digits = Math.abs(n).toString().length;
(digits > nResult.digits) && (nResult.digits = digits);
nResult.numbers.push(n);
}
// 补零
if (data.zero) {
let digits = nResult.digits;
let base = Math.pow(10, digits);
let numbers = nResult.numbers;
for (let i = 0; i < numbers.length; i++) {
let n = numbers[i];
if (n >= 0) {
numbers[i] = (n / base).toFixed(digits).substr(2);
} else {
numbers[i] = '-' + (n / base).toFixed(digits).substr(3);
}
}
}
// 倒序
if (data.reverse) {
nResult.numbers.reverse();
}
// 生成链接
nResult.numbers.forEach((n) => {
links.push(linkBaseVal.replace(linkRegExp, n));
});
return links.join('\n');
}
/**
* @description 生成字母变化链接
* @returns {string} 生成结果
*/
function generateLetter() {
let linkBaseVal = linkBase.value;
if (!linkBaseVal) {
return '';
}
let data = modes.lc;
// 编码数值
let cStart = data.start.charCodeAt(0);
let cEnd = data.end.charCodeAt(0);
let chars = [];
let links = [];
// 生成字母
if (cStart >= 65 && cStart <= 122 && cEnd >= 65 && cEnd <= 122) {
if (cStart < cEnd) {
for (let i = cStart; i <= cEnd; i++) {
// 跳过符号 [ \ ] ^ _ `
if (i >= 91 && i <= 96) {
continue;
}
chars.push(String.fromCharCode(i));
}
} else if (cStart > cEnd) {
$message.warning('字母先后顺序有误。注意:大写字母需要在前。');
return '';
} else if (cStart === cEnd) {
$message.warning('仅有 1 条链接,无需生成。');
return '';
} else {
$message.error('未知错误。');
return '';
}
} else {
$message.warning('输入有误,请检查字母范围。');
return '';
}
// 倒序
if (data.reverse) {
chars.reverse();
}
// 生成链接
chars.forEach((c) => {
links.push(linkBaseVal.replace(linkRegExp, c));
});
return links.join('\n');
}
/** 处理复制结果 */
function handleCopy() {
if (clipboard.isSupported) {
return clipboard.copy(linkResult.value).then(() => {
$message.success('复制成功');
}).catch((error) => {
console.error('复制失败:');
console.error(error);
$message.error('复制失败:异常');
});
} else {
$message.error('复制失败:当前浏览器不支持该操作');
return Promise.resolve();
}
}
/** 处理清空结果 */
function handleClear() {
linkResult.value = '';
}
</script>
<style lang="less" scoped>
.tool-detail-page {
:deep(.n-input-number) {
width: 8em;
}
:deep(.n-input) {
max-width: 48em;
}
:deep(.n-input--textarea) {
max-width: 48em;
font-family: monospace;
}
.mode-item {
padding: 12px 0;
border-bottom: 1px solid #eee;
&:last-child {
border-bottom: none;
}
:deep(.n-form) {
margin-left: 2em;
}
}
}
</style>

View File

@@ -1,9 +1,153 @@
<template>
<div class="tool-detail-page"></div>
<div class="tool-detail-page">
<!-- 配置参数 -->
<n-card size="small" title="配置参数">
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-form-item label="生成数量:">
<n-input-number
v-model:value="info.count"
:min="1"
:max="1000"
:step="1"
></n-input-number>
</n-form-item>
<n-form-item label="选项:">
<n-flex>
<n-checkbox
v-model:checked="info.options.withSplit"
>是否包含分隔符-</n-checkbox>
</n-flex>
</n-form-item>
</n-form>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex>
<n-button
type="primary"
@click="handleGenerate"
>生成 UUID V4</n-button>
<n-button
type="primary"
:disabled="!info.result"
@click="handleCopy"
>复制结果</n-button>
</n-flex>
</n-card>
<!-- 结果 -->
<n-card size="small" title="结果">
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-form-item>
<n-input
v-model:value="info.result"
placeholder="生成的 UUID 将显示在这里"
type="textarea"
:readonly="true"
:rows="8"
></n-input>
</n-form-item>
</n-form>
</n-card>
</div>
</template>
<script setup>
import {
NButton, NCard, NCheckbox,
NFlex, NForm, NFormItem,
NInput, NInputNumber,
} from 'naive-ui';
import {
reactive,
} from 'vue';
import {
$message,
} from '@/assets/js/naive-ui';
import {
useClipboard,
} from '@vueuse/core';
import {
getUuidV4,
} from '@/assets/js/utils';
const clipboard = useClipboard({
legacy: true,
read: false,
});
const info = reactive({
count: 1,
options: {
withSplit: true,
},
result: '',
});
/** 处理复制操作 */
function handleCopy() {
if (clipboard.isSupported) {
return clipboard.copy(info.result).then(() => {
$message.success('复制成功');
}).catch((error) => {
console.error('复制失败:');
console.error(error);
$message.error('复制失败:异常');
});
} else {
$message.error('复制失败:当前浏览器不支持该操作');
return Promise.resolve();
}
}
/** 处理生成操作 */
function handleGenerate() {
let count = info.count;
let opt = info.options;
let results = [];
for (let i = 0; i < count; i++) {
let uuid = getUuidV4(!opt.withSplit);
results.push(uuid);
}
info.result = results.join('\n');
}
</script>
<style lang="less" scoped>
.tool-detail-page {
:deep(.n-input-number) {
width: 8em;
}
:deep(.n-input--textarea) {
max-width: 48em;
font-family: monospace;
}
}
</style>

View File

@@ -7,8 +7,8 @@
<n-p>若内容出现乱码请尝试更改文件编码后重新打开文件</n-p>
</n-card>
<!-- 设置 -->
<n-card size="small" title="设置">
<!-- 配置参数 -->
<n-card size="small" title="配置参数">
<n-flex>
<!-- 文件编码 -->
<div class="config-item">

View File

@@ -1,8 +1,8 @@
<template>
<div class="tool-detail-page">
<!-- 注意 -->
<n-card size="small" title="注意">
<!-- 注意事项 -->
<n-card size="small" title="注意事项">
<n-p>由于浏览器限制通过 HTTPS 访问网站时只能连接带 SSL WebSocketWSS</n-p>
<n-p>若需要连接不带 SSL WebSocketWS建议下载到本地后使用</n-p>
</n-card>
@@ -73,8 +73,8 @@
</n-flex>
</n-card>
<!-- 日志 -->
<n-card size="small" title="日志">
<!-- 日志内容 -->
<n-card size="small" title="日志内容">
<div
ref="logsContentRef"
class="logs-content"
@@ -107,8 +107,8 @@
</div>
</n-card>
<!-- 设置 -->
<n-card size="small" title="设置">
<!-- 配置参数 -->
<n-card size="small" title="配置参数">
<n-form
class="form-no-feedback"
label-align="left"

View File

@@ -13,71 +13,78 @@
}"
@click="isFaded = false"
>
<div class="controls">
<div class="title">开关 / Switch</div>
<n-switch
:value="wakeLock.isActive.value"
size="medium"
:round="false"
@update:value="handleToggleWakeLock"
<!-- 配置参数 -->
<n-card size="small" title="配置参数">
<n-form
class="form-no-feedback"
label-align="left"
label-placement="left"
label-width="auto"
>
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
<div class="title">背景颜色 / Background Color</div>
<n-form-item label="开关 / Switch">
<n-switch
:value="wakeLock.isActive.value"
size="medium"
:round="false"
@update:value="handleToggleWakeLock"
>
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
</n-form-item>
<n-color-picker
v-model:value="bgColor"
size="medium"
:modes="['hex']"
:show-preview="true"
:swatches="[
'#000000',
'#252525',
'#505050',
'#808080',
'#FFFFFF',
]"
/>
<n-form-item label="背景颜色 / Background Color">
<n-color-picker
v-model:value="bgColor"
size="medium"
:modes="['hex']"
:show-preview="true"
:swatches="[
'#000000',
'#252525',
'#505050',
'#808080',
'#FFFFFF',
]"
/>
</n-form-item>
<div
v-show="fullscreen.isSupported"
class="title"
>切换全屏 / Toggle Fullscreen</div>
<n-form-item
v-show="fullscreen.isSupported"
label="切换全屏 / Toggle Fullscreen"
>
<n-switch
size="medium"
:round="false"
:value="fullscreen.isFullscreen.value"
@update:value="handleToggleFullscreen"
>
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
</n-form-item>
<n-switch
v-show="fullscreen.isSupported"
size="medium"
:round="false"
:value="fullscreen.isFullscreen.value"
@update:value="handleToggleFullscreen"
>
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
<n-form-item label="隐藏界面 / Hide UI">
<n-switch
v-model:value="isFaded"
size="medium"
:round="false"
@click.stop
>
<template #checked>隐藏</template>
<template #unchecked>显示</template>
</n-switch>
</n-form-item>
<div class="title">隐藏界面 / Hide UI</div>
<n-switch
v-model:value="isFaded"
size="medium"
:round="false"
@click.stop
>
<template #checked>隐藏</template>
<template #unchecked>显示</template>
</n-switch>
</div>
</n-form>
</n-card>
</div>
</template>
<script setup>
import {
NColorPicker, NSwitch,
NCard, NColorPicker, NForm, NFormItem, NSwitch,
} from 'naive-ui';
import {
@@ -178,7 +185,6 @@ onBeforeUnmount(() => {
<style lang="less" scoped>
.tool-detail-page {
display: flex;
background-color: inherit;
transition: all 0.25s;
user-select: none;
@@ -190,26 +196,19 @@ onBeforeUnmount(() => {
&.is-on {
background-color: var(--bg-color);
&.is-dark-color {
&.is-dark-color * {
color: #FFF;
}
}
> * {
transition: opacity 0.25s;
:deep(.n-card) {
border-color: transparent;
}
}
}
.controls {
margin: auto;
padding-bottom: 48px;
text-align: center;
}
.title {
margin-top: 48px;
margin-bottom: 16px;
font-size: 24px;
:deep(.n-card) {
background-color: transparent;
transition: all 0.25s;
}
:deep(.n-color-picker) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
<template>
<div class="tool-detail-page">
<!-- 设置 -->
<n-card size="small" title="设置">
<!-- 配置参数 -->
<n-card size="small" title="配置参数">
<n-form
class="form-no-feedback config-inputs"
label-align="left"

View File

@@ -1,9 +1,336 @@
<template>
<div class="tool-detail-page"></div>
<div class="tool-detail-page">
<!-- 操作 -->
<n-card class="actions" size="small" title="操作">
<n-flex>
<n-button
type="primary"
@click="handleRunCode"
>执行代码</n-button>
<n-button
type="error"
@click="handleClearContent"
>清空内容</n-button>
<n-button
type="default"
@click="handleClearOutput"
>清空输出</n-button>
</n-flex>
</n-card>
<!-- 代码和结果 -->
<div class="code-and-result">
<!-- 编辑器 -->
<n-card size="small" title="编辑器">
<div ref="editorContainer" class="editor-container"></div>
</n-card>
<!-- 执行结果 -->
<n-card size="small" title="执行结果">
<n-scrollbar class="output-container">
<pre class="output-content">{{ outputContent }}</pre>
</n-scrollbar>
</n-card>
</div>
</div>
</template>
<script setup>
import {
NButton, NCard, NFlex, NScrollbar,
} from 'naive-ui';
import {
ref, shallowRef,
onBeforeUnmount, onMounted,
} from 'vue';
import {
initMonacoEnvironment,
} from '@/assets/js/monaco-editor';
import {
$dialog, $message,
} from '@/assets/js/naive-ui';
import * as monaco from 'monaco-editor';
/** 模块名称 */
const PREFIX = '[RunJavaScript]';
/**
* @desc 编辑器实例(注:内部不能为响应式,防止出现部分操作会导致界面卡死等异常)
* @type {VueRef<monaco.editor.IStandaloneCodeEditor>}
*/
const editorInstance = shallowRef(null);
/** @type {VueRef<HTMLElement>} */
const editorContainer = ref(null);
/** 防抖定时器 */
const resizeTimer = ref(null);
/** 输出内容 */
const outputContent = ref('');
/** 处理清空内容操作 */
function handleClearContent() {
$dialog.create({
content: '确定要清空编辑器内容吗?',
negativeText: '取消',
positiveText: '确定',
title: '确认',
type: 'default',
onPositiveClick: () => {
updateEditorContent('');
},
});
}
/** 处理清空输出操作 */
function handleClearOutput() {
outputContent.value = '';
}
/** 处理执行代码操作 */
function handleRunCode() {
let code = editorInstance.value?.getValue() || '';
if (!code.trim()) {
$message.warning('请先输入要执行的 JavaScript 代码');
return;
}
outputContent.value += '> 开始执行...\n';
// 创建控制台重定向
let originalConsole = {
log: console.log,
error: console.error,
warn: console.warn,
info: console.info,
};
// 重定向控制台输出到结果区域
console.log = (...args) => {
originalConsole.log(...args);
outputContent.value += '[LOG] ' + formatConsoleArgs(args) + '\n';
};
console.error = (...args) => {
originalConsole.error(...args);
outputContent.value += '[ERROR] ' + formatConsoleArgs(args) + '\n';
};
console.warn = (...args) => {
originalConsole.warn(...args);
outputContent.value += '[WARN] ' + formatConsoleArgs(args) + '\n';
};
console.info = (...args) => {
originalConsole.info(...args);
outputContent.value += '[INFO] ' + formatConsoleArgs(args) + '\n';
};
try {
// 执行代码
let result = eval(code);
// 如果有返回值且不是 undefined输出结果
if (result !== undefined) {
outputContent.value += '返回值: ' + JSON.stringify(result, null, 2) + '\n';
}
outputContent.value += '> 执行完成' + '\n';
} catch (error) {
outputContent.value += '错误: ' + error.message + '\n';
} finally {
// 恢复原控制台
Object.assign(console, originalConsole);
}
}
/** 格式化控制台参数 */
function formatConsoleArgs(args) {
return args.map(arg => {
if (typeof arg === 'object') {
return JSON.stringify(arg, null, 2);
} else {
return String(arg);
}
}).join(' ');
}
/** 处理窗口大小变化 */
function handleWindowResize() {
clearTimeout(resizeTimer.value);
resizeTimer.value = setTimeout(() => {
if (editorInstance.value) {
editorInstance.value.layout();
}
}, 200);
}
/** 初始化编辑器 */
function initEditor() {
console.log(PREFIX, 'initEditor');
let container = editorContainer.value;
let editor = null;
let valueStr = `// 在这里输入 JavaScript 代码\nconsole.log('Hello, World!');`;
if (!container) {
console.error(PREFIX, '初始化失败:元素不存在');
return;
}
editor = monaco.editor.create(container, {
autoDetectHighContrast: false,
automaticLayout: false,
contextmenu: true,
find: {
cursorMoveOnType: false,
},
fontFamily: 'monospace',
fontSize: 14,
language: 'javascript',
minimap: {
enabled: true,
renderCharacters: false,
},
mouseWheelScrollSensitivity: 2,
stickyScroll: {
enabled: false,
},
tabSize: 2,
theme: 'vs',
value: valueStr,
});
editorInstance.value = editor;
}
/** 销毁编辑器 */
function resetEditor() {
console.log(PREFIX, 'resetEditor');
try {
if (editorInstance.value) {
editorInstance.value.getModel().dispose()
editorInstance.value.dispose();
editorInstance.value = null;
}
return true;
} catch (error) {
console.error(PREFIX, '销毁失败:');
console.error(error);
return false;
}
}
/** 更新编辑器内容 */
function updateEditorContent(text = '') {
if (editorInstance.value) {
editorInstance.value.setValue(text);
}
}
onMounted(() => {
initMonacoEnvironment();
initEditor();
window.addEventListener('resize', handleWindowResize);
});
onBeforeUnmount(() => {
resetEditor();
window.removeEventListener('resize', handleWindowResize);
});
</script>
<style lang="less" scoped>
.tool-detail-page {
display: flex;
flex-direction: column;
container-type: inline-size;
}
.actions {
height: auto;
}
.code-and-result {
display: flex;
flex-direction: row;
flex-grow: 1;
gap: 20px;
margin-top: 20px;
height: 0;
.n-card {
width: 0;
height: 100%;
}
:deep(.n-card__content) {
height: 0;
}
.n-card:first-child {
flex-grow: 1.5;
}
.n-card:last-child {
flex-grow: 1;
}
}
@container (max-width: 640px) {
.code-and-result {
display: block;
.n-card {
width: 100%;
height: 100%;
}
.n-card:last-child {
margin-top: 20px;
}
}
}
.editor-container {
width: 100%;
height: 100%;
outline: 1px solid var(--n-border-color);
}
.output-container {
width: 100%;
height: 100%;
}
.output-content {
margin: 0;
padding: 8px;
background-color: #F5F5F5;
border-radius: 4px;
line-height: 1.5;
font-family: monospace;
font-size: 14px;
color: #252525;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,416 @@
<template>
<div class="tool-detail-page">
<!-- 计时器 -->
<n-card size="small" title="计时器">
<div class="timer-display">
<span class="time-main">{{ formatTimeMain(data.timeDisplay) }}</span>
<span class="time-milliseconds">{{ formatTimeMilliseconds(data.timeDisplay) }}</span>
</div>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex justify="center">
<n-button
type="success"
:disabled="data.isRunning"
@click="startTimer"
>开始</n-button>
<n-button
type="warning"
:disabled="!data.isRunning"
@click="pauseTimer"
>暂停</n-button>
<n-button
type="error"
@click="resetTimer"
>重置</n-button>
<n-button
type="primary"
@click="recordTime"
>记录</n-button>
</n-flex>
</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-radio-group v-model:value="data.timerMode">
<n-radio-button label="正计时" :value="'countUp'" />
<n-radio-button label="倒计时" :value="'countDown'" />
</n-radio-group>
</n-form-item>
<!-- 倒计时时间设置 -->
<n-form-item label="倒计时设置" v-if="data.timerMode === 'countDown'">
<n-input-group>
<n-input-number
v-model:value="data.countDownTime.hours"
:min="0"
:max="99"
:precision="0"
:step="1"
></n-input-number>
<span class="time-separator"></span>
<n-input-number
v-model:value="data.countDownTime.minutes"
:min="0"
:max="59"
:precision="0"
:step="1"
></n-input-number>
<span class="time-separator"></span>
<n-input-number
v-model:value="data.countDownTime.seconds"
:min="0"
:max="59"
:precision="0"
:step="1"
></n-input-number>
<span class="time-separator"></span>
</n-input-group>
</n-form-item>
</n-form>
</n-card>
<!-- 时间记录 -->
<n-card size="small" title="时间记录">
<div class="records-content">
<div
v-if="data.records.length === 0"
class="no-records"
>暂无记录</div>
<div
v-for="(record, index) in data.records"
:key="record.id"
class="record-item"
>
<span class="record-index">{{ index + 1 }}</span>
<span class="record-time">{{ formatTime(record.time) }}</span>
<span class="record-timestamp">{{ formatDateTime(record.timestamp) }}</span>
</div>
</div>
<n-button
type="error"
size="small"
class="clear-records-btn"
@click="clearRecords"
>清空记录</n-button>
</n-card>
</div>
</template>
<script setup>
import {
NButton, NCard, NFlex, NForm, NFormItem, NInputGroup, NInputNumber, NRadioButton, NRadioGroup,
} from 'naive-ui';
import {
reactive, onBeforeUnmount, watch,
} from 'vue';
import {
getCommonDateTime,
} from '@frost-utils/javascript/common/index';
import {
$dialog, $notification,
} from '@/assets/js/naive-ui';
/** 数据 */
const data = reactive({
/** 计时器模式countUp正计时, countDown倒计时 */
timerMode: 'countUp',
/** 是否正在运行 */
isRunning: false,
/** 当前显示时间(毫秒) */
timeDisplay: 0,
/** 倒计时时间设置 */
countDownTime: {
hours: 0,
minutes: 5,
seconds: 0
},
/** 计时器ID */
timerID: null,
/** 开始时间戳 */
startTime: 0,
/** 已运行时间 */
elapsedTime: 0,
/** 时间记录 */
records: [],
/** 记录ID */
recordID: 0,
});
/** 格式化时间显示主时间部分HH:MM:SS */
function formatTimeMain(milliseconds) {
let totalSeconds = Math.floor(milliseconds / 1000);
let hours = Math.floor(totalSeconds / 3600);
let minutes = Math.floor((totalSeconds % 3600) / 60);
let seconds = totalSeconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
/** 格式化时间显示(毫秒部分:.SSS */
function formatTimeMilliseconds(milliseconds) {
let ms = milliseconds % 1000;
return `.${ms.toString().padStart(3, '0')}`;
}
/** 格式化完整时间显示(用于记录和提示) */
function formatTime(milliseconds) {
return `${formatTimeMain(milliseconds)}${formatTimeMilliseconds(milliseconds)}`;
}
/** 格式化日期时间 */
function formatDateTime(timestamp) {
return getCommonDateTime(timestamp);
}
/** 开始计时器 */
function startTimer() {
if (data.timerMode === 'countUp') {
// 正计时模式
data.startTime = Date.now() - data.elapsedTime;
data.timerID = setInterval(() => {
data.elapsedTime = Date.now() - data.startTime;
data.timeDisplay = data.elapsedTime;
}, 10); // 每10毫秒更新一次提高毫秒显示精度
} else {
// 倒计时模式
if (data.timeDisplay === 0) {
// 如果是首次开始或重置后开始,设置倒计时时间
const { hours, minutes, seconds } = data.countDownTime;
data.timeDisplay = (hours * 3600 + minutes * 60 + seconds) * 1000;
}
data.startTime = Date.now() + data.timeDisplay;
data.timerID = setInterval(() => {
data.timeDisplay = Math.max(0, data.startTime - Date.now());
data.elapsedTime = (data.startTime - data.timeDisplay) - data.startTime + data.timeDisplay;
// 倒计时结束
if (data.timeDisplay === 0) {
pauseTimer();
notify({
message: '倒计时结束!',
type: 'success',
title: '提示'
});
}
}, 10); // 每10毫秒更新一次提高毫秒显示精度
}
data.isRunning = true;
}
/** 暂停计时器 */
function pauseTimer() {
if (data.timerID) {
clearInterval(data.timerID);
data.timerID = null;
data.isRunning = false;
}
}
/** 重置计时器 */
function resetTimer() {
pauseTimer();
data.elapsedTime = 0;
data.timeDisplay = 0;
}
/** 记录时间 */
function recordTime() {
data.recordID += 1;
data.records.push({
id: data.recordID,
time: data.timeDisplay,
timestamp: Date.now()
});
notify({
message: `已记录时间: ${formatTime(data.timeDisplay)}`,
type: 'info',
title: '记录成功'
});
}
/** 清空记录 */
function clearRecords() {
$dialog.create({
content: '确定要清空所有时间记录吗?',
negativeText: '取消',
positiveText: '确定',
title: '确认',
type: 'default',
onPositiveClick: () => {
data.records = [];
data.recordID = 0;
},
});
}
/**
* @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,
});
}
/** 监听计时模式切换 */
watch(
() => data.timerMode,
() => {
// 切换模式时重置计时器
resetTimer();
}
);
/** 监听倒计时时间设置变化 */
watch(
() => [data.countDownTime.hours, data.countDownTime.minutes, data.countDownTime.seconds],
() => {
// 只有在倒计时模式且计时器未运行时,才更新显示时间
if (data.timerMode === 'countDown' && !data.isRunning && data.timeDisplay === 0) {
const { hours, minutes, seconds } = data.countDownTime;
data.timeDisplay = (hours * 3600 + minutes * 60 + seconds) * 1000;
}
},
{ deep: true }
);
onBeforeUnmount(() => {
if (data.timerID) {
clearInterval(data.timerID);
}
});
</script>
<style lang="less" scoped>
.timer-display {
font-size: 48px;
font-weight: bold;
text-align: center;
padding: 20px 0;
color: #2196F3;
font-family: 'Courier New', monospace;
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
.time-main {
font-size: 48px;
}
.time-milliseconds {
font-size: 32px;
color: #666;
margin-left: 2px;
}
}
.time-separator {
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
font-size: 16px;
color: #666;
}
.records-content {
padding: 8px 16px;
border: 1px solid #F0F0F0;
border-radius: 4px;
background-color: #FFF;
line-height: 1.6;
max-height: 200px;
overflow-y: auto;
margin-bottom: 12px;
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px dashed #F0F0F0;
&:last-child {
border-bottom: none;
}
}
.record-index {
width: 30px;
color: #999;
}
.record-time {
flex: 1;
text-align: center;
font-weight: bold;
color: #2196F3;
font-family: 'Courier New', monospace;
}
.record-timestamp {
color: #999;
font-size: 12px;
}
.no-records {
text-align: center;
color: #999;
padding: 20px 0;
}
}
.clear-records-btn {
display: block;
margin-left: auto;
}
</style>

View File

@@ -1,12 +1,12 @@
<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>
<span class="card-title__label">配置参数</span>
</div>
</template>
<n-form

View File

@@ -4,7 +4,7 @@ import { KEY_PREFIX } from '@/assets/js/local-storage';
/** 模块名称 */
const STORAGE_PREFIX = KEY_PREFIX + 'visualized-working-hours/';
/** 配置选项 */
/** 配置参数 */
export const configData = {
/** 收入币种 */

View File

@@ -20,6 +20,13 @@ export default defineConfig({
},
},
envPrefix: 'V_ENV_',
optimizeDeps: {
include: [
// 预构建 Monaco Editor Worker
'monaco-editor/esm/vs/editor/editor.worker',
'monaco-editor/esm/vs/language/json/json.worker',
],
},
plugins: [
legacy({
polyfills: false,
@@ -37,6 +44,7 @@ export default defineConfig({
},
},
server: {
allowedHosts: true,
port: 9000,
},
});