├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .stylelintrc.js ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── electron-builder.json ├── electron ├── config │ └── index.ts ├── data │ ├── cacert.pem │ └── config.json ├── main │ ├── developer.ts │ ├── file.ts │ ├── helper.ts │ ├── image.ts │ └── index.ts ├── preload │ └── index.ts └── resource │ ├── html │ └── loading.html │ └── image │ ├── loading.svg │ └── logo.png ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── favicon.ico ├── src ├── config │ ├── index.ts │ └── type.ts ├── env.d.ts ├── extend │ └── index.js ├── helper │ ├── ajax.ts │ ├── crypto.ts │ ├── index.ts │ ├── storage.ts │ └── validate.ts ├── layout │ ├── App.tsx │ └── Index.tsx ├── lib │ └── db.ts ├── main.ts ├── page │ ├── developer │ │ ├── babel.tsx │ │ ├── css.tsx │ │ ├── javascript.tsx │ │ ├── regex.tsx │ │ ├── translate.tsx │ │ └── typescript.tsx │ ├── error │ │ └── 404.tsx │ ├── image │ │ ├── clip.tsx │ │ ├── compress.tsx │ │ ├── ico.tsx │ │ └── watermark.tsx │ ├── index.tsx │ ├── qrcode │ │ ├── create.tsx │ │ └── decode.tsx │ ├── util │ │ ├── date-timestamp.tsx │ │ ├── num-money.tsx │ │ └── timestamp-date.tsx │ └── video │ │ ├── m3u8.tsx │ │ └── parse.tsx ├── router │ └── index.ts ├── service │ ├── common.ts │ ├── devoloper.ts │ ├── qrcode.ts │ ├── util.ts │ └── video.ts └── static │ ├── image │ ├── error-404.png │ ├── logo.png │ └── success.png │ └── style │ ├── animate.scss │ ├── app.scss │ ├── common.scss │ ├── flex.scss │ ├── font.scss │ ├── mixin.scss │ └── var.scss ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /dist -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: ["plugin:@typescript-eslint/recommended", "plugin:vue/vue3-recommended", "plugin:prettier/recommended"], 7 | parserOptions: { 8 | parser: "@typescript-eslint/parser", 9 | ecmaFeatures: { 10 | jsx: true, 11 | }, 12 | ecmaVersion: 12, 13 | sourceType: "module", 14 | }, 15 | rules: {}, 16 | settings: {}, 17 | globals: { 18 | JSX: true, 19 | NodeJS: true, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | release 14 | *.local 15 | 16 | # electron/data 17 | 18 | # Editor directories and files 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /dist -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', // 箭头函数只有一个参数的时候可以忽略括号 3 | endOfLine: 'lf', // 行结束符使用 Unix 格式 4 | printWidth: 140, // 行宽 5 | proseWrap: 'preserve', // 换行方式 6 | semi: true, // 句尾添加分号 7 | tabWidth: 2, // 缩进 8 | trailingComma: 'es5', // 在对象或数组最后一个元素后面是否加逗号(在ES5中加尾逗号) 9 | bracketSpacing: true, // 在对象,数组括号与文字之间加空格 10 | semicolons: true, // 在语句末尾打印分号 11 | } -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "plugins": [ 3 | 'stylelint-declaration-block-no-ignored-properties', 4 | "stylelint-scss" 5 | ], 6 | extends: ['stylelint-config-standard', 'stylelint-config-standard-scss'], 7 | rules: { 8 | "declaration-block-no-duplicate-properties": null, 9 | "no-invalid-double-slash-comments": null, 10 | "font-family-name-quotes": null, 11 | "font-family-no-missing-generic-family-keyword": null, 12 | "unit-case": null, 13 | "unit-no-unknown": null, 14 | "alpha-value-notation": null, 15 | "selector-class-pattern": null, 16 | "value-keyword-case": null, 17 | "scss/at-import-partial-extension": null, 18 | }, 19 | }; -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["johnsoncodehk.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": true, 5 | "source.fixAll.stylelint": true 6 | }, 7 | "eslint.validate": [ 8 | "javascript", 9 | "javascriptreact", 10 | "typescript", 11 | "typescriptreact", 12 | "html", 13 | "vue" 14 | ], 15 | "stylelint.snippet": [ 16 | "css", 17 | "less", 18 | "postcss", 19 | "scss", 20 | "sass" 21 | ], 22 | "stylelint.validate": [ 23 | "css", 24 | "less", 25 | "postcss", 26 | "scss", 27 | "sass" 28 | ], 29 | "files.eol": "\n" 30 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 沃德工具箱 2 | 3 | 项目基于`Vue3`+`TypeScript`+`Vite`+`Naive UI`+`Electron`开发,旨在通过一些常用工具的开发为日常工作提供方便。 4 | 5 | ## 主要功能(持续迭代中...) 6 | - 图片 7 | - [图片压缩](https://tool.hellowmonkey.cc/image/compress) 8 | - [图片裁剪](https://tool.hellowmonkey.cc/image/clip) 9 | - [图片加水印](https://tool.hellowmonkey.cc/image/watermark) 10 | - [ico生成](https://tool.hellowmonkey.cc/image/ico) 11 | - 二维码 12 | - [生成二维码](https://tool.hellowmonkey.cc/qrcode/create) 13 | - [解析二维码](https://tool.hellowmonkey.cc/qrcode/decode) 14 | - 视频 15 | - [m3u8下载](https://tool.hellowmonkey.cc/video/m3u8) 16 | - [视频在线解析](https://tool.hellowmonkey.cc/video/parse) 17 | - 开发 18 | - [常用正则](https://tool.hellowmonkey.cc/developer/regex) 19 | - [程序变量命名](https://tool.hellowmonkey.cc/developer/translate) 20 | - 日常 21 | - [数字转大写金额](https://tool.hellowmonkey.cc/util/num-money) 22 | - [时间戳转日期](https://tool.hellowmonkey.cc/util/timestamp-date) 23 | - [日期转时间戳](https://tool.hellowmonkey.cc/util/date-timestamp) 24 | > 其中[m3u8下载](https://tool.hellowmonkey.cc/video/m3u8)功能为[沃德影视](https://movie.hellowmonkey.cc/)提供视频了下载支持 25 | -------------------------------------------------------------------------------- /electron-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "cc.hellowmonkey.tool", 3 | "productName": "沃德工具箱", 4 | "copyright": "2022", 5 | "directories": { 6 | "output": "release", 7 | "buildResources": "electron/resource" 8 | }, 9 | "win": { 10 | "icon": "dist/electron/resource/image/logo.png", 11 | "target": "nsis", 12 | "legalTrademarks": "hellowmonkey", 13 | "artifactName": "${productName}-Windows-Setup-${version}.${ext}" 14 | }, 15 | "nsis": { 16 | "oneClick": false, 17 | "perMachine": true, 18 | "allowToChangeInstallationDirectory": true, 19 | "createDesktopShortcut": true, 20 | "createStartMenuShortcut": false 21 | }, 22 | "files": [ 23 | "dist" 24 | ] 25 | } -------------------------------------------------------------------------------- /electron/config/index.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | const isDev = process.env.NODE_ENV === "development"; 3 | 4 | export default { 5 | isDev, 6 | tinifyKeys: ["4RxZwMzdcMT4ksdgYnVYJzMtn2R7cgCT", "XrHtLVmrnvnhGLHH2RCkRN9BPm7ZdJg1", "ZZtYtycXQk4d5P11NmFTt70YnJrJx1Qk"], 7 | width: 1200, 8 | height: 800, 9 | title: "沃德工具箱", 10 | icon: join(__dirname, "../resource/image/logo.png"), 11 | url: isDev ? "http://127.0.0.1:3030" : "https://tool.hellowmonkey.cc", 12 | youdaoAppId: "4942fc478d27c774", 13 | youdaoKey: "qLDdY5g9qUNQN8WV1WBSVOGYjRfug0mq", 14 | baiduAppId: "20221003001368543", 15 | baiduKey: "tVV9MC_Jdtse2oO3CAEU", 16 | scheme: "tool-box", 17 | }; 18 | -------------------------------------------------------------------------------- /electron/data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "keyboard": "Alt+CommandOrControl+E", 3 | "openAtLogin": false, 4 | "compressDirs": [], 5 | "compressNotify": true 6 | } 7 | -------------------------------------------------------------------------------- /electron/main/developer.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | import { ajax } from "./helper"; 3 | import config from "../config"; 4 | 5 | function truncate(q: string) { 6 | const len = q.length; 7 | if (len <= 20) return q; 8 | return q.substring(0, 10) + len + q.substring(len - 10, len); 9 | } 10 | 11 | // 有道翻译 12 | export interface IYoudao { 13 | errorCode: string; 14 | query: string; 15 | translation?: string[]; 16 | basic?: { 17 | explains: string[]; 18 | }; 19 | web?: { key: string; value: string[] }[]; 20 | } 21 | export function youdaoTranslate(q = "") { 22 | const appKey = config.youdaoAppId; 23 | const now = Date.now(); 24 | const salt = now; 25 | const curtime = Math.round(now / 1000); 26 | const from = "zh-CHS"; 27 | const to = "en"; 28 | const sign = createHash("SHA256") 29 | .update(appKey + truncate(q) + salt + curtime + config.youdaoKey) 30 | .digest("hex"); 31 | 32 | return ajax("https://openapi.youdao.com/api", { 33 | q, 34 | appKey, 35 | salt, 36 | from, 37 | to, 38 | sign, 39 | signType: "v3", 40 | curtime, 41 | }).then(data => { 42 | if (data.errorCode === "0") { 43 | const arr: string[] = []; 44 | if (data.translation?.length) { 45 | arr.push(...data.translation); 46 | } 47 | if (data.basic?.explains.length) { 48 | arr.push(...data.basic.explains); 49 | } 50 | if (data.web?.length) { 51 | const value = data.web.find(v => v.key === q)?.value; 52 | if (value?.length) { 53 | arr.push(...value); 54 | } 55 | } 56 | return arr; 57 | } else { 58 | return Promise.reject(new Error("youdao error")); 59 | } 60 | }); 61 | } 62 | 63 | // 百度翻译 64 | export interface IBaidu { 65 | from: string; 66 | to: string; 67 | trans_result: { 68 | src: string; 69 | dst: string; 70 | }[]; 71 | error_msg?: string; 72 | } 73 | export function baiduTranslate(q = "") { 74 | const appid = config.baiduAppId; 75 | const salt = Date.now(); 76 | const from = "zh"; 77 | const to = "en"; 78 | const sign = createHash("MD5") 79 | .update(appid + q + salt + config.baiduKey) 80 | .digest("hex"); 81 | 82 | return ajax("https://api.fanyi.baidu.com/api/trans/vip/translate", { 83 | q, 84 | appid, 85 | salt, 86 | from, 87 | to, 88 | sign, 89 | }).then(data => { 90 | if (data.error_msg) { 91 | return Promise.reject(new Error(data.error_msg)); 92 | } else { 93 | return data.trans_result.map(v => v.dst); 94 | } 95 | }); 96 | } 97 | 98 | // 翻译 99 | export async function translate(words: string) { 100 | const arr: string[] = []; 101 | try { 102 | await Promise.any([ 103 | youdaoTranslate(words).then(data => { 104 | arr.push(...data); 105 | }), 106 | baiduTranslate(words).then(data => { 107 | arr.push(...data); 108 | }), 109 | ]); 110 | } finally { 111 | return arr; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /electron/main/file.ts: -------------------------------------------------------------------------------- 1 | import { dialog, SaveDialogOptions, shell } from "electron"; 2 | import { writeFileSync } from "fs-extra"; 3 | import { getFilePath } from "./helper"; 4 | import { compressImage } from "./image"; 5 | 6 | // 保存文件弹框 7 | export function saveDialog(opts: SaveDialogOptions) { 8 | return dialog.showSaveDialog(opts).then(data => data.filePath); 9 | } 10 | 11 | // 保存base64文件 12 | export async function saveBase64File(base64Str: string, name = "") { 13 | base64Str = base64Str.replace(/^data:image\/\w+;base64,/, ""); 14 | const fullPath = await saveDialog({ title: "保存图片", defaultPath: name, filters: [{ extensions: ["png"], name }] }); 15 | if (!fullPath) { 16 | return Promise.reject("位置选择错误"); 17 | } 18 | writeFileSync(fullPath, Buffer.from(base64Str, "base64")); 19 | const data = await compressImage(fullPath); 20 | const { fileName, filePath } = getFilePath(fullPath); 21 | return { 22 | ...data, 23 | fullPath, 24 | fileName, 25 | filePath, 26 | }; 27 | } 28 | 29 | // 选择文件夹 30 | export function selectDirectory(title = "选择文件夹", defaultPath?: string) { 31 | return dialog.showOpenDialog({ title, defaultPath, properties: ["openDirectory"] }).then(data => data.filePaths[0]); 32 | } 33 | 34 | // 打开文件夹 35 | export function openDirectory(path: string) { 36 | return shell.openPath(path); 37 | } 38 | 39 | // 保存文件 40 | export async function writeFile(filePath: string, buf: NodeJS.ArrayBufferView) { 41 | return writeFileSync(filePath, buf); 42 | } 43 | -------------------------------------------------------------------------------- /electron/main/helper.ts: -------------------------------------------------------------------------------- 1 | import { sep } from "path"; 2 | import { Notification } from "electron"; 3 | import config from "../config"; 4 | import https from "https"; 5 | 6 | // 获取文件路径 7 | export function getFilePath(fullPath: string) { 8 | const arr = fullPath.split(sep); 9 | const fileName = arr[arr.length - 1]; 10 | const filePath = arr.slice(0, arr.length - 1).join(sep); 11 | return { 12 | fileName, 13 | filePath, 14 | }; 15 | } 16 | 17 | // 获取文件名和扩展名 18 | export function getFilePathInfo(fileName: string): [string, string] { 19 | const arr = fileName.split("."); 20 | const str = arr.slice(0, arr.length - 1).join(""); 21 | const ext = arr[arr.length - 1]; 22 | return [str, ext]; 23 | } 24 | 25 | // 通知 26 | export function notification(title: string, body: string) { 27 | if (!Notification.isSupported()) { 28 | return Promise.reject("不支持"); 29 | } 30 | return new Notification({ 31 | title, 32 | body, 33 | icon: config.icon, 34 | }).show(); 35 | } 36 | 37 | // 网络请求 38 | export function ajax(url: string, data?: any) { 39 | if (data && typeof data === "object") { 40 | const arr: string[] = []; 41 | Object.keys(data).forEach(k => { 42 | arr.push(`${k}=${data[k]}`); 43 | }); 44 | if (arr.length) { 45 | url = url + `?${arr.join("&")}`; 46 | } 47 | } 48 | return new Promise((resolve, reject) => { 49 | https 50 | .get(url, res => { 51 | const list: Uint8Array[] = []; 52 | res.on("data", chunk => { 53 | list.push(chunk); 54 | }); 55 | res.on("end", () => { 56 | try { 57 | let str = Buffer.concat(list).toString(); 58 | const freg = /^\w+\s?\(/g; 59 | const lreg = /\)$/g; 60 | if (freg.test(str) && lreg.test(str)) { 61 | str = str.replace(freg, "").replace(lreg, ""); 62 | } 63 | const data = JSON.parse(str); 64 | resolve(data); 65 | } catch (error) { 66 | reject(error); 67 | } 68 | }); 69 | }) 70 | .on("error", err => { 71 | reject(err); 72 | }); 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /electron/main/image.ts: -------------------------------------------------------------------------------- 1 | import tinify from "tinify"; 2 | import { join } from "path"; 3 | import * as fse from "fs-extra"; 4 | import { getFilePath, getFilePathInfo } from "./helper"; 5 | import toIco from "png-to-ico"; 6 | import config from "../config"; 7 | 8 | // 压缩图片 9 | export async function compressImage(orgPath: string, targetPath?: string, width?: number) { 10 | let i = 0; 11 | const { fileName, filePath } = getFilePath(orgPath); 12 | if (!targetPath) { 13 | targetPath = filePath; 14 | } 15 | if (width) { 16 | const [name, ext] = getFilePathInfo(fileName); 17 | targetPath = join(targetPath, `${name}--width-${width}.${ext}`); 18 | } else { 19 | targetPath = join(targetPath, fileName); 20 | } 21 | const fileSize = fse.statSync(orgPath).size; 22 | while (i < config.tinifyKeys.length) { 23 | tinify.key = config.tinifyKeys[i]; 24 | try { 25 | let source = tinify.fromFile(orgPath); 26 | if (width) { 27 | source = source.resize({ 28 | method: "scale", 29 | width, 30 | }); 31 | } 32 | await source.toFile(targetPath); 33 | const targetSize = fse.statSync(targetPath).size; 34 | return Promise.resolve({ fileSize, targetSize }); 35 | } catch (error) { 36 | i++; 37 | } 38 | } 39 | return Promise.reject("压缩失败"); 40 | } 41 | 42 | // png转ico 43 | export async function pngToIco(filePath: string, size = 32) { 44 | const { filePath: targetPath, fileName } = getFilePath(filePath); 45 | const targetFile = join(targetPath, "favicon.ico"); 46 | const tempPath = join(__dirname, "../temp"); 47 | const tempFile = join(tempPath, fileName); 48 | let i = config.tinifyKeys.length - 1; 49 | if (!fse.existsSync(tempPath)) { 50 | fse.mkdirSync(tempPath); 51 | } 52 | while (i >= 0) { 53 | tinify.key = config.tinifyKeys[i]; 54 | try { 55 | await tinify 56 | .fromFile(filePath) 57 | .resize({ 58 | method: "scale", 59 | width: size, 60 | }) 61 | .toFile(tempFile); 62 | break; 63 | } catch (error) { 64 | i--; 65 | } 66 | } 67 | const buf = await toIco([tempFile]); 68 | fse.writeFileSync(targetFile, buf); 69 | fse.removeSync(tempFile); 70 | return targetFile; 71 | } 72 | -------------------------------------------------------------------------------- /electron/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, globalShortcut, ipcMain, Menu, SaveDialogOptions, Tray, Notification, shell } from "electron"; 2 | import { join, resolve } from "path"; 3 | import { writeJSONSync, readJSONSync, existsSync, mkdirSync } from "fs-extra"; 4 | import chokidar from "chokidar"; 5 | import { compressImage, pngToIco } from "./image"; 6 | import { openDirectory, saveDialog, saveBase64File, selectDirectory, writeFile } from "./file"; 7 | import { getFilePath, notification } from "./helper"; 8 | import { translate } from "./developer"; 9 | import config from "../config"; 10 | import defaultUserConfig from "../data/config.json"; 11 | 12 | let tray: Tray, win: BrowserWindow; 13 | 14 | const schemeReg = new RegExp(`^${config.scheme}:\/\/`); 15 | 16 | const userConfigPath = app.getPath("userData"); 17 | const userConfigFile = join(userConfigPath, "config.json"); 18 | if (!existsSync(userConfigPath)) { 19 | mkdirSync(userConfigPath); 20 | } 21 | if (!existsSync(userConfigFile)) { 22 | writeJSONSync(userConfigFile, defaultUserConfig); 23 | } 24 | const userConfig: { keyboard: string; openAtLogin: boolean; compressDirs: string[]; compressNotify: boolean } = Object.assign( 25 | {}, 26 | defaultUserConfig, 27 | readJSONSync(userConfigFile) 28 | ); 29 | writeJSONSync(userConfigFile, userConfig); 30 | 31 | function toggleWin() { 32 | if (win?.isVisible()) { 33 | hideWin(); 34 | } else { 35 | showWin(); 36 | } 37 | } 38 | 39 | function showWin() { 40 | if (win.isMinimized()) win.restore(); 41 | win?.show(); 42 | win?.focus(); 43 | win?.setSkipTaskbar(false); 44 | win?.maximize(); 45 | } 46 | 47 | function hideWin() { 48 | win?.hide(); 49 | win?.setSkipTaskbar(true); 50 | } 51 | 52 | function destroyApp() { 53 | win?.destroy(); 54 | app.quit(); 55 | tray?.destroy(); 56 | } 57 | 58 | function createWindow() { 59 | win = new BrowserWindow({ 60 | width: config.width, 61 | height: config.height, 62 | title: config.title, 63 | minWidth: config.width, 64 | minHeight: config.height, 65 | autoHideMenuBar: true, 66 | backgroundColor: "#ffffff", 67 | webPreferences: { 68 | preload: join(__dirname, "../preload/index.js"), 69 | }, 70 | show: false, 71 | }); 72 | 73 | const loading = new BrowserWindow({ 74 | autoHideMenuBar: true, 75 | width: 375, 76 | height: 650, 77 | title: config.title, 78 | hasShadow: false, 79 | show: false, 80 | parent: win, 81 | }); 82 | 83 | const { openAsHidden } = app.getLoginItemSettings(); 84 | const hidden = process.argv.indexOf("--openAsHidden") !== -1 || openAsHidden; 85 | 86 | loading.loadFile(join(__dirname, "../resource/html/loading.html")); 87 | loading.setIcon(config.icon); 88 | loading.once("ready-to-show", () => { 89 | if (!hidden && !loading.isDestroyed()) { 90 | loading.show(); 91 | } 92 | }); 93 | loading.on("close", () => { 94 | if (!hidden) { 95 | showWin(); 96 | } 97 | if (config.isDev) { 98 | win.webContents.openDevTools(); 99 | } 100 | // 新建托盘 101 | tray = new Tray(config.icon); 102 | // 托盘名称 103 | tray.setToolTip(config.title); 104 | // 托盘菜单 105 | const contextMenu = Menu.buildFromTemplate([ 106 | { 107 | label: "显示", 108 | click: () => { 109 | showWin(); 110 | }, 111 | }, 112 | { 113 | type: "separator", 114 | }, 115 | { 116 | label: "退出", 117 | click: () => { 118 | destroyApp(); 119 | }, 120 | }, 121 | ]); 122 | // 载入托盘菜单 123 | tray.setContextMenu(contextMenu); 124 | 125 | tray.on("click", () => { 126 | toggleWin(); 127 | }); 128 | }); 129 | 130 | win.setIcon(config.icon); 131 | win.removeMenu(); 132 | 133 | let { url } = config; 134 | const schemeUrl = process.argv[process.argv.length - 1]; 135 | if (schemeReg.test(schemeUrl)) { 136 | url = schemeUrl.replace(schemeReg, config.url + "/"); 137 | } 138 | 139 | win.loadURL(url).then(() => { 140 | loading.hide(); 141 | loading.close(); 142 | }); 143 | win.on("close", e => { 144 | e.preventDefault(); 145 | hideWin(); 146 | }); 147 | } 148 | 149 | app 150 | .whenReady() 151 | .then(() => { 152 | createWindow(); 153 | 154 | app.on("activate", () => { 155 | if (BrowserWindow.getAllWindows().length === 0) { 156 | createWindow(); 157 | } 158 | }); 159 | }) 160 | .then(() => { 161 | // 快捷键 162 | if (userConfig.keyboard) { 163 | globalShortcut.register(userConfig.keyboard, () => { 164 | toggleWin(); 165 | }); 166 | } 167 | // 开机自启 168 | app.setLoginItemSettings({ 169 | openAtLogin: app.isPackaged && userConfig.openAtLogin, 170 | args: ["--openAsHidden"], 171 | openAsHidden: true, 172 | }); 173 | // 文件压缩目录 174 | if (userConfig.compressDirs.length) { 175 | chokidar 176 | .watch(userConfig.compressDirs, { 177 | persistent: true, 178 | depth: 9, 179 | }) 180 | .on("add", (path, stat) => { 181 | if (stat?.birthtime && Date.now() - stat.birthtime.getTime() < 60 * 1000) { 182 | compressImage(path).then(() => { 183 | if (userConfig.compressNotify) { 184 | const { filePath } = getFilePath(path); 185 | const notify = new Notification({ 186 | title: "图片压缩成功", 187 | body: path, 188 | icon: config.icon, 189 | }); 190 | notify.on("click", () => { 191 | openDirectory(filePath); 192 | }); 193 | notify.show(); 194 | } 195 | }); 196 | } 197 | }); 198 | } 199 | }); 200 | 201 | // 设置注册表 202 | if (!app.isDefaultProtocolClient(config.scheme)) { 203 | if (app.isPackaged) { 204 | app.setAsDefaultProtocolClient(config.scheme); 205 | } else { 206 | app.setAsDefaultProtocolClient(config.scheme, process.execPath, [resolve(process.argv[1])]); 207 | } 208 | } 209 | // app.removeAsDefaultProtocolClient(config.scheme, process.execPath, [resolve(process.argv[1])]); 210 | 211 | app.on("window-all-closed", () => { 212 | if (process.platform !== "darwin") { 213 | destroyApp(); 214 | } 215 | }); 216 | 217 | const gotTheLock = app.requestSingleInstanceLock(); 218 | if (!gotTheLock) { 219 | destroyApp(); 220 | } else { 221 | app.on("second-instance", (e, args) => { 222 | const schemeUrl = args[args.length - 1]; 223 | if (schemeReg.test(schemeUrl)) { 224 | win.loadURL(schemeUrl.replace(schemeReg, config.url + "/")); 225 | } 226 | showWin(); 227 | }); 228 | } 229 | 230 | // 设置title 231 | ipcMain.on("set-title", (event, title) => { 232 | const webContents = event.sender; 233 | const win = BrowserWindow.fromWebContents(webContents); 234 | win?.setTitle(title); 235 | }); 236 | 237 | // 设置进度条 238 | ipcMain.on("set-progress-bar", (e, progress: number) => { 239 | win?.setProgressBar(progress); 240 | }); 241 | 242 | // 图片压缩 243 | ipcMain.handle("compress-image", async (e, filePath: string, targetPath?: string, width?: number) => { 244 | return compressImage(filePath, targetPath, width); 245 | }); 246 | 247 | // 选择保存位置弹框 248 | ipcMain.handle("save-dialog", (e, opts: SaveDialogOptions) => { 249 | return saveDialog(opts); 250 | }); 251 | 252 | // 保存base64文件 253 | ipcMain.handle("save-base64-file", (e, base64Str: string, fileName?: string) => { 254 | return saveBase64File(base64Str, fileName); 255 | }); 256 | 257 | // 选择文件夹 258 | ipcMain.handle("select-directory", (e, path: string, defaultPath?: string) => { 259 | return selectDirectory(path, defaultPath); 260 | }); 261 | 262 | // 打开文件夹 263 | ipcMain.handle("open-directory", (e, title: string) => { 264 | return openDirectory(title); 265 | }); 266 | 267 | // png转ico 268 | ipcMain.handle("png-to-ico", (e, filePath: string, size: number) => { 269 | return pngToIco(filePath, size); 270 | }); 271 | 272 | // 通知 273 | ipcMain.handle("notification", (e, title: string, content: string) => { 274 | return notification(title, content); 275 | }); 276 | 277 | // 保存文件 278 | ipcMain.handle("write-file", (e, filePath: string, buf: NodeJS.ArrayBufferView) => { 279 | return writeFile(filePath, buf); 280 | }); 281 | 282 | // 设置config 283 | ipcMain.handle("set-config", (e, data: unknown) => { 284 | Object.assign(userConfig, data); 285 | writeJSONSync(userConfigFile, userConfig); 286 | tray?.destroy(); 287 | app.relaunch({ args: process.argv.slice(1).concat(["--relaunch"]) }); 288 | app.exit(0); 289 | }); 290 | 291 | // 获取config 292 | ipcMain.handle("get-config", () => userConfig); 293 | 294 | // 打开链接 295 | ipcMain.handle("open-url", (e, url: string) => shell.openExternal(url)); 296 | 297 | // 翻译 298 | ipcMain.handle("translate", (e, words: string) => translate(words)); 299 | -------------------------------------------------------------------------------- /electron/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron"; 2 | 3 | contextBridge.exposeInMainWorld("electronAPI", { 4 | on: ipcRenderer.on, 5 | // 设置进度条 6 | setProgressBar: (...args: any[]) => ipcRenderer.send("set-progress-bar", ...args), 7 | // 设置title 8 | setTitle: (...args: any[]) => ipcRenderer.send("set-title", ...args), 9 | // 图片压缩 10 | compressImage: (...args: any[]) => ipcRenderer.invoke("compress-image", ...args), 11 | // 保存对话框 12 | saveDialog: (...args: any[]) => ipcRenderer.invoke("save-dialog", ...args), 13 | // 保存文件 14 | saveBase64File: (...args: any[]) => ipcRenderer.invoke("save-base64-file", ...args), 15 | // 选择文件夹 16 | selectDirectory: (...args: any[]) => ipcRenderer.invoke("select-directory", ...args), 17 | // 打开文件夹 18 | openDirectory: (...args: any[]) => ipcRenderer.invoke("open-directory", ...args), 19 | // ico 20 | pngToIco: (...args: any[]) => ipcRenderer.invoke("png-to-ico", ...args), 21 | // 保存文件 22 | writeFile: (...args: any[]) => ipcRenderer.invoke("write-file", ...args), 23 | // 设置config 24 | setConfig: (...args: any[]) => ipcRenderer.invoke("set-config", ...args), 25 | // 获取config 26 | getConfig: (...args: any[]) => ipcRenderer.invoke("get-config", ...args), 27 | // 通知 28 | notification: (...args: any[]) => ipcRenderer.invoke("notification", ...args), 29 | // 打开链接 30 | openUrl: (...args: any[]) => ipcRenderer.invoke("open-url", ...args), 31 | // 翻译 32 | translate: (...args: any[]) => ipcRenderer.invoke("translate", ...args), 33 | }); 34 | -------------------------------------------------------------------------------- /electron/resource/html/loading.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 37 | 38 | 39 | 40 |
41 | logo 42 |
沃德工具箱
43 |
44 | loading 45 |
Create by parker
46 | 47 | 48 | -------------------------------------------------------------------------------- /electron/resource/image/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /electron/resource/image/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hellowmonkey-site/tool-box/9451816a1ef23f3916eee67942f055b90f96afcc/electron/resource/image/logo.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 沃德工具箱 9 | 30 | 31 | 32 | 33 |
34 |
玩命加载中...
35 |
36 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tool-box", 3 | "private": true, 4 | "version": "1.0.6", 5 | "main": "dist/electron/main/index.js", 6 | "author": "parker", 7 | "description": "沃德工具箱", 8 | "scripts": { 9 | "dev": "vite", 10 | "build": "vue-tsc --noEmit && vite build", 11 | "preview": "vite preview", 12 | "lint": "npm run eslint && npm run stylelint", 13 | "eslint": "eslint --fix src/**/*.{js,ts,jsx,tsx,vue}", 14 | "stylelint": "stylelint --fix src/**/*.{scss,sass,css,less}", 15 | "electron-dev": "cross-env NODE_ENV=development MODE=electron vite", 16 | "electron-build": "cross-env NODE_ENV=production MODE=electron vite build && electron-builder", 17 | "electron-builder": "electron-builder" 18 | }, 19 | "dependencies": { 20 | "cropperjs": "^1.5.12", 21 | "dayjs": "^1.10.8", 22 | "mux.js": "^6.2.0", 23 | "naive-ui": "^2.33.2", 24 | "normalize.css": "^8.0.1", 25 | "nprogress": "^0.2.0", 26 | "qrcode": "^1.5.1", 27 | "qrcode-decoder": "^0.3.1", 28 | "reset-css": "^5.0.1", 29 | "vicons": "^0.0.1", 30 | "vue": "^3.2.25", 31 | "vue-router": "4" 32 | }, 33 | "devDependencies": { 34 | "@types/nprogress": "^0.2.0", 35 | "@types/qrcode": "^1.5.0", 36 | "@typescript-eslint/eslint-plugin": "^5.13.0", 37 | "@typescript-eslint/parser": "^5.13.0", 38 | "@vicons/material": "^0.12.0", 39 | "@vitejs/plugin-vue": "^2.2.0", 40 | "@vitejs/plugin-vue-jsx": "^1.3.8", 41 | "chokidar": "^3.5.3", 42 | "cross-env": "^7.0.3", 43 | "electron": "^18.2.0", 44 | "electron-builder": "^23.0.3", 45 | "eslint": "^8.10.0", 46 | "eslint-config-prettier": "^8.5.0", 47 | "eslint-plugin-prettier": "^4.0.0", 48 | "eslint-plugin-vue": "^8.5.0", 49 | "png-to-ico": "^2.1.7", 50 | "postcss-preset-env": "^7.4.2", 51 | "prettier": "^2.5.1", 52 | "sass": "^1.49.9", 53 | "stylelint": "^14.5.3", 54 | "stylelint-config-standard": "^25.0.0", 55 | "stylelint-config-standard-scss": "^3.0.0", 56 | "stylelint-declaration-block-no-ignored-properties": "^2.5.0", 57 | "stylelint-scss": "^4.1.0", 58 | "tinify": "^1.6.1", 59 | "typescript": "^4.5.4", 60 | "vite": "^2.8.0", 61 | "vite-plugin-electron": "^0.9.2", 62 | "vue-tsc": "^0.29.8" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-preset-env": { 4 | browsers: `last 2 versions`, 5 | stage: 0, 6 | }, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hellowmonkey-site/tool-box/9451816a1ef23f3916eee67942f055b90f96afcc/public/favicon.ico -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | const isDev = import.meta.env.DEV; 2 | const isElectron = typeof electronAPI !== "undefined"; 3 | import { version } from "../../package.json"; 4 | import { productName } from "../../electron-builder.json"; 5 | 6 | const releaseName = `${productName}-Windows-Setup-${version}.exe`; 7 | const releaseUrl = `/release/${releaseName}`; 8 | 9 | export default { 10 | isDev, 11 | isElectron, 12 | dbName: "tool", 13 | dbVersion: 1, 14 | version, 15 | productName, 16 | releaseName, 17 | releaseUrl, 18 | movieUrl: "https://movie.hellowmonkey.cc", 19 | }; 20 | -------------------------------------------------------------------------------- /src/config/type.ts: -------------------------------------------------------------------------------- 1 | export type KeyType = string | number; 2 | 3 | export type ObjType = { 4 | [prop: KeyType]: any; 5 | }; 6 | 7 | export type FunType = (...args: any[]) => any; 8 | 9 | export type ExcludeAny = T extends U ? any : T; 10 | // 去除interface中某些属性 11 | export type ExcludeInterface = { 12 | [P in ExcludeAny]: T[P]; 13 | }; 14 | 15 | export const enum NumberBoolean { 16 | FALSE, 17 | TRUE, 18 | } 19 | 20 | // 状态 21 | export const enum StatusType { 22 | OFFLINE, 23 | ONLINE, 24 | } 25 | 26 | // 类型列表 27 | export type ColorType = "default" | "success" | "warning" | "info" | "error" | "primary"; 28 | export type TypeItem = { 29 | text: string; 30 | value: number | undefined; 31 | color?: ColorType; 32 | }; 33 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "*.vue" { 4 | import type { DefineComponent } from "vue"; 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any>; 7 | export default component; 8 | } 9 | 10 | declare let electronAPI: { 11 | on(channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void): Electron.IpcRenderer; 12 | setProgressBar: (progress: number) => void; 13 | setTitle: (title: string) => void; 14 | compressImage: (filePath: string, targetPath?: string, width?: number) => Promise<{ fileSize: number; targetSize: number }>; 15 | saveDialog: (opts: any) => Promise; 16 | saveBase64File: ( 17 | base64Str: string, 18 | fileName?: string 19 | ) => Promise<{ fileSize: number; targetSize: number; fullPath: string; fileName: string; filePath: string }>; 20 | openDirectory: (path: string) => Promise; 21 | selectDirectory: (title?: string, defaultPath?: string) => Promise; 22 | pngToIco: (filePath: string, size?: number) => Promise; 23 | notification: (title: string, content: string) => Promise; 24 | writeFile: (filePath: string, buf: NodeJS.ArrayBufferView) => Promise; 25 | setConfig: (data: unknown) => Promise; 26 | getConfig: () => Promise<{ keyboard: string; openAtLogin: boolean; compressDirs: string[]; compressNotify: boolean }>; 27 | openUrl: (url: string) => Promise; 28 | translate: (words: string) => Promise; 29 | }; 30 | 31 | declare module "qrcode-decoder"; 32 | 33 | declare module "mux.js"; 34 | -------------------------------------------------------------------------------- /src/extend/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-extend-native */ 2 | if (!Array.prototype.flat) { 3 | Array.prototype.flat = function (count) { 4 | let c = count || 1; 5 | const len = this.length; 6 | let exe = []; 7 | if (this.length === 0) { 8 | return this; 9 | } 10 | while (c--) { 11 | const _arr = []; 12 | let flag = false; 13 | if (exe.length === 0) { 14 | flag = true; 15 | for (let i = 0; i < len; i++) { 16 | if (Array.isArray(this[i])) { 17 | exe.push(...this[i]); 18 | } else { 19 | exe.push(this[i]); 20 | } 21 | } 22 | } else { 23 | for (let i = 0; i < exe.length; i++) { 24 | if (Array.isArray(exe[i])) { 25 | flag = true; 26 | _arr.push(...exe[i]); 27 | } else { 28 | _arr.push(exe[i]); 29 | } 30 | } 31 | exe = _arr; 32 | } 33 | if (!flag && c === Infinity) { 34 | break; 35 | } 36 | } 37 | return exe; 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/helper/ajax.ts: -------------------------------------------------------------------------------- 1 | export default function ajax(url: string, type: XMLHttpRequestResponseType = "") { 2 | return new Promise((resolve, reject) => { 3 | const xhr = new XMLHttpRequest(); 4 | if (type) { 5 | xhr.responseType = type; 6 | } 7 | 8 | xhr.onreadystatechange = () => { 9 | if (xhr.readyState === 4) { 10 | const status = xhr.status; 11 | if (status >= 200 && status < 300) { 12 | resolve(xhr.response); 13 | } else { 14 | reject(new Error("请求失败")); 15 | } 16 | } 17 | }; 18 | 19 | xhr.open("GET", url, true); 20 | xhr.send(null); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/helper/crypto.ts: -------------------------------------------------------------------------------- 1 | const defaultXor = 125; 2 | const defaultHex = 25; 3 | export function encrypto(str: string, xor = defaultXor, hex = defaultHex): string { 4 | if (typeof str !== "string" || typeof xor !== "number" || typeof hex !== "number") { 5 | return ""; 6 | } 7 | 8 | const resultList = []; 9 | hex = hex <= 25 ? hex : hex % 25; 10 | 11 | for (let i = 0; i < str.length; i++) { 12 | // 提取字符串每个字符的ascll码 13 | let charCode = str.charCodeAt(i); 14 | // 进行异或加密 15 | charCode = (charCode * 1) ^ xor; 16 | // 异或加密后的字符转成 hex 位数的字符串 17 | const newCode = charCode.toString(hex); 18 | resultList.push(newCode); 19 | } 20 | 21 | const splitStr = String.fromCharCode(hex + 97); 22 | const resultStr = resultList.join(splitStr); 23 | return resultStr; 24 | } 25 | 26 | export function decrypto(str: string, xor = defaultXor, hex = defaultHex): string { 27 | if (typeof str !== "string" || typeof xor !== "number" || typeof hex !== "number") { 28 | return ""; 29 | } 30 | let strCharList = []; 31 | const resultList = []; 32 | hex = hex <= 25 ? hex : hex % 25; 33 | // 解析出分割字符 34 | const splitStr = String.fromCharCode(hex + 97); 35 | // 分割出加密字符串的加密后的每个字符 36 | strCharList = str.split(splitStr); 37 | 38 | for (let i = 0; i < strCharList.length; i++) { 39 | // 将加密后的每个字符转成加密后的ascll码 40 | let charCode = parseInt(strCharList[i], hex); 41 | // 异或解密出原字符的ascll码 42 | charCode = (charCode * 1) ^ xor; 43 | const strChar = String.fromCharCode(charCode); 44 | resultList.push(strChar); 45 | } 46 | const resultStr = resultList.join(""); 47 | return resultStr; 48 | } 49 | -------------------------------------------------------------------------------- /src/helper/index.ts: -------------------------------------------------------------------------------- 1 | import config from "@/config"; 2 | import { ObjType } from "@/config/type"; 3 | import { isNumberLike, isEmpty, isUrl } from "@/helper/validate"; 4 | import { message } from "@/service/common"; 5 | import { nextTick } from "vue"; 6 | 7 | // 加零 8 | export const addZero = (num: number | string): string => { 9 | num = Number(Number(num)); 10 | if (num < 10) { 11 | num = "0" + num; 12 | } 13 | return String(num); 14 | }; 15 | 16 | // 获取数据类型 17 | export const getType = (item: any): string => { 18 | const str = Object.prototype.toString.call(item); 19 | return str.substring(8, str.length - 1).toLocaleLowerCase(); 20 | }; 21 | 22 | // 对象值处理 23 | export const filterObject = (obj: ObjType = {}, transformNum = false) => { 24 | const params: ObjType = {}; 25 | for (const key in obj) { 26 | let data = obj[key]; 27 | if (!isEmpty(data)) { 28 | if (typeof data === "string") { 29 | data = data.trim(); 30 | } 31 | if (transformNum && isNumberLike(data)) { 32 | data = Number(data); 33 | } 34 | params[key] = data; 35 | } 36 | } 37 | return params; 38 | }; 39 | 40 | // 路由参数过滤 41 | export const filterParams = (query: ObjType = {}) => { 42 | const data = filterObject(query, true); 43 | for (const key in data) { 44 | query[key] = data[key]; 45 | } 46 | return query; 47 | }; 48 | 49 | // 参数对象转参数字符串 50 | export const stringifyParams = (obj: ObjType = {}, strict = true): string => { 51 | const arr = []; 52 | let str = ""; 53 | if (strict) { 54 | obj = filterObject(obj); 55 | } else { 56 | obj = filterParams(obj); 57 | } 58 | for (const key in obj) { 59 | let data = obj[key]; 60 | if (getType(data) === "array") { 61 | data = data.join(","); 62 | } 63 | if (!strict || !isEmpty(data)) { 64 | arr.push(`${key}=${data}`); 65 | } 66 | } 67 | str = arr.join("&"); 68 | if (str.length > 0) { 69 | str = "?" + str; 70 | } 71 | return str; 72 | }; 73 | 74 | // 链接转参数对象 75 | export const parseParams = (str = window.location.href) => { 76 | const params: ObjType = {}; 77 | const arr = str.split("?"); 78 | const query = arr[1]; 79 | if (!query) { 80 | return params; 81 | } 82 | const paramsArr = query.split("&"); 83 | paramsArr.forEach(item => { 84 | const arrs = item.split("="); 85 | params[arrs[0]] = arrs[1]; 86 | }); 87 | return params; 88 | }; 89 | 90 | // 追加链接参数 91 | export const putParams = (url = "", putParams = {}): string => { 92 | const oldParams = parseParams(url); 93 | const path = url.split("?")[0]; 94 | // 去除原有参数 95 | for (const key in putParams) { 96 | delete oldParams[key]; 97 | } 98 | const oldParamsStr = stringifyParams(oldParams); 99 | let putParamsStr = stringifyParams(putParams, false); 100 | if (oldParamsStr.includes("?")) { 101 | putParamsStr = putParamsStr.replace("?", "&"); 102 | } 103 | return path + oldParamsStr + putParamsStr; 104 | }; 105 | 106 | // 合并链接 107 | export const getFullUrl = (...urls: string[]): string => { 108 | urls.slice(1).forEach((value, index) => { 109 | if (isUrl(value)) { 110 | urls.slice(0, index + 1).forEach((v, k) => { 111 | urls[k] = ""; 112 | }); 113 | } 114 | }); 115 | const arr = urls.filter(v => !!v).map(v => v.replace(/\/$/, "").replace(/^\//, "")); 116 | return arr.join("/"); 117 | }; 118 | 119 | // 随机数 120 | export const random = (n: number, m: number) => { 121 | return Math.floor(Math.random() * (m - n + 1) + n); 122 | }; 123 | 124 | // 随机字符串 125 | const stringTemplate = "1234567890qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM"; 126 | export const randomString = (length = 6) => { 127 | return new Array(length) 128 | .fill(1) 129 | .map(() => stringTemplate[random(0, stringTemplate.length - 1)]) 130 | .join(""); 131 | }; 132 | 133 | // 深度拷贝 134 | export const deepEachObjClone = (obj: any, cache = new WeakMap()) => { 135 | if (typeof obj !== "object") return obj; // 普通类型,直接返回 136 | if (obj === null) return obj; 137 | if (cache.get(obj)) return cache.get(obj); // 防止循环引用,程序进入死循环 138 | if (obj instanceof Date) return new Date(obj); 139 | if (obj instanceof RegExp) return new RegExp(obj); 140 | 141 | // 找到所属原型上的constructor,所属原型上的constructor指向当前对象的构造函数 142 | const cloneObj = new obj.constructor(); 143 | cache.set(obj, cloneObj); // 缓存拷贝的对象,用于处理循环引用的情况 144 | for (const key in obj) { 145 | if (obj.hasOwnProperty(key)) { 146 | cloneObj[key] = deepEachObjClone(obj[key], cache); // 递归拷贝 147 | } 148 | } 149 | return cloneObj; 150 | }; 151 | 152 | // 将base64转换为file 153 | export const base64ToFile = (dataurl: string): File => { 154 | const base64Prefix = "data:image/png;base64,"; 155 | if (!/^data:image\/.+;base64,/.test(dataurl)) { 156 | dataurl = base64Prefix + dataurl; 157 | } 158 | let mime = "png"; 159 | const arr = dataurl.split(","); 160 | const match = arr[0].match(/:(.*?);/); 161 | if (match) { 162 | mime = match[1]; 163 | } 164 | const suffix = String(mime).replace("image/", ""); 165 | const filename = randomString(16) + "." + suffix; 166 | const bstr = atob(arr[1]); 167 | let n = bstr.length; 168 | const u8arr = new Uint8Array(n); 169 | while (n--) { 170 | u8arr[n] = bstr.charCodeAt(n); 171 | } 172 | const file = new File([u8arr], filename, { 173 | type: mime, 174 | }); 175 | return file; 176 | }; 177 | 178 | // file转base64 179 | export const fileToBase64 = (file: File): Promise => { 180 | return new Promise((resolve, reject) => { 181 | const reader = new FileReader(); 182 | reader.readAsDataURL(file); 183 | reader.onload = () => { 184 | resolve(reader.result); 185 | }; 186 | reader.onerror = error => reject(error); 187 | }); 188 | }; 189 | 190 | // style拼接 191 | export function putStyle(params: ObjType) { 192 | const style = document.body.getAttribute("style") || ""; 193 | const obj: ObjType = {}; 194 | const arr = String(style) 195 | .split(";") 196 | .filter(v => !!v); 197 | arr.forEach(item => { 198 | const [k, v] = String(item).split(":"); 199 | obj[k] = v; 200 | }); 201 | let str = ""; 202 | Object.assign(obj, params); 203 | Object.keys(obj).forEach(k => { 204 | str += `${k}: ${obj[k]};`; 205 | }); 206 | return str; 207 | } 208 | 209 | // 复制文本到剪贴板 210 | export async function copyText(text: string): Promise { 211 | if (navigator.clipboard) { 212 | await navigator.clipboard.writeText(text); 213 | } else { 214 | const textArea = document.createElement("textarea"); 215 | textArea.value = text; 216 | textArea.style.top = "0"; 217 | textArea.style.left = "0"; 218 | textArea.style.position = "fixed"; 219 | textArea.style.opacity = "0"; 220 | 221 | document.body.appendChild(textArea); 222 | textArea.focus(); 223 | textArea.select(); 224 | const successful = document.execCommand("copy"); 225 | document.body.removeChild(textArea); 226 | if (!successful) { 227 | throw new Error("复制失败"); 228 | } 229 | } 230 | } 231 | 232 | // 复制图片到剪贴板 233 | export async function copyImg(src: string) { 234 | const canvas = document.createElement("canvas"); 235 | const ctx = canvas.getContext("2d"); 236 | if (!ctx) { 237 | return; 238 | } 239 | 240 | const img = await awaitLoadImg(src); 241 | const { width, height } = img; 242 | canvas.width = width; 243 | canvas.height = height; 244 | ctx.clearRect(0, 0, width, height); 245 | ctx.drawImage(img, 0, 0); 246 | const blob = await new Promise(resolve => { 247 | canvas.toBlob(blob => { 248 | resolve(blob); 249 | }); 250 | }); 251 | if (!blob) { 252 | return; 253 | } 254 | const data = [ 255 | new ClipboardItem({ 256 | [blob.type]: blob, 257 | }), 258 | ]; 259 | await navigator.clipboard.write(data); 260 | } 261 | 262 | // 横线转驼峰 263 | export function lineToHump(str: string, lineType = "-") { 264 | const reg = new RegExp(`${lineType}(\\w)`, "g"); 265 | return str.replace(reg, (all, letter) => { 266 | return letter.toUpperCase(); 267 | }); 268 | } 269 | 270 | // 横线转大驼峰 271 | export function lineToBigHump(str: string, lineType = "-") { 272 | const [first, ...data] = lineToHump(str, lineType); 273 | return first.toLocaleUpperCase() + data.join(""); 274 | } 275 | 276 | // 驼峰转横线 277 | export function humpToLine(str: string, lineType = "-") { 278 | let temp = str.replace(/[A-Z]/g, i => { 279 | return lineType + i.toLowerCase(); 280 | }); 281 | if (temp.slice(0, 1) === lineType) { 282 | temp = temp.slice(1); 283 | } 284 | return temp; 285 | } 286 | 287 | // 下载 288 | let a: HTMLAnchorElement; 289 | export function downLoad(url: string, fileName = "") { 290 | if (!a) { 291 | a = document.createElement("a"); 292 | } 293 | a.href = url; 294 | a.download = fileName; 295 | a.click(); 296 | } 297 | 298 | // 下载图片 299 | export async function downLoadBase64File(base64Str: string, fileName?: string) { 300 | if (config.isElectron) { 301 | const { filePath } = await electronAPI.saveBase64File(base64Str, fileName); 302 | await electronAPI.openDirectory(filePath); 303 | message.success(`下载成功:【${filePath}/${fileName!}】`); 304 | } else { 305 | downLoad(base64Str, fileName); 306 | } 307 | } 308 | 309 | // 等待一段时间 310 | export function sleep(s: number) { 311 | return new Promise(resolve => { 312 | const timer = setTimeout(() => { 313 | resolve(timer); 314 | }, s); 315 | }); 316 | } 317 | 318 | // nextTick的Promise 319 | export function awaitNextTick(data?: any) { 320 | return new Promise(resolve => { 321 | nextTick(() => { 322 | resolve(data); 323 | }); 324 | }); 325 | } 326 | 327 | // 图片加载 328 | export function awaitLoadImg(src: string) { 329 | const img = new Image(); 330 | img.crossOrigin = "Anonymous"; 331 | img.src = src; 332 | return new Promise((resolve, reject) => { 333 | img.onload = () => { 334 | resolve(img); 335 | }; 336 | img.onerror = e => { 337 | reject(e); 338 | }; 339 | }); 340 | } 341 | 342 | // 获取文件名和扩展名 343 | export function getFilePathInfo(fileName: string): [string, string] { 344 | const arr = fileName.split("."); 345 | const str = arr.slice(0, arr.length - 1).join(""); 346 | const ext = arr[arr.length - 1]; 347 | return [str, ext]; 348 | } 349 | 350 | // 打开链接 351 | export function openUrl(url: string) { 352 | if (config.isElectron) { 353 | electronAPI.openUrl(url); 354 | } else { 355 | window.open(url); 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /src/helper/storage.ts: -------------------------------------------------------------------------------- 1 | import { KeyType, ObjType } from "@/config/type"; 2 | import { isJsonString } from "@/helper/validate"; 3 | 4 | const s = window.sessionStorage; 5 | const l = window.localStorage; 6 | 7 | const fn = (storage = s) => { 8 | return { 9 | get(key: string): string | T | any[] { 10 | const data = storage.getItem(key); 11 | if (data === null) { 12 | return ""; 13 | } 14 | if (isJsonString(data)) { 15 | return JSON.parse(data); 16 | } 17 | return data; 18 | }, 19 | set(key: string, value: any) { 20 | if (typeof value === "object") { 21 | value = JSON.stringify(value); 22 | } 23 | storage.setItem(key, value); 24 | }, 25 | append(key: string, value: any, mergeKey: any = null) { 26 | let data = this.get(key); 27 | if (Array.isArray(data)) { 28 | if (mergeKey) { 29 | data.forEach((item, k) => { 30 | if (item[mergeKey] === value[mergeKey]) { 31 | (data as any[]).splice(k, 1); 32 | } 33 | }); 34 | } 35 | data.unshift(value); 36 | } else { 37 | data = [value]; 38 | } 39 | this.set(key, data); 40 | }, 41 | has(key: string, id: KeyType, val: any) { 42 | const data = this.get(key); 43 | if (Array.isArray(data)) { 44 | return data.filter(item => item[id] === val).length > 0; 45 | } 46 | return false; 47 | }, 48 | remove(key: string) { 49 | storage.removeItem(key); 50 | }, 51 | clear() { 52 | storage.clear(); 53 | }, 54 | }; 55 | }; 56 | 57 | export default fn(); 58 | 59 | export const sessionStorage = fn(s); 60 | export const localStorage = fn(l); 61 | -------------------------------------------------------------------------------- /src/helper/validate.ts: -------------------------------------------------------------------------------- 1 | export const isEmpty = (data: any) => { 2 | return data === "" || data === null || data === undefined || (typeof data === "number" && isNaN(data)); 3 | }; 4 | 5 | export const isRealEmpty = (data: any) => { 6 | return isEmpty(data) || (Array.isArray(data) && !data.length) || (typeof data === "object" && !Object.keys(data).length); 7 | }; 8 | 9 | // 是不是链接 10 | export const isUrl = (url: string) => { 11 | return /^https?:\/\/.+/.test(url); 12 | }; 13 | 14 | // 是不是email 15 | export const isEmail = (data: string) => { 16 | return /^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(data); 17 | }; 18 | 19 | // 是不是身份证号 20 | export const isIdcard = (data: string) => { 21 | return /^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/.test(data); 22 | }; 23 | 24 | // 车牌号的正则 25 | export const isCarNumber = (data: string) => { 26 | if (typeof data !== "string") { 27 | return false; 28 | } 29 | const regExp = 30 | /^([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Za-z]{1}[A-Za-z]{1}(([0-9]{5}[DFdf])|([DFdf]([A-HJ-NP-Za-hj-np-z0-9])[0-9]{4})))|([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Za-z]{1}[A-Za-z]{1}[A-HJ-NP-Za-hj-np-z0-9]{4}[A-HJ-NP-Za-hj-np-z0-9挂学警港澳]{1})$/; 31 | return regExp.test(data); 32 | }; 33 | 34 | // 手机号码的正则 35 | export const isPhoneNumber = (data: string) => { 36 | const regExp = /^[1]([3-9])[0-9]{9}$/; 37 | return regExp.test(data); 38 | }; 39 | 40 | // 是不是数字 41 | export const isNumberLike = (data: string) => { 42 | return /^[\d]+$/g.test(data) && Number(data) < Number.MAX_SAFE_INTEGER; 43 | }; 44 | 45 | // json字符串 46 | export const isJsonString = (data: string | any) => { 47 | if (typeof data !== "string") { 48 | return false; 49 | } 50 | try { 51 | if (typeof JSON.parse(data) === "object") { 52 | return true; 53 | } 54 | } catch (e) {} 55 | return false; 56 | }; 57 | 58 | // 判断是不是IE浏览器 59 | export const isIE = (() => "ActiveXObject" in window)(); 60 | // 判断是不是IOS 61 | export const isIos = (() => /(iPhone|iPad|iPod|iOS|Safari)/i.test(navigator.userAgent))(); 62 | // 判断是不是Android 63 | export const isAndroid = (() => /(Android)/i.test(navigator.userAgent))(); 64 | -------------------------------------------------------------------------------- /src/layout/App.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue"; 2 | import { RouterView } from "vue-router"; 3 | import { NConfigProvider, NDialogProvider, NGlobalStyle, NMessageProvider, NNotificationProvider } from "naive-ui"; 4 | import { globalTheme, themeOverrides } from "@/service/common"; 5 | 6 | export default defineComponent({ 7 | setup() { 8 | return () => ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/layout/Index.tsx: -------------------------------------------------------------------------------- 1 | import { isIE } from "@/helper/validate"; 2 | import { 3 | appConfig, 4 | dialog, 5 | isShowBackTop, 6 | menuCollapsed, 7 | setAppConfig, 8 | settingOpen, 9 | themeColors, 10 | ThemeTypes, 11 | visitedPageNum, 12 | } from "@/service/common"; 13 | import { globalTheme, themeTypes } from "@/service/common"; 14 | import { 15 | DialogOptions, 16 | MenuOption, 17 | NBackTop, 18 | NButton, 19 | NDivider, 20 | NDrawer, 21 | NDrawerContent, 22 | NDropdown, 23 | NDynamicInput, 24 | NH2, 25 | NIcon, 26 | NInput, 27 | NLayout, 28 | NLayoutHeader, 29 | NLayoutSider, 30 | NMenu, 31 | NResult, 32 | NSelect, 33 | NSwitch, 34 | NText, 35 | NTooltip, 36 | useOsTheme, 37 | } from "naive-ui"; 38 | import { computed, defineComponent, KeepAlive, onMounted, reactive, ref, Transition } from "vue"; 39 | import { RouteLocationNormalizedLoaded, RouterLink, RouterView, useRoute, useRouter } from "vue-router"; 40 | import { 41 | ChevronLeftRound, 42 | ChevronRightRound, 43 | DeveloperBoardOutlined, 44 | DownloadFilled, 45 | DownloadOutlined, 46 | HomeOutlined, 47 | ImageOutlined, 48 | PanToolAltOutlined, 49 | QrCodeOutlined, 50 | ReplayOutlined, 51 | SettingsFilled, 52 | SettingsOutlined, 53 | VideocamOutlined, 54 | WbSunnyFilled, 55 | WbSunnyOutlined, 56 | } from "@vicons/material"; 57 | import config from "@/config"; 58 | import { menuRoutes } from "@/router"; 59 | import Logo from "@/static/image/logo.png"; 60 | import { downLoad } from "@/helper"; 61 | 62 | export const firstMenus: MenuOption[] = [ 63 | { 64 | label: "图片", 65 | key: "image", 66 | icon() { 67 | return ( 68 | 69 | 70 | 71 | ); 72 | }, 73 | }, 74 | { 75 | label: "二维码", 76 | key: "qrcode", 77 | icon() { 78 | return ( 79 | 80 | 81 | 82 | ); 83 | }, 84 | }, 85 | { 86 | label: "视频", 87 | key: "video", 88 | icon() { 89 | return ( 90 | 91 | 92 | 93 | ); 94 | }, 95 | }, 96 | { 97 | label: "开发", 98 | key: "developer", 99 | icon() { 100 | return ( 101 | 102 | 103 | 104 | ); 105 | }, 106 | }, 107 | { 108 | label: "日常", 109 | key: "util", 110 | icon() { 111 | return ( 112 | 113 | 114 | 115 | ); 116 | }, 117 | }, 118 | ]; 119 | 120 | export default defineComponent({ 121 | props: {}, 122 | emits: [], 123 | setup: (props, ctx) => { 124 | const route = useRoute(); 125 | const router = useRouter(); 126 | const os = useOsTheme(); 127 | const electronConfig = reactive<{ keyboard: string; openAtLogin: boolean; compressDirs: string[]; compressNotify: boolean }>({ 128 | keyboard: "", 129 | openAtLogin: true, 130 | compressDirs: [], 131 | compressNotify: true, 132 | }); 133 | const compressDirs = ref([]); 134 | const showCompressDirsBtn = computed(() => { 135 | return electronConfig.compressDirs.join("") !== [...new Set(compressDirs.value.filter(v => !!v))].join(""); 136 | }); 137 | 138 | function renderMenu(item: MenuOption): MenuOption { 139 | return { 140 | ...item, 141 | children: menuRoutes 142 | .filter(v => new RegExp(`^${item.key}-`).test(v.name as string)) 143 | .map(v => { 144 | return { 145 | label() { 146 | return {v.meta!.title}; 147 | }, 148 | key: v.name as string, 149 | }; 150 | }), 151 | }; 152 | } 153 | 154 | const menus: MenuOption[] = [ 155 | { 156 | label() { 157 | return 首页; 158 | }, 159 | key: "index", 160 | icon() { 161 | return ( 162 | 163 | 164 | 165 | ); 166 | }, 167 | }, 168 | ...firstMenus.map(v => renderMenu(v)), 169 | ]; 170 | 171 | // 设置系统快捷键 172 | function setAppKeyboard(e: KeyboardEvent) { 173 | e.stopPropagation(); 174 | e.preventDefault(); 175 | // 清空 176 | if (e.key === "Backspace") { 177 | showConfigDialog( 178 | { 179 | title: "取消快捷键", 180 | content: "确认取消打开工具的快捷键吗?", 181 | }, 182 | { keyboard: "" } 183 | ); 184 | return; 185 | } 186 | const arr: string[] = []; 187 | if (e.altKey) { 188 | arr.push("Alt"); 189 | } 190 | if (e.shiftKey) { 191 | arr.push("Shift"); 192 | } 193 | if (e.ctrlKey) { 194 | arr.push("CommandOrControl"); 195 | } 196 | if (arr.length && e.key.length === 1 && /[a-z]/i.test(e.key)) { 197 | arr.push(e.key.toLocaleUpperCase()); 198 | const val = [...new Set(arr)].join("+"); 199 | if (electronConfig.keyboard !== val) { 200 | showConfigDialog( 201 | { 202 | title: "设置快捷键", 203 | content: `确认修改打开工具的快捷键为【${val}】吗?`, 204 | }, 205 | { keyboard: val } 206 | ); 207 | } 208 | } 209 | } 210 | 211 | function showConfigDialog(opts: DialogOptions, data: unknown) { 212 | dialog.warning({ 213 | positiveText: "确认", 214 | negativeText: "取消", 215 | onPositiveClick() { 216 | electronAPI.setConfig(data); 217 | }, 218 | ...opts, 219 | content() { 220 | return ( 221 | <> 222 |
{opts.content}
223 | 224 | 设置后将自动重启软件! 225 | 226 | 227 | ); 228 | }, 229 | }); 230 | } 231 | 232 | // 图片自动压缩位置保存 233 | function saveCompressDirs() { 234 | compressDirs.value = compressDirs.value.filter(v => !!v); 235 | showConfigDialog( 236 | { 237 | title: "修改需要自动压缩的图片目录", 238 | content: `${compressDirs.value.length ? "确认修改需要自动压缩的图片目录?" : "确认关闭图片自动压缩监听?"}`, 239 | }, 240 | { compressDirs: Array.from(compressDirs.value) } 241 | ); 242 | } 243 | 244 | onMounted(() => { 245 | if (config.isElectron) { 246 | electronAPI.getConfig().then(data => { 247 | electronConfig.keyboard = data.keyboard; 248 | electronConfig.openAtLogin = data.openAtLogin; 249 | electronConfig.compressDirs = data.compressDirs; 250 | compressDirs.value = Array.from(electronConfig.compressDirs); 251 | }); 252 | } 253 | 254 | // 判断是不是IE浏览器 255 | if (isIE) { 256 | dialog.warning({ 257 | title: "重要提示", 258 | content: 259 | "监测到您当前浏览器版本过低,会影响部分功能的使用,建议使用谷歌、火狐等高级浏览器,或将360等双核心的浏览器切换至极速模式", 260 | positiveText: "立即升级", 261 | maskClosable: false, 262 | onPositiveClick() { 263 | window.open("https://www.microsoft.com/zh-cn/edge"); 264 | return Promise.reject({ message: "" }); 265 | }, 266 | }); 267 | } 268 | }); 269 | return () => ( 270 | <> 271 | 272 | 273 | {config.isElectron ? ( 274 |
275 | 276 | {{ 277 | default: () => 后退, 278 | trigger: () => ( 279 | { 286 | router.back(); 287 | }} 288 | > 289 | {{ 290 | icon: () => ( 291 | 292 | 293 | 294 | ), 295 | }} 296 | 297 | ), 298 | }} 299 | 300 | 301 | {{ 302 | default: () => 前进, 303 | trigger: () => ( 304 | { 310 | router.forward(); 311 | }} 312 | > 313 | {{ 314 | icon: () => ( 315 | 316 | 317 | 318 | ), 319 | }} 320 | 321 | ), 322 | }} 323 | 324 | 325 | {{ 326 | default: () => 刷新, 327 | trigger: () => ( 328 | { 334 | location.reload(); 335 | }} 336 | > 337 | {{ 338 | icon: () => ( 339 | 340 | 341 | 342 | ), 343 | }} 344 | 345 | ), 346 | }} 347 | 348 |
349 | ) : null} 350 |
351 | 352 | 353 | {config.productName} 354 | 355 |
356 |
357 | {!config.isElectron ? ( 358 | 359 | {{ 360 | default: () => 下载客户端, 361 | trigger: () => ( 362 | { 369 | downLoad(config.releaseUrl, config.releaseName); 370 | }} 371 | > 372 | {{ 373 | icon: () => {globalTheme.value === null ? : }, 374 | }} 375 | 376 | ), 377 | }} 378 | 379 | ) : null} 380 | { 382 | return { 383 | ...v, 384 | icon() { 385 | switch (v.key) { 386 | case ThemeTypes.OS: 387 | return {os.value === "dark" ? : }; 388 | case ThemeTypes.LIGHT: 389 | return ( 390 | 391 | 392 | 393 | ); 394 | case ThemeTypes.DARK: 395 | return ( 396 | 397 | 398 | 399 | ); 400 | } 401 | }, 402 | }; 403 | })} 404 | trigger="click" 405 | onSelect={themeType => { 406 | setAppConfig({ themeType }); 407 | }} 408 | > 409 | 410 | {{ 411 | default: () => 选择主题, 412 | trigger: () => ( 413 | 414 | {{ 415 | icon: () => {globalTheme.value === null ? : }, 416 | }} 417 | 418 | ), 419 | }} 420 | 421 | 422 | 423 | {{ 424 | default: () => 系统设置, 425 | trigger: () => ( 426 | { 431 | settingOpen.value = true; 432 | }} 433 | > 434 | {{ 435 | icon: () => {globalTheme.value === null ? : }, 436 | }} 437 | 438 | ), 439 | }} 440 | 441 |
442 |
443 | 444 | 445 | v.children?.some(item => item.key === route.name)).map(v => v.key!)} 449 | > 450 | 451 | 452 |
453 | {route.meta.title ? ( 454 | 455 | {route.meta.title} 456 | 457 | ) : null} 458 | 459 | {{ 460 | default: ({ Component, route }: { Component: () => JSX.Element; route: RouteLocationNormalizedLoaded }) => { 461 | if (route.meta.electron && !config.isElectron) { 462 | return ( 463 |
464 | 465 | {{ 466 | footer() { 467 | return ( 468 | { 470 | downLoad(config.releaseUrl, config.releaseName); 471 | }} 472 | > 473 | 下载客户端 474 | 475 | ); 476 | }, 477 | }} 478 | 479 |
480 | ); 481 | } 482 | return ( 483 | 484 | 485 | 486 | 487 | 488 | ); 489 | }, 490 | }} 491 |
492 |
493 | 494 | {/* 返回顶部 */} 495 | { 498 | isShowBackTop.value = show; 499 | }} 500 | /> 501 |
502 |
503 |
504 | 505 | 506 |
507 | 主题设置 508 |
509 | 选择主题 510 | setAppConfig({ themeType })} 514 | options={themeTypes.map(v => { 515 | return { 516 | label: v.label, 517 | value: v.key, 518 | }; 519 | })} 520 | > 521 |
522 |
523 | 主题颜色 524 | setAppConfig({ themeColor })} 528 | options={themeColors.map(v => { 529 | return { 530 | label() { 531 | return ( 532 |
533 | 534 | {v.label} 535 |
536 | ); 537 | }, 538 | value: v.key, 539 | }; 540 | })} 541 | >
542 |
543 | {config.isElectron ? ( 544 | <> 545 | 通用设置 546 |
547 | 开机自启 548 | { 551 | showConfigDialog( 552 | { 553 | title: "开机自启", 554 | content: `确认要${val ? "启动" : "取消"}开机自启吗?`, 555 | }, 556 | { openAtLogin: val } 557 | ); 558 | }} 559 | /> 560 |
561 | 快捷键 562 |
563 | 打开工具 564 |
565 | { 570 | setAppKeyboard(e); 571 | }} 572 | /> 573 | 574 | Ctrl或Alt或Shift的任意组合+字母 575 | 576 |
577 |
578 | 自动化 579 |
580 |
581 | 图片自动压缩目录 582 | {showCompressDirsBtn.value ? ( 583 | { 585 | saveCompressDirs(); 586 | }} 587 | > 588 | 保存 589 | 590 | ) : null} 591 |
592 | { 596 | compressDirs.value.splice(index, 0, ""); 597 | }} 598 | onRemove={index => { 599 | compressDirs.value.splice(index, 1); 600 | }} 601 | > 602 | {{ 603 | "create-button-default"() { 604 | return "添加目录"; 605 | }, 606 | default({ value, index }: { value: string; index: number }) { 607 | return ( 608 | { 612 | electronAPI.selectDirectory("选择需要自动压缩的图片目录", value).then(data => { 613 | if (data) { 614 | compressDirs.value.splice(index, 1, data); 615 | } 616 | }); 617 | }} 618 | value={value} 619 | /> 620 | ); 621 | }, 622 | }} 623 | 624 | {compressDirs.value.length ? ( 625 |
626 | 图片压缩成功后通知 627 | { 630 | showConfigDialog( 631 | { 632 | title: "图片压缩成功后通知", 633 | content: `确认要${val ? "开启" : "取消"}图片压缩成功后通知吗?`, 634 | }, 635 | { compressNotify: val } 636 | ); 637 | }} 638 | /> 639 |
640 | ) : null} 641 |
642 | 643 | ) : null} 644 | 关于 645 |
646 | 版本 647 | 648 | v {config.version} 649 | 650 |
651 |
652 |
653 |
654 | 655 | ); 656 | }, 657 | }); 658 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import config from "@/config"; 2 | 3 | export default class Db { 4 | db?: IDBDatabase; 5 | readonly idKey = { keyPath: "id", autoIncrement: true }; 6 | 7 | open(createTableName?: string) { 8 | return new Promise(resolve => { 9 | const request = indexedDB.open(config.dbName, config.dbVersion); 10 | request.onupgradeneeded = () => { 11 | this.db = request.result; 12 | if (createTableName && !this.db.objectStoreNames.contains(createTableName)) { 13 | this.db.createObjectStore(createTableName, this.idKey); 14 | } 15 | }; 16 | request.onsuccess = () => { 17 | this.db = request.result; 18 | resolve(this); 19 | }; 20 | }); 21 | } 22 | 23 | // 获取单条记录 24 | findOne(tableName: string, id: number) { 25 | if (!this.db) { 26 | throw new Error("错误的db"); 27 | } 28 | 29 | return new Promise((resolve, reject) => { 30 | const request = this.db!.transaction(tableName).objectStore(tableName).get(id); 31 | request.onerror = event => { 32 | reject(event); 33 | }; 34 | 35 | request.onsuccess = () => { 36 | resolve(request.result); 37 | }; 38 | }); 39 | } 40 | 41 | // 获取多条记录 42 | findAll(tableName: string, query?: any) { 43 | if (!this.db) { 44 | throw new Error("错误的db"); 45 | } 46 | 47 | return new Promise((resolve, reject) => { 48 | const request = this.db!.transaction(tableName).objectStore(tableName).getAll(query); 49 | request.onerror = event => { 50 | reject(event); 51 | }; 52 | 53 | request.onsuccess = () => { 54 | resolve(request.result); 55 | }; 56 | }); 57 | } 58 | 59 | // 添加 60 | insert(tableName: string, data: any) { 61 | if (!this.db) { 62 | throw new Error("错误的db"); 63 | } 64 | 65 | return new Promise((resolve, reject) => { 66 | const request = this.db!.transaction(tableName, "readwrite") 67 | .objectStore(tableName) 68 | .add({ ...data, createdAt: Date.now() }); 69 | request.onerror = event => { 70 | reject(event); 71 | }; 72 | 73 | request.onsuccess = () => { 74 | resolve(data.id); 75 | }; 76 | }); 77 | } 78 | 79 | // 更新 80 | update(tableName: string, data: any) { 81 | if (!this.db) { 82 | throw new Error("错误的db"); 83 | } 84 | 85 | return new Promise((resolve, reject) => { 86 | const request = this.db!.transaction(tableName, "readwrite").objectStore(tableName).put(data); 87 | request.onerror = event => { 88 | reject(event); 89 | }; 90 | 91 | request.onsuccess = () => { 92 | resolve(request.result); 93 | }; 94 | }); 95 | } 96 | 97 | // 删除 98 | delete(tableName: string, id: number) { 99 | if (!this.db) { 100 | throw new Error("错误的db"); 101 | } 102 | 103 | return new Promise((resolve, reject) => { 104 | const request = this.db!.transaction(tableName, "readwrite").objectStore(tableName).delete(id); 105 | request.onerror = event => { 106 | reject(event); 107 | }; 108 | 109 | request.onsuccess = () => { 110 | resolve(request.result); 111 | }; 112 | }); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./layout/App"; 3 | import router from "@/router"; 4 | import "@/static/style/app.scss"; 5 | 6 | import "@/extend"; 7 | 8 | const app = createApp(App); 9 | 10 | app.use(router); 11 | 12 | router.isReady().then(async () => { 13 | app.mount("#app"); 14 | }); 15 | -------------------------------------------------------------------------------- /src/page/developer/babel.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue"; 2 | 3 | export default defineComponent({ 4 | props: {}, 5 | emits: [], 6 | setup: (props, ctx) => { 7 | return () =>
babel
; 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/page/developer/css.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue"; 2 | 3 | export default defineComponent({ 4 | props: {}, 5 | emits: [], 6 | setup: (props, ctx) => { 7 | return () =>
css
; 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/page/developer/javascript.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue"; 2 | 3 | export default defineComponent({ 4 | props: {}, 5 | emits: [], 6 | setup: (props, ctx) => { 7 | return () =>
javascript
; 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/page/developer/regex.tsx: -------------------------------------------------------------------------------- 1 | import { copyText } from "@/helper"; 2 | import { message } from "@/service/common"; 3 | import { regList } from "@/service/devoloper"; 4 | import { NButton, NCard, NH4 } from "naive-ui"; 5 | import { defineComponent } from "vue"; 6 | 7 | export default defineComponent({ 8 | props: {}, 9 | emits: [], 10 | setup: (props, ctx) => { 11 | return () => ( 12 |
13 | {regList.map(item => ( 14 | <> 15 | {item.title} 16 | 17 |
18 |
{item.code}
19 | { 22 | copyText(item.code).then(() => { 23 | message.success("复制成功"); 24 | }); 25 | }} 26 | > 27 | 复制 28 | 29 |
30 |
31 | 32 | ))} 33 |
34 | ); 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /src/page/developer/translate.tsx: -------------------------------------------------------------------------------- 1 | import { copyText, lineToHump, random } from "@/helper"; 2 | import { isEmpty } from "@/helper/validate"; 3 | import { globalTheme, message } from "@/service/common"; 4 | import { colors, VariableType, variableTypeList } from "@/service/devoloper"; 5 | import { NButton, NInput, NInputGroup, NInputGroupLabel, NSelect } from "naive-ui"; 6 | import { computed, defineComponent, onActivated, onMounted, reactive, ref } from "vue"; 7 | import { useRoute } from "vue-router"; 8 | 9 | export default defineComponent({ 10 | props: {}, 11 | emits: [], 12 | setup: (props, ctx) => { 13 | const route = useRoute(); 14 | const form = reactive({ 15 | words: "", 16 | type: VariableType.HUMP, 17 | }); 18 | const iptEl = ref(); 19 | const data = ref([]); 20 | const loading = ref(false); 21 | 22 | const dataList = computed<{ text: string; color: string }[]>(() => { 23 | const list = [ 24 | ...new Set( 25 | data.value.map(v => 26 | v 27 | .split("") 28 | .map(val => val.toLocaleLowerCase().replace(/[\.\,\?\!\~\/\-]/g, "")) 29 | .join("") 30 | ) 31 | ), 32 | ]; 33 | return list.map(text => { 34 | text = text.replace(/\s/g, "-"); 35 | if (form.type === VariableType.HUMP) { 36 | text = lineToHump(text); 37 | } else if (form.type === VariableType.UNDERLINE) { 38 | text = text.replace(/-/g, "_"); 39 | } 40 | return { 41 | text, 42 | color: colors[random(0, colors.length - 1)], 43 | }; 44 | }); 45 | }); 46 | 47 | function handleSubmit() { 48 | if (!form.words || !/[\u4e00-\u9fa5]{0,}/g.test(form.words)) { 49 | message.error("请先输入变量中文名"); 50 | return; 51 | } 52 | loading.value = true; 53 | electronAPI 54 | .translate(form.words) 55 | .then(list => { 56 | data.value = list; 57 | if (!dataList.value.length) { 58 | message.error("未匹配到相关数据"); 59 | } 60 | }) 61 | .finally(() => { 62 | loading.value = false; 63 | }); 64 | } 65 | 66 | onMounted(() => { 67 | const { words, type } = route.query; 68 | if (words) { 69 | form.words = words as string; 70 | } 71 | if (!isEmpty(type)) { 72 | form.type = Number(type); 73 | } 74 | }); 75 | 76 | onActivated(() => { 77 | iptEl.value?.focus(); 78 | }); 79 | 80 | return () => ( 81 |
82 |
83 |
84 | 85 | 变量中文名 86 | { 92 | if (e.key === "Enter") { 93 | e.preventDefault(); 94 | handleSubmit(); 95 | } 96 | }} 97 | /> 98 | ({ label: v.text, value: v.value }))} 101 | v-model={[form.type, "value"]} 102 | /> 103 | 104 |
105 | 106 | {form.words ? ( 107 | { 113 | handleSubmit(); 114 | }} 115 | > 116 | 翻译 117 | 118 | ) : null} 119 |
120 | {data.value.length ? ( 121 |
122 | {dataList.value.map(item => ( 123 | { 129 | copyText(item.text).then(() => { 130 | message.success("复制成功"); 131 | }); 132 | }} 133 | > 134 | {item.text} 135 | 136 | ))} 137 |
138 | ) : null} 139 |
140 | ); 141 | }, 142 | }); 143 | -------------------------------------------------------------------------------- /src/page/developer/typescript.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue"; 2 | 3 | export default defineComponent({ 4 | props: {}, 5 | emits: [], 6 | setup: (props, ctx) => { 7 | return () =>
typescript
; 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/page/error/404.tsx: -------------------------------------------------------------------------------- 1 | import { NButton, NResult } from "naive-ui"; 2 | import { defineComponent } from "vue"; 3 | import { useRouter } from "vue-router"; 4 | 5 | export default defineComponent({ 6 | props: {}, 7 | emits: [], 8 | setup: (props, ctx) => { 9 | const router = useRouter(); 10 | 11 | return () => ( 12 |
13 | 14 | {{ 15 | footer() { 16 | return router.replace({ name: "index" })}>返回首页; 17 | }, 18 | }} 19 | 20 |
21 | ); 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/page/image/clip.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, ref } from "vue"; 2 | import Cropper from "cropperjs"; 3 | import "cropperjs/dist/cropper.css"; 4 | import { NButton, NIcon, NText, NUpload, NUploadDragger, UploadFileInfo } from "naive-ui"; 5 | import { UploadFileOutlined } from "@vicons/material"; 6 | import { downLoadBase64File, fileToBase64, getFilePathInfo, sleep } from "@/helper"; 7 | // import { dialog, message } from "@/service/common"; 8 | 9 | export default defineComponent({ 10 | props: {}, 11 | emits: [], 12 | setup: (props, ctx) => { 13 | const orgSrc = ref(""); 14 | const orgImg = ref(); 15 | const loading = ref(false); 16 | let cropper: Cropper; 17 | let fileName: string; 18 | 19 | async function uploadImage({ file }: UploadFileInfo) { 20 | if (!file) return; 21 | fileName = file.name; 22 | orgSrc.value = await fileToBase64(file).then(v => v as string); 23 | cropper?.destroy(); 24 | await sleep(100); 25 | cropper = new Cropper(orgImg.value!, { 26 | viewMode: 1, 27 | dragMode: "crop", 28 | initialAspectRatio: 1, 29 | preview: ".preview", 30 | zoomOnWheel: true, 31 | aspectRatio: undefined, 32 | }); 33 | } 34 | 35 | async function handleClip() { 36 | const croppedCanvas = cropper?.getCroppedCanvas({ 37 | imageSmoothingQuality: "high", 38 | }); 39 | const src = croppedCanvas.toDataURL("image/png"); 40 | const { width, height } = croppedCanvas; 41 | const [name, ext] = getFilePathInfo(fileName); 42 | loading.value = true; 43 | try { 44 | // const iptEl = ref(); 45 | // const fileName = ref(""); 46 | // await new Promise((resolve, reject) => { 47 | // dialog.warning({ 48 | // title: "请输入文件名称", 49 | // content() { 50 | // return ( 51 | // 52 | // 53 | // .png 54 | // 55 | // ); 56 | // }, 57 | // negativeText: "取消", 58 | // positiveText: "确认", 59 | // onPositiveClick(e) { 60 | // resolve(e); 61 | // }, 62 | // onNegativeClick(e) { 63 | // reject(e); 64 | // }, 65 | // onAfterEnter() { 66 | // iptEl.value?.focus(); 67 | // }, 68 | // }); 69 | // }); 70 | // if (!fileName.value) { 71 | // message.error("请先输入文件名称"); 72 | // return; 73 | // } 74 | await downLoadBase64File(src, `${name}--${width}x${height}.${ext}`); 75 | } finally { 76 | loading.value = false; 77 | } 78 | } 79 | 80 | return () => ( 81 |
82 | uploadImage(e.file)} 85 | fileList={[]} 86 | class="mar-b-5-item" 87 | showCancelButton={false} 88 | showDownloadButton={false} 89 | showRemoveButton={false} 90 | showRetryButton={false} 91 | showPreviewButton={false} 92 | > 93 | 94 |
95 | 96 | 97 | 98 | 点击或者拖动图片到该区域来进行裁剪 99 |
100 |
101 |
102 | {orgSrc.value ? ( 103 |
104 |
105 | 图片裁剪 106 |
107 |
108 |
109 |
110 |
111 | { 116 | handleClip(); 117 | }} 118 | loading={loading.value} 119 | > 120 | 确认裁切 121 | 122 |
123 |
124 | ) : null} 125 |
126 | ); 127 | }, 128 | }); 129 | -------------------------------------------------------------------------------- /src/page/image/compress.tsx: -------------------------------------------------------------------------------- 1 | import { random } from "@/helper"; 2 | import { UploadFileOutlined } from "@vicons/material"; 3 | import { NAlert, NButton, NCard, NCheckbox, NIcon, NInput, NProgress, NText, NUpload, NUploadDragger, UploadFileInfo } from "naive-ui"; 4 | import { defineComponent, ref } from "vue"; 5 | import { loadingProgressBar, message } from "@/service/common"; 6 | import config from "@/config"; 7 | 8 | type UploadFile = UploadFileInfo & { 9 | fileSize: number; 10 | targetSize: number; 11 | }; 12 | 13 | export default defineComponent({ 14 | props: {}, 15 | emits: [], 16 | setup: (props, ctx) => { 17 | const fileList = ref([]); 18 | const saveDirectory = ref(""); 19 | const checkOpen = ref(true); 20 | const checkResize = ref(false); 21 | const width = ref(null); 22 | let timer: NodeJS.Timeout; 23 | 24 | async function uploadImage({ file }: UploadFileInfo) { 25 | if (!file) return; 26 | const init: UploadFile = { 27 | id: file.path, 28 | name: file.name, 29 | status: "pending", 30 | percentage: 0, 31 | file, 32 | fileSize: file.size, 33 | targetSize: file.size, 34 | }; 35 | if (fileList.value.some(v => v.id === init.id)) { 36 | return; 37 | } 38 | fileList.value.push(init); 39 | if (!saveDirectory.value) { 40 | saveDirectory.value = file.path.replace(file.name, ""); 41 | } 42 | clearTimeout(timer); 43 | await new Promise(resolve => { 44 | timer = setTimeout(resolve, 100); 45 | }); 46 | const list = fileList.value.filter(v => v.status === "pending"); 47 | const hide = loadingProgressBar(4000); 48 | try { 49 | await Promise.all( 50 | list 51 | .map(v => v.file) 52 | .map(file => { 53 | const index = fileList.value.findIndex(v => v.id === file!.path); 54 | const item = fileList.value[index]; 55 | fileList.value.splice(index, 1, { ...item, status: "uploading" }); 56 | const t = setInterval(() => { 57 | const li = fileList.value[index]; 58 | const percentage = Math.min(99, li.percentage! + random(10, 300) / 100); 59 | fileList.value.splice(index, 1, { ...li, percentage: Number(percentage.toFixed(2)), status: "uploading" }); 60 | }, 100); 61 | return electronAPI 62 | .compressImage(file!.path, saveDirectory.value, checkResize.value && width.value ? Number(width.value) : undefined) 63 | .then(e => { 64 | fileList.value.splice(index, 1, { ...item, status: "finished", file: undefined, percentage: 100, ...e }); 65 | }) 66 | .catch(e => { 67 | fileList.value.splice(index, 1); 68 | message.error(`${item.name}压缩失败`); 69 | return Promise.reject(new Error(`${item.name}压缩失败`)); 70 | }) 71 | .finally(() => { 72 | clearInterval(t); 73 | }); 74 | }) 75 | ); 76 | message.success("图片压缩成功"); 77 | if (config.isElectron) { 78 | electronAPI.notification("图片压缩成功", list.map(v => v.name).join(",")); 79 | } 80 | if (checkOpen.value && saveDirectory.value) { 81 | electronAPI.openDirectory(saveDirectory.value); 82 | } 83 | } finally { 84 | hide(); 85 | } 86 | } 87 | 88 | return () => ( 89 |
90 | 91 |
92 | { 96 | electronAPI.selectDirectory("图片压缩后保存的位置").then(data => { 97 | if (data) { 98 | saveDirectory.value = data; 99 | } 100 | }); 101 | }} 102 | size="small" 103 | > 104 | 选择位置 105 | 106 | {saveDirectory.value ? ( 107 | { 113 | electronAPI.openDirectory(saveDirectory.value); 114 | }} 115 | > 116 | 打开位置 117 | 118 | ) : null} 119 |
120 |
121 |
122 |
123 | (checkResize.value = e)} 126 | class="mar-r-2-item" 127 | label="修改图片尺寸" 128 | > 129 | {checkResize.value ? ( 130 | { 135 | if (Number(e)) { 136 | width.value = e; 137 | } 138 | }} 139 | /> 140 | ) : null} 141 |
142 | {saveDirectory.value ? ( 143 | { 146 | checkOpen.value = e; 147 | }} 148 | > 149 | 上传完成后自动打开位置 150 | 151 | ) : null} 152 |
153 | uploadImage(e.file)} 162 | fileList={[]} 163 | class="mar-b-5-item" 164 | > 165 | 166 |
167 | 168 | 169 | 170 | 点击或者拖动图片到该区域来进行压缩 171 |
172 |
173 |
174 |
175 | {fileList.value.map(item => ( 176 | 177 |
178 |
179 | 180 | {item.name} 181 | 182 | 183 | {(item.fileSize / 1024).toFixed(2)}kb 184 | 185 |
186 | 194 |
195 | {item.status === "finished" ? ( 196 | <> 197 | 198 | {(item.targetSize / 1024).toFixed(2)}kb 199 | 200 | -{(((item.fileSize - item.targetSize) / item.fileSize) * 100).toFixed(2)}% 201 | 202 | ) : null} 203 |
204 |
205 |
206 | ))} 207 |
208 |
209 | ); 210 | }, 211 | }); 212 | -------------------------------------------------------------------------------- /src/page/image/ico.tsx: -------------------------------------------------------------------------------- 1 | import { random } from "@/helper"; 2 | import { loadingProgressBar, message } from "@/service/common"; 3 | import { UploadFileOutlined } from "@vicons/material"; 4 | import { NIcon, NRadio, NRadioGroup, NText, NUpload, NUploadDragger, UploadFileInfo } from "naive-ui"; 5 | import { defineComponent, ref } from "vue"; 6 | 7 | export default defineComponent({ 8 | props: {}, 9 | emits: [], 10 | setup: (props, ctx) => { 11 | const fileList = ref([]); 12 | const icoSizes = ref([16, 24, 32, 48, 64, 128, 256]); 13 | const icoSize = ref(32); 14 | 15 | function uploadImage({ file }: UploadFileInfo) { 16 | if (!file) return; 17 | const item: UploadFileInfo = { id: file.name, name: file.name, status: "uploading", file, percentage: 0 }; 18 | fileList.value.push(item); 19 | const index = fileList.value.length - 1; 20 | const t = setInterval(() => { 21 | const li = fileList.value[index]; 22 | const percentage = Math.min(99, li.percentage! + random(10, 300) / 100); 23 | fileList.value.splice(index, 1, { ...li, percentage: parseFloat(percentage.toFixed(2)), status: "uploading" }); 24 | }, 100); 25 | const hide = loadingProgressBar(); 26 | electronAPI 27 | .pngToIco(file.path, icoSize.value) 28 | .then(data => { 29 | fileList.value.splice(index, 1, { ...item, status: "finished" }); 30 | message.success(`转化成功,【${data}】`); 31 | }) 32 | .catch(e => { 33 | fileList.value.splice(index, 1, { ...item, status: "error" }); 34 | message.error(`${item.name}转化失败`); 35 | }) 36 | .finally(() => { 37 | clearInterval(t); 38 | hide(); 39 | }); 40 | return false; 41 | } 42 | 43 | return () => ( 44 |
45 |
46 | 选择尺寸: 47 | 48 | {icoSizes.value.map(size => ( 49 | 50 | {size} 51 | 52 | ))} 53 | 54 |
55 | uploadImage(e.file)} 58 | fileList={fileList.value} 59 | listType="image" 60 | class="mar-b-5-item" 61 | showCancelButton={false} 62 | showDownloadButton={false} 63 | showRemoveButton={false} 64 | showRetryButton={false} 65 | showPreviewButton={false} 66 | > 67 | 68 |
69 | 70 | 71 | 72 | 点击或者拖动.png文件到该区域来进行转换 73 |
74 |
75 |
76 |
77 | ); 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /src/page/image/watermark.tsx: -------------------------------------------------------------------------------- 1 | import { awaitLoadImg, awaitNextTick, copyImg, downLoadBase64File, fileToBase64, getFilePathInfo } from "@/helper"; 2 | import { message } from "@/service/common"; 3 | import { UploadFileOutlined } from "@vicons/material"; 4 | import { NButton, NColorPicker, NIcon, NInput, NSlider, NSwitch, NText, NUpload, NUploadDragger, UploadFileInfo } from "naive-ui"; 5 | import { defineComponent, reactive, ref } from "vue"; 6 | 7 | export default defineComponent({ 8 | props: {}, 9 | emits: [], 10 | setup: (props, ctx) => { 11 | const canvasEl = ref(); 12 | const iptEl = ref(); 13 | const loading = ref(false); 14 | const form = reactive({ 15 | color: "rgba(0, 0, 0, 0.6)", 16 | fontSize: 12, 17 | margin: 20, 18 | space: 20, 19 | angle: 45, 20 | src: "", 21 | bold: false, 22 | text: "", 23 | fontFamily: '-apple-system,"Helvetica Neue",Helvetica,Arial,"PingFang SC","Hiragino Sans GB","WenQuanYi Micro Hei",sans-serif', 24 | fileName: "", 25 | }); 26 | 27 | // 上传 28 | async function uploadImage({ file }: UploadFileInfo) { 29 | if (!file) { 30 | return false; 31 | } 32 | form.fileName = file.name; 33 | form.src = await fileToBase64(file).then(v => v as string); 34 | await awaitNextTick(); 35 | render(); 36 | iptEl.value?.focus(); 37 | return false; 38 | } 39 | 40 | // 下载二维码 41 | function downCanvas() { 42 | if (!canvasEl.value) { 43 | return; 44 | } 45 | const imgURL = canvasEl.value.toDataURL("image/png"); 46 | const [name, ext] = getFilePathInfo(form.fileName); 47 | loading.value = true; 48 | downLoadBase64File(imgURL, `${name}-watermark.${ext}`).finally(() => { 49 | loading.value = false; 50 | }); 51 | } 52 | 53 | // 渲染 54 | async function render() { 55 | if (!form.src) { 56 | return; 57 | } 58 | const canvas = canvasEl.value; 59 | if (!canvas) { 60 | return; 61 | } 62 | const ctx = canvas.getContext("2d"); 63 | if (!ctx) { 64 | return; 65 | } 66 | 67 | ctx.clearRect(0, 0, canvas.width, canvas.height); 68 | const img = await awaitLoadImg(form.src); 69 | canvas.width = img.width; 70 | canvas.height = img.height; 71 | ctx.drawImage(img, 0, 0, img.width, img.height); 72 | if (form.text) { 73 | const fontArr = [form.fontSize + "px", form.fontFamily]; 74 | if (form.bold) { 75 | fontArr.unshift("bold"); 76 | } 77 | ctx.fillStyle = form.color; 78 | ctx.font = fontArr.join(" "); 79 | ctx.rotate((form.angle * Math.PI) / 180); 80 | const width = ctx.measureText(form.text).width; 81 | const step = Math.sqrt(Math.pow(canvas.width, 2) + Math.pow(canvas.height, 2)); 82 | const x = Math.ceil(step / (width + form.space)); 83 | const y = Math.ceil(step / (form.margin * form.fontSize) / 2); 84 | let i, j, k, l, ref, ref1, ref2; 85 | for (i = k = 0, ref = x; 0 <= ref ? k <= ref : k >= ref; i = 0 <= ref ? ++k : --k) { 86 | for (j = l = ref1 = -y, ref2 = y; ref1 <= ref2 ? l <= ref2 : l >= ref2; j = ref1 <= ref2 ? ++l : --l) { 87 | ctx.fillText(form.text, (width + form.space) * i, form.margin * form.fontSize * j); 88 | } 89 | } 90 | } 91 | } 92 | 93 | let timer: NodeJS.Timeout; 94 | function delayRender() { 95 | clearTimeout(timer); 96 | timer = setTimeout(() => { 97 | render(); 98 | }, 500); 99 | } 100 | 101 | return () => ( 102 |
103 | uploadImage(e.file)} fileList={[]} class="mar-b-7-item"> 104 | 105 |
106 | 107 | 108 | 109 | 点击或者拖动图片到该区域 110 |
111 |
112 |
113 | {form.src ? ( 114 | <> 115 |
116 | 水印文字 117 | { 122 | if (e.key === "Enter") { 123 | e.preventDefault(); 124 | render(); 125 | } 126 | }} 127 | class="flex-item-extend" 128 | /> 129 |
130 |
131 | 字体颜色 132 | delayRender()} class="flex-item-extend" /> 133 |
134 |
135 | 字体大小 136 | delayRender()} class="flex-item-extend" /> 137 |
138 |
139 | 字体加粗 140 | delayRender()} /> 141 |
142 |
143 | 空白间距 144 | delayRender()} class="flex-item-extend" /> 145 |
146 |
147 | 文字间距 148 | delayRender()} class="flex-item-extend" /> 149 |
150 |
151 | 角度 152 | delayRender()} class="flex-item-extend" /> 153 |
154 |
155 | 156 |
157 | { 164 | copyImg(canvasEl.value!.toDataURL("image/png")).then(() => { 165 | message.success("复制成功"); 166 | }); 167 | }} 168 | > 169 | 复制图片 170 | 171 | { 177 | downCanvas(); 178 | }} 179 | loading={loading.value} 180 | > 181 | 下载图片 182 | 183 | 184 | ) : null} 185 |
186 | ); 187 | }, 188 | }); 189 | -------------------------------------------------------------------------------- /src/page/index.tsx: -------------------------------------------------------------------------------- 1 | import { firstMenus } from "@/layout/Index"; 2 | import { menuRoutes } from "@/router"; 3 | import { NCard, NGrid, NGridItem, NIcon } from "naive-ui"; 4 | import { defineComponent } from "vue"; 5 | import { RouterLink } from "vue-router"; 6 | 7 | export default defineComponent({ 8 | props: {}, 9 | emits: [], 10 | setup: (props, ctx) => { 11 | return () => ( 12 | 13 | {menuRoutes.map(item => { 14 | const firstMenu = firstMenus.find(v => new RegExp(`^${v.key}-`).test(item.name as string)); 15 | return ( 16 | 17 | 18 | 23 | 24 | {firstMenu?.icon!()} 25 | 26 | {item.meta?.title} 27 | 28 | 29 | 30 | ); 31 | })} 32 | 33 | ); 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/page/qrcode/create.tsx: -------------------------------------------------------------------------------- 1 | import { NButton, NCard, NCheckbox, NDrawer, NDrawerContent, NIcon, NInput, NUpload, UploadFileInfo } from "naive-ui"; 2 | import { defineComponent, onActivated, onMounted, ref } from "vue"; 3 | import { awaitLoadImg, awaitNextTick, copyImg, downLoadBase64File, fileToBase64, randomString } from "@/helper"; 4 | import qrcode from "qrcode"; 5 | import { DeleteOutlined, UploadFileOutlined, UploadOutlined } from "@vicons/material"; 6 | import { addLogo, deleteLogo, getLogoList, logoList, logoOpts } from "@/service/qrcode"; 7 | import { dialog, message } from "@/service/common"; 8 | import router from "@/router"; 9 | 10 | export default defineComponent({ 11 | props: {}, 12 | emits: [], 13 | setup: (props, ctx) => { 14 | const text = ref(""); 15 | const canvasEl = ref(); 16 | const iptEl = ref(); 17 | const showPreview = ref(false); 18 | const logo = ref(""); 19 | const logoDrawer = ref(false); 20 | const loading = ref(false); 21 | 22 | function clip(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { 23 | ctx.beginPath(); 24 | ctx.moveTo(x + logoOpts.radius, y); 25 | ctx.arcTo(x + w, y, x + w, y + h, logoOpts.radius); 26 | ctx.arcTo(x + w, y + h, x, y + h, logoOpts.radius); 27 | ctx.arcTo(x, y + h, x, y, logoOpts.radius); 28 | ctx.arcTo(x, y, x + w, y, logoOpts.radius); 29 | ctx.closePath(); 30 | } 31 | 32 | async function drawImg(url: string, x: number, y: number, width: number, height: number) { 33 | const canvas = document.createElement("canvas"); 34 | const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; 35 | const img = await awaitLoadImg(url); 36 | canvas.width = width + x * 2; 37 | canvas.height = height + y * 2; 38 | ctx.drawImage(img, x, y, width, height); 39 | return canvas.toDataURL("image/png"); 40 | } 41 | // 生成有圆角的矩形 42 | async function drawRoundedImg(url: string, width: number, height: number, x = 0, y = 0) { 43 | const canvas = document.createElement("canvas"); 44 | const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; 45 | const imgX = x + logoOpts.border, 46 | imgY = y + logoOpts.border, 47 | imgW = width - logoOpts.border * 2, 48 | imgH = height - logoOpts.border * 2; 49 | const src = await drawImg(url, imgX, imgY, imgW, imgH); 50 | const img = await awaitLoadImg(src); 51 | 52 | canvas.width = width; 53 | canvas.height = height; 54 | clip(ctx, x, y, width, height); 55 | ctx.fillStyle = logoOpts.bgColor; 56 | ctx.fill(); 57 | clip(ctx, imgX, imgY, imgW, imgH); 58 | ctx.fillStyle = ctx.createPattern(img, "no-repeat")!; 59 | ctx.fill(); 60 | const data = canvas.toDataURL("image/png"); 61 | return data; 62 | } 63 | 64 | // 生成二维码 65 | async function makeQrcode() { 66 | if (!text.value) { 67 | return; 68 | } 69 | showPreview.value = true; 70 | await awaitNextTick(); 71 | await qrcode.toCanvas(canvasEl.value, text.value, { width: 260, margin: 2 }); 72 | 73 | if (!canvasEl.value) { 74 | return; 75 | } 76 | if (!logo.value) { 77 | return; 78 | } 79 | 80 | const ctx = canvasEl.value.getContext("2d"); 81 | const w = canvasEl.value.offsetWidth; 82 | const h = canvasEl.value.offsetHeight; 83 | if (!ctx) { 84 | return; 85 | } 86 | let img = await awaitLoadImg(logo.value); 87 | const height = (logoOpts.width * img.height) / img.width; 88 | const x = (w - logoOpts.width) / 2; 89 | const y = (h - height) / 2; 90 | 91 | const temSrc = canvasEl.value.toDataURL(); 92 | const temImg = await awaitLoadImg(temSrc); 93 | ctx.clearRect(0, 0, w, h); 94 | ctx.drawImage(temImg, 0, 0, w, h); 95 | 96 | const src = await drawRoundedImg(logo.value, logoOpts.width, height); 97 | img = await awaitLoadImg(src); 98 | 99 | ctx.drawImage(img, x, y, logoOpts.width, height); 100 | } 101 | 102 | // 下载二维码 103 | function downCanvas() { 104 | if (!canvasEl.value) { 105 | return; 106 | } 107 | const imgURL = canvasEl.value.toDataURL("image/png"); 108 | loading.value = true; 109 | downLoadBase64File(imgURL, `qrcode-${randomString(10)}.png`).finally(() => { 110 | loading.value = false; 111 | }); 112 | } 113 | 114 | // 上传logo 115 | async function uploadImage({ file }: UploadFileInfo) { 116 | if (!file) return; 117 | const url = await fileToBase64(file).then(v => v as string); 118 | await addLogo(url); 119 | return false; 120 | } 121 | 122 | onActivated(() => { 123 | iptEl.value?.focus(); 124 | }); 125 | 126 | onMounted(() => { 127 | const { query } = router.currentRoute.value; 128 | if (query.text) { 129 | text.value = query.text as string; 130 | } 131 | }); 132 | 133 | return () => ( 134 |
135 |
136 |
137 | { 140 | showPreview.value = false; 141 | }} 142 | ref={iptEl} 143 | placeholder="请输入文字内容" 144 | rows={8} 145 | class="mar-b-5-item" 146 | v-model={[text.value, "value"]} 147 | /> 148 | {text.value ? ( 149 | { 153 | makeQrcode(); 154 | }} 155 | > 156 | 生成二维码 157 | 158 | ) : null} 159 |
160 | {text.value && showPreview.value ? ( 161 | 162 | {{ 163 | default() { 164 | return ( 165 |
166 | 167 |
168 | ); 169 | }, 170 | footer() { 171 | return ( 172 |
173 | { 177 | logoDrawer.value = true; 178 | getLogoList(); 179 | }} 180 | > 181 | {{ 182 | icon() { 183 | return ( 184 | 185 | 186 | 187 | ); 188 | }, 189 | default() { 190 | return "上传logo"; 191 | }, 192 | }} 193 | 194 | { 201 | copyImg(canvasEl.value!.toDataURL("image/png")).then(() => { 202 | message.success("复制成功"); 203 | }); 204 | }} 205 | > 206 | 复制图片 207 | 208 | { 214 | downCanvas(); 215 | }} 216 | loading={loading.value} 217 | > 218 | 下载图片 219 | 220 |
221 | ); 222 | }, 223 | }} 224 |
225 | ) : null} 226 |
227 | 228 | 229 | <> 230 | uploadImage(e.file)} fileList={[]} class="mar-b-5-item full-width"> 231 | 232 | {{ 233 | icon() { 234 | return ( 235 | 236 | 237 | 238 | ); 239 | }, 240 | default() { 241 | return "上传"; 242 | }, 243 | }} 244 | 245 | 246 | {logoList.value.length ? ( 247 |
248 | {logoList.value.map(item => ( 249 | { 253 | logo.value = checked ? item.url : ""; 254 | makeQrcode(); 255 | logoDrawer.value = false; 256 | }} 257 | checked={logo.value === item.url} 258 | > 259 |
260 | 261 | { 267 | e.stopPropagation(); 268 | dialog.warning({ 269 | title: "删除确认", 270 | content: "确认要删除此logo?", 271 | positiveText: "确认", 272 | negativeText: "取消", 273 | onPositiveClick() { 274 | return deleteLogo(item.id); 275 | }, 276 | maskClosable: false, 277 | }); 278 | }} 279 | > 280 | {{ 281 | icon() { 282 | return ( 283 | 284 | 285 | 286 | ); 287 | }, 288 | }} 289 | 290 |
291 |
292 | ))} 293 |
294 | ) : null} 295 | 296 |
297 |
298 |
299 | ); 300 | }, 301 | }); 302 | -------------------------------------------------------------------------------- /src/page/qrcode/decode.tsx: -------------------------------------------------------------------------------- 1 | import { copyText, fileToBase64 } from "@/helper"; 2 | import { UploadFileOutlined } from "@vicons/material"; 3 | import { NIcon, NText, NUpload, NUploadDragger, UploadFileInfo } from "naive-ui"; 4 | import { defineComponent } from "vue"; 5 | import qrcodeDecoder from "qrcode-decoder"; 6 | import { dialog, message } from "@/service/common"; 7 | 8 | export default defineComponent({ 9 | props: {}, 10 | emits: [], 11 | setup: (props, ctx) => { 12 | async function uploadImage({ file }: UploadFileInfo) { 13 | if (!file) { 14 | return; 15 | } 16 | const src = await fileToBase64(file); 17 | const img = new Image(); 18 | img.src = src as string; 19 | await new Promise(resolve => { 20 | img.onload = e => { 21 | resolve(e); 22 | }; 23 | }); 24 | const qr = new qrcodeDecoder(); 25 | const { data } = await qr.decodeFromImage(img); 26 | dialog.success({ 27 | title: "解码成功", 28 | content: data, 29 | positiveText: "复制", 30 | onPositiveClick() { 31 | copyText(data).then(() => { 32 | message.success("复制成功"); 33 | }); 34 | }, 35 | }); 36 | } 37 | 38 | return () => ( 39 |
40 | uploadImage(e.file)} 43 | fileList={[]} 44 | listType="image" 45 | showDownloadButton 46 | class="mar-b-5-item" 47 | > 48 | 49 |
50 | 51 | 52 | 53 | 点击或者拖动图片到该区域来上传 54 |
55 |
56 |
57 |
58 | ); 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /src/page/util/date-timestamp.tsx: -------------------------------------------------------------------------------- 1 | import { copyText } from "@/helper"; 2 | import { message } from "@/service/common"; 3 | import { NAlert, NButton, NInput, NInputGroup, NInputGroupLabel, NSelect } from "naive-ui"; 4 | import { defineComponent, onActivated, onMounted, reactive, ref } from "vue"; 5 | import { DateType } from "@/service/util"; 6 | import dayjs from "dayjs"; 7 | import { useRoute } from "vue-router"; 8 | 9 | export default defineComponent({ 10 | props: {}, 11 | emits: [], 12 | setup: (props, ctx) => { 13 | const route = useRoute(); 14 | const form = reactive<{ value: string; type: DateType }>({ 15 | value: "", 16 | type: DateType.MS, 17 | }); 18 | const ret = ref(0); 19 | const iptEl = ref(); 20 | 21 | function handleSubmit() { 22 | const data = dayjs(form.value); 23 | if (!data.isValid()) { 24 | message.error("错误的日期"); 25 | return; 26 | } 27 | let value = data.toDate().getTime(); 28 | if (form.type === DateType.S) { 29 | value = Math.ceil(value / 1000); 30 | } 31 | ret.value = value; 32 | } 33 | 34 | onActivated(() => { 35 | iptEl.value?.focus(); 36 | }); 37 | 38 | onMounted(() => { 39 | const { value, type } = route.query; 40 | if (value) { 41 | form.value = value as string; 42 | } 43 | if (type) { 44 | form.type = type as DateType; 45 | } 46 | }); 47 | 48 | return () => ( 49 |
50 |
51 |
52 | 53 | 日期 54 | { 58 | if (e.key === "Enter") { 59 | e.preventDefault(); 60 | handleSubmit(); 61 | } 62 | }} 63 | v-model={[form.value, "value"]} 64 | placeholder="请输入日期,回车转换" 65 | /> 66 | 80 | 81 |
82 | {form.value ? ( 83 | { 87 | handleSubmit(); 88 | }} 89 | > 90 | 转换 91 | 92 | ) : null} 93 |
94 | {ret.value ? ( 95 | 96 |
97 | { 100 | copyText(String(ret.value)).then(() => { 101 | message.success("复制成功"); 102 | }); 103 | }} 104 | size="small" 105 | > 106 | 复制 107 | 108 |
109 |
110 | ) : null} 111 |
112 | ); 113 | }, 114 | }); 115 | -------------------------------------------------------------------------------- /src/page/util/num-money.tsx: -------------------------------------------------------------------------------- 1 | import { copyText } from "@/helper"; 2 | import router from "@/router"; 3 | import { message } from "@/service/common"; 4 | import { digitUppercase } from "@/service/util"; 5 | import { NAlert, NButton, NInput } from "naive-ui"; 6 | import { defineComponent, onActivated, onMounted, ref } from "vue"; 7 | 8 | export default defineComponent({ 9 | props: {}, 10 | emits: [], 11 | setup: (props, ctx) => { 12 | const num = ref(null); 13 | const ret = ref(""); 14 | const iptEl = ref(); 15 | 16 | function handleSubmit() { 17 | if (!num.value) { 18 | return; 19 | } 20 | ret.value = digitUppercase(Number(num.value)); 21 | } 22 | 23 | onActivated(() => { 24 | iptEl.value?.focus(); 25 | }); 26 | 27 | onMounted(() => { 28 | const { query } = router.currentRoute.value; 29 | if (query.num) { 30 | num.value = query.num as string; 31 | } 32 | }); 33 | 34 | return () => ( 35 |
36 |
37 | { 42 | if (Number(val)) { 43 | num.value = val; 44 | } 45 | }} 46 | size="large" 47 | ref={iptEl} 48 | onKeydown={e => { 49 | if (e.key === "Enter") { 50 | e.preventDefault(); 51 | handleSubmit(); 52 | } 53 | }} 54 | /> 55 | {num.value ? ( 56 | { 61 | handleSubmit(); 62 | }} 63 | > 64 | 转换 65 | 66 | ) : null} 67 |
68 | {ret.value ? ( 69 | 70 |
71 | { 74 | copyText(ret.value).then(() => { 75 | message.success("复制成功"); 76 | }); 77 | }} 78 | size="small" 79 | > 80 | 复制 81 | 82 |
83 |
84 | ) : null} 85 |
86 | ); 87 | }, 88 | }); 89 | -------------------------------------------------------------------------------- /src/page/util/timestamp-date.tsx: -------------------------------------------------------------------------------- 1 | import { copyText } from "@/helper"; 2 | import { message } from "@/service/common"; 3 | import { NAlert, NButton, NInput, NInputGroup, NInputGroupLabel, NSelect } from "naive-ui"; 4 | import { defineComponent, onActivated, onMounted, reactive, ref } from "vue"; 5 | import { DateType } from "@/service/util"; 6 | import dayjs from "dayjs"; 7 | import { useRoute } from "vue-router"; 8 | 9 | export default defineComponent({ 10 | props: {}, 11 | emits: [], 12 | setup: (props, ctx) => { 13 | const route = useRoute(); 14 | const form = reactive<{ value: string; type: DateType }>({ 15 | value: "", 16 | type: DateType.S, 17 | }); 18 | const ret = ref(""); 19 | const iptEl = ref(); 20 | 21 | function handleSubmit() { 22 | let value = Number(form.value); 23 | if (form.type === DateType.S) { 24 | value = Math.ceil(value * 1000); 25 | } 26 | ret.value = dayjs(value).format("YYYY-MM-DD HH:mm:ss"); 27 | } 28 | 29 | onActivated(() => { 30 | iptEl.value?.focus(); 31 | }); 32 | 33 | onMounted(() => { 34 | const { value, type } = route.query; 35 | if (value) { 36 | form.value = value as string; 37 | } 38 | if (type) { 39 | form.type = type as DateType; 40 | } 41 | }); 42 | 43 | return () => ( 44 |
45 |
46 |
47 | 48 | 时间戳 49 | { 53 | if (e.key === "Enter") { 54 | e.preventDefault(); 55 | handleSubmit(); 56 | } 57 | }} 58 | v-model={[form.value, "value"]} 59 | placeholder="请输入时间戳,回车转换" 60 | /> 61 | 75 | 76 |
77 | {form.value ? ( 78 | { 82 | handleSubmit(); 83 | }} 84 | > 85 | 转换 86 | 87 | ) : null} 88 |
89 | {ret.value ? ( 90 | 91 |
92 | { 95 | copyText(ret.value).then(() => { 96 | message.success("复制成功"); 97 | }); 98 | }} 99 | size="small" 100 | > 101 | 复制 102 | 103 |
104 |
105 | ) : null} 106 |
107 | ); 108 | }, 109 | }); 110 | -------------------------------------------------------------------------------- /src/page/video/m3u8.tsx: -------------------------------------------------------------------------------- 1 | import config from "@/config"; 2 | import { downLoad, getFullUrl, openUrl } from "@/helper"; 3 | import ajax from "@/helper/ajax"; 4 | import { dialog, message } from "@/service/common"; 5 | import { DownloadStatus, downloadStatusList, IM3u8Item, ITsItem } from "@/service/video"; 6 | import { NAlert, NButton, NCard, NInput, NInputGroup, NInputGroupLabel, NProgress, NTag, NText } from "naive-ui"; 7 | import { computed, defineComponent, onActivated, onMounted, reactive, ref } from "vue"; 8 | import { useRoute } from "vue-router"; 9 | import muxjs from "mux.js"; 10 | 11 | export default defineComponent({ 12 | props: {}, 13 | emits: [], 14 | setup: (props, ctx) => { 15 | const route = useRoute(); 16 | const iptEl = ref(); 17 | const form = reactive({ 18 | name: "", 19 | filePath: "", 20 | url: "", 21 | }); 22 | const loading = ref(false); 23 | const tsList = ref([]); 24 | 25 | const m3u8List = computed(() => { 26 | const arr = [...new Set(tsList.value.map(v => v.m3u8Src))]; 27 | const data: IM3u8Item[] = arr.map(m3u8Src => { 28 | const list = tsList.value.filter(v => v.m3u8Src === m3u8Src)!; 29 | const [item] = list; 30 | let status = item.status; 31 | const total = list.length; 32 | const doneNum = list.filter(li => li.status === DownloadStatus.FINISHED).length; 33 | const percentage = Number(((doneNum / total) * 100).toFixed(2)); 34 | const duration = list.reduce((t, v) => t + v.duration, 0); 35 | if (tsList.value.some(t => t.status === DownloadStatus.DOWNLOADING)) { 36 | status = DownloadStatus.DOWNLOADING; 37 | } else if (tsList.value.some(t => t.status === DownloadStatus.ERROR)) { 38 | status = DownloadStatus.ERROR; 39 | } else if (percentage >= 100) { 40 | status = DownloadStatus.FINISHED; 41 | } 42 | return { 43 | name: item.name, 44 | src: m3u8Src, 45 | filePath: item.filePath, 46 | status, 47 | percentage, 48 | total, 49 | doneNum, 50 | duration, 51 | }; 52 | }); 53 | return data; 54 | }); 55 | 56 | async function downloadM3u8() { 57 | if (!form.url) { 58 | message.error("请先输入m3u8地址"); 59 | return; 60 | } 61 | if (tsList.value.some(v => v.m3u8Src === form.url)) { 62 | message.error("此链接已被处理过"); 63 | return; 64 | } 65 | try { 66 | const { origin } = new URL(form.url); 67 | loading.value = true; 68 | let data = await ajax(form.url); 69 | const newUrl = data.split(/\s/).find(item => /\.m3u8(\?.+)?$/i.test(item)); 70 | if (newUrl) { 71 | data = await ajax(getFullUrl(origin, newUrl)); 72 | } 73 | 74 | const tempArr: ITsItem[] = []; 75 | const lines = data.split(/\s/); 76 | lines.forEach((item, index) => { 77 | if (/((\.ts)|(\.jpg)|(\.png)|(\.gif)|(\.image))(\?.+)?$/i.test(item)) { 78 | // 计算持续时间 79 | let duration = 0; 80 | const durationItem = lines[index - 1]; 81 | const extinf = "#EXTINF:"; 82 | if (durationItem.includes(extinf)) { 83 | duration = parseFloat(durationItem.split(extinf)[1]) || 0; 84 | } 85 | const src = getFullUrl(origin, item); 86 | tempArr.push({ 87 | status: DownloadStatus.WAITING, 88 | name: form.name, 89 | m3u8Src: form.url, 90 | filePath: form.filePath, 91 | src, 92 | file: undefined, 93 | duration, 94 | }); 95 | } 96 | }); 97 | if (!tempArr.length) { 98 | message.error("未解析到相关内容"); 99 | return; 100 | } 101 | tsList.value.push(...tempArr); 102 | 103 | // 批量下载 104 | downloadTsList(); 105 | 106 | form.name = ""; 107 | form.url = ""; 108 | } catch (e) { 109 | message.error("解析错误"); 110 | } finally { 111 | loading.value = false; 112 | } 113 | } 114 | 115 | // 批量下载ts 116 | function downloadTsList() { 117 | for (let i = 0; i < 30 - tsList.value.filter(v => v.status === DownloadStatus.DOWNLOADING).length; i++) { 118 | downloadTs(); 119 | } 120 | } 121 | async function downloadTs() { 122 | const index = tsList.value.findIndex(v => v.status === DownloadStatus.WAITING); 123 | if (index === -1) { 124 | return; 125 | } 126 | const item = tsList.value[index]; 127 | tsList.value[index].status = DownloadStatus.DOWNLOADING; 128 | try { 129 | const data = await ajax(item.src, "arraybuffer"); 130 | // 转码mp4 131 | const file: Uint8Array = await new Promise(resolve => { 132 | const { duration } = m3u8List.value.find(v => v.src === item.m3u8Src)!; 133 | const opts = duration 134 | ? { 135 | keepOriginalTimestamps: true, 136 | duration, 137 | } 138 | : undefined; 139 | const transmuxer = new muxjs.mp4.Transmuxer(opts); 140 | const timer = setTimeout(() => { 141 | resolve(data); 142 | }, 2000); 143 | transmuxer.on("data", (segment: any) => { 144 | clearTimeout(timer); 145 | const data = new Uint8Array(segment.initSegment.byteLength + segment.data.byteLength); 146 | data.set(segment.initSegment, 0); 147 | data.set(segment.data, segment.initSegment.byteLength); 148 | resolve(data); 149 | }); 150 | transmuxer.push(new Uint8Array(data)); 151 | transmuxer.flush(); 152 | }); 153 | 154 | tsList.value[index].status = DownloadStatus.FINISHED; 155 | tsList.value[index].file = file; 156 | const m3u8 = m3u8List.value.find(v => v.src === item.m3u8Src); 157 | if (m3u8?.status === DownloadStatus.FINISHED) { 158 | downloadFile(m3u8); 159 | } 160 | } catch (e) { 161 | tsList.value[index].status = DownloadStatus.ERROR; 162 | } finally { 163 | downloadTs(); 164 | } 165 | } 166 | 167 | // 文件下载 168 | async function downloadFile(m3u8: IM3u8Item) { 169 | const fileDataList = tsList.value 170 | .filter(v => v.m3u8Src === m3u8.src) 171 | .map(v => v.file!) 172 | .filter(v => !!v); 173 | const fileBlob = new Blob(fileDataList, { type: "video/mp4" }); 174 | const url = URL.createObjectURL(fileBlob); 175 | if (config.isElectron && m3u8.filePath) { 176 | const buf = await new Promise((resolve, reject) => { 177 | const reader = new FileReader(); 178 | reader.readAsArrayBuffer(fileBlob); 179 | reader.onload = () => { 180 | if (reader.result && typeof reader.result !== "string") { 181 | resolve(new Uint8Array(reader.result)); 182 | } 183 | }; 184 | reader.onerror = error => reject(error); 185 | }); 186 | const filePath = `${m3u8.filePath}/${m3u8.name}.mp4`; 187 | await electronAPI.writeFile(filePath, buf); 188 | message.success(`下载成功:【${filePath}】`); 189 | if (config.isElectron) { 190 | electronAPI.notification("下载成功", filePath); 191 | } 192 | } else { 193 | downLoad(url, `${m3u8.name}.mp4`); 194 | } 195 | } 196 | 197 | // 修改文件名称 198 | function showFileNameDialog() { 199 | const iptEl = ref(); 200 | const fileName = ref(""); 201 | return new Promise((resolve, reject) => { 202 | dialog.warning({ 203 | title: "请输入文件名称", 204 | content() { 205 | return ; 206 | }, 207 | negativeText: "取消", 208 | positiveText: "确认", 209 | onPositiveClick() { 210 | resolve(fileName.value); 211 | }, 212 | onNegativeClick(e) { 213 | reject(e); 214 | }, 215 | onAfterEnter() { 216 | iptEl.value?.focus(); 217 | }, 218 | }); 219 | }); 220 | } 221 | 222 | onActivated(() => { 223 | iptEl.value?.focus(); 224 | }); 225 | 226 | onMounted(() => { 227 | const { url, name } = route.query; 228 | if (url) { 229 | form.url = url as string; 230 | } 231 | if (name) { 232 | form.name = name as string; 233 | } 234 | }); 235 | 236 | return () => ( 237 |
238 | 239 |
240 | 241 | 视频地址 242 | { 246 | if (e.key === "Enter") { 247 | e.preventDefault(); 248 | downloadM3u8(); 249 | } 250 | }} 251 | placeholder="请输入m3u8地址,回车解析" 252 | v-model={[form.url, "value"]} 253 | /> 254 | 255 |
256 |
257 | 258 | 文件名称 259 | { 263 | tsList.value 264 | .filter(v => v.m3u8Src === form.url) 265 | .forEach(item => { 266 | item.name = e; 267 | }); 268 | }} 269 | v-model={[form.name, "value"]} 270 | /> 271 | 272 |
273 |
274 | { 280 | openUrl(config.movieUrl); 281 | }} 282 | > 283 | 前往沃德影视 284 | 285 | { 292 | downloadM3u8(); 293 | }} 294 | > 295 | 解析 296 | 297 |
298 | 299 | {config.isElectron ? ( 300 | 301 |
302 | { 306 | electronAPI.selectDirectory("视频下载后保存的位置").then(data => { 307 | if (data) { 308 | form.filePath = data; 309 | } 310 | }); 311 | }} 312 | size="small" 313 | > 314 | 选择位置 315 | 316 |
317 |
318 | ) : null} 319 |
320 | 321 | {m3u8List.value.map(item => ( 322 | 323 | {{ 324 | default() { 325 | return ( 326 | <> 327 |
328 |
329 | {(() => { 330 | const data = downloadStatusList.find(v => v.value === item.status); 331 | if (data) { 332 | return ( 333 | 334 | {data.text} 335 | 336 | ); 337 | } 338 | return null; 339 | })()} 340 | {item.name ? {item.name} : null} 341 | {item.filePath ? ( 342 | 343 | ({item.filePath}) 344 | 345 | ) : null} 346 |
347 |
348 | {item.status === DownloadStatus.ERROR ? ( 349 | { 352 | tsList.value 353 | .filter(v => v.m3u8Src === item.src && v.status === DownloadStatus.ERROR) 354 | .forEach(item => { 355 | item.status = DownloadStatus.WAITING; 356 | }); 357 | downloadTsList(); 358 | }} 359 | > 360 | 重试下载 361 | 362 | ) : null} 363 | {item.status === DownloadStatus.FINISHED ? ( 364 | { 367 | downloadFile(item); 368 | }} 369 | > 370 | 下载视频 371 | 372 | ) : item.percentage > 0 ? ( 373 | { 376 | downloadFile(item); 377 | }} 378 | > 379 | 强制下载 380 | 381 | ) : null} 382 | {config.isElectron ? ( 383 | <> 384 | {item.filePath && item.status === DownloadStatus.FINISHED ? ( 385 | { 388 | electronAPI.openDirectory(item.filePath); 389 | }} 390 | > 391 | 打开视频位置 392 | 393 | ) : null} 394 | { 397 | electronAPI.selectDirectory("视频下载后保存的位置").then(data => { 398 | if (data) { 399 | tsList.value 400 | .filter(v => v.m3u8Src === item.src) 401 | .forEach(item => { 402 | item.filePath = data; 403 | }); 404 | } 405 | }); 406 | }} 407 | > 408 | 存放位置 409 | 410 | 411 | ) : null} 412 | { 415 | showFileNameDialog().then(name => { 416 | if (name) { 417 | tsList.value 418 | .filter(v => v.m3u8Src === item.src) 419 | .forEach(item => { 420 | item.name = name; 421 | }); 422 | } 423 | }); 424 | }} 425 | > 426 | 文件名称 427 | 428 |
429 |
430 |
431 | 432 | {item.src} 433 | 434 |
435 | 436 | ); 437 | }, 438 | footer() { 439 | return ( 440 |
441 | 451 | 452 | {item.doneNum} 453 | / 454 | {item.total} 455 | 456 |
457 | ); 458 | }, 459 | }} 460 |
461 | ))} 462 |
463 | ); 464 | }, 465 | }); 466 | -------------------------------------------------------------------------------- /src/page/video/parse.tsx: -------------------------------------------------------------------------------- 1 | import { openUrl } from "@/helper"; 2 | import { isUrl } from "@/helper/validate"; 3 | import { message } from "@/service/common"; 4 | import { circuits, videoList } from "@/service/video"; 5 | import { NButton, NInput, NInputGroup, NInputGroupLabel, NSelect, NTooltip } from "naive-ui"; 6 | import { defineComponent, onActivated, onMounted, reactive, ref } from "vue"; 7 | import { useRoute } from "vue-router"; 8 | 9 | export default defineComponent({ 10 | props: {}, 11 | emits: [], 12 | setup: (props, ctx) => { 13 | const route = useRoute(); 14 | const form = reactive({ 15 | circuit: circuits[0].value, 16 | url: "", 17 | }); 18 | const iframeSrc = ref(""); 19 | const iptEl = ref(); 20 | 21 | function handleParse(check = true) { 22 | if (check) { 23 | if (!form.url) { 24 | message.error("请先输入播放地址"); 25 | return; 26 | } 27 | if (!isUrl(form.url)) { 28 | message.error("播放地址输入错误"); 29 | return; 30 | } 31 | } 32 | iframeSrc.value = form.circuit.replace("__URL__", form.url); 33 | } 34 | 35 | onActivated(() => { 36 | iptEl.value?.focus(); 37 | }); 38 | 39 | onMounted(() => { 40 | const { url, circuit } = route.query; 41 | if (url) { 42 | form.url = url as string; 43 | } 44 | if (circuit) { 45 | form.circuit = circuit as string; 46 | } 47 | }); 48 | 49 | return () => ( 50 |
51 |
52 | 53 | 播放地址 54 | { 58 | if (e.key === "Enter") { 59 | e.preventDefault(); 60 | handleParse(); 61 | } 62 | }} 63 | ref={iptEl} 64 | placeholder="请输入播放地址,回车解析" 65 | /> 66 | 67 |
68 |
69 | 70 | 线路节点 71 | { 74 | handleParse(false); 75 | }} 76 | options={circuits} 77 | v-model={[form.circuit, "value"]} 78 | /> 79 | 80 |
81 |
82 | {form.url ? ( 83 | { 88 | handleParse(); 89 | }} 90 | class="mar-b-3-item" 91 | > 92 | 解析 93 | 94 | ) : null} 95 |
96 | {videoList.map(item => ( 97 | 98 | {{ 99 | trigger: () => ( 100 | { 106 | openUrl(item.url); 107 | }} 108 | > 109 | {item.name} 110 | 111 | ), 112 | default: () => item.url, 113 | }} 114 | 115 | ))} 116 |
117 |
118 |
119 | {iframeSrc.value ? ( 120 |
121 | 122 |
123 | ) : null} 124 |
125 |
126 | ); 127 | }, 128 | }); 129 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import NProgress from "nprogress"; 2 | import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"; 3 | 4 | import Index from "@/layout/Index"; 5 | import { filterParams, putStyle } from "@/helper"; 6 | import { setTitle, themeOverrides, visitedPageNum } from "@/service/common"; 7 | 8 | NProgress.inc(0.2); 9 | NProgress.configure({ easing: "ease", speed: 500, showSpinner: false }); 10 | 11 | export const menuRoutes: RouteRecordRaw[] = [ 12 | { 13 | path: "image/compress", 14 | name: "image-compress", 15 | meta: { 16 | title: "图片压缩", 17 | electron: true, 18 | }, 19 | component: () => import("@/page/image/compress"), 20 | }, 21 | { 22 | path: "image/clip", 23 | name: "image-clip", 24 | meta: { 25 | title: "图片裁剪", 26 | }, 27 | component: () => import("@/page/image/clip"), 28 | }, 29 | { 30 | path: "image/watermark", 31 | name: "image-watermark", 32 | meta: { 33 | title: "图片加水印", 34 | }, 35 | component: () => import("@/page/image/watermark"), 36 | }, 37 | { 38 | path: "image/ico", 39 | name: "image-ico", 40 | meta: { 41 | title: "ico生成", 42 | electron: true, 43 | }, 44 | component: () => import("@/page/image/ico"), 45 | }, 46 | { 47 | path: "video/m3u8", 48 | name: "video-m3u8", 49 | meta: { 50 | title: "m3u8下载", 51 | }, 52 | component: () => import("@/page/video/m3u8"), 53 | }, 54 | { 55 | path: "video/parse", 56 | name: "video-parse", 57 | meta: { 58 | title: "视频在线解析", 59 | }, 60 | component: () => import("@/page/video/parse"), 61 | }, 62 | { 63 | path: "developer/regex", 64 | name: "developer-regex", 65 | meta: { 66 | title: "常用正则", 67 | }, 68 | component: () => import("@/page/developer/regex"), 69 | }, 70 | { 71 | path: "developer/translate", 72 | name: "developer-translate", 73 | meta: { 74 | title: "程序变量命名", 75 | electron: true, 76 | }, 77 | component: () => import("@/page/developer/translate"), 78 | }, 79 | // { 80 | // path: "developer/javascript", 81 | // name: "developer-javascript", 82 | // meta: { 83 | // title: "javascript", 84 | // }, 85 | // component: () => import("@/page/developer/javascript"), 86 | // }, 87 | // { 88 | // path: "developer/babel", 89 | // name: "developer-babel", 90 | // meta: { 91 | // title: "babel编译", 92 | // }, 93 | // component: () => import("@/page/developer/babel"), 94 | // }, 95 | // { 96 | // path: "developer/typescript", 97 | // name: "developer-typescript", 98 | // meta: { 99 | // title: "typescript编译", 100 | // }, 101 | // component: () => import("@/page/developer/typescript"), 102 | // }, 103 | // { 104 | // path: "developer/css", 105 | // name: "developer-css", 106 | // meta: { 107 | // title: "css", 108 | // }, 109 | // component: () => import("@/page/developer/css"), 110 | // }, 111 | { 112 | path: "qrcode/create", 113 | name: "qrcode-create", 114 | meta: { 115 | title: "生成二维码", 116 | }, 117 | component: () => import("@/page/qrcode/create"), 118 | }, 119 | { 120 | path: "qrcode/decode", 121 | name: "qrcode-decode", 122 | meta: { 123 | title: "解析二维码", 124 | }, 125 | component: () => import("@/page/qrcode/decode"), 126 | }, 127 | { 128 | path: "util/num-money", 129 | name: "util-num-money", 130 | meta: { 131 | title: "数字转大写金额", 132 | }, 133 | component: () => import("@/page/util/num-money"), 134 | }, 135 | { 136 | path: "util/timestamp-date", 137 | name: "util-timestamp-date", 138 | meta: { 139 | title: "时间戳转日期", 140 | }, 141 | component: () => import("@/page/util/timestamp-date"), 142 | }, 143 | { 144 | path: "util/date-timestamp", 145 | name: "util-date-timestamp", 146 | meta: { 147 | title: "日期转时间戳", 148 | }, 149 | component: () => import("@/page/util/date-timestamp"), 150 | }, 151 | ]; 152 | 153 | export const routes: RouteRecordRaw[] = [ 154 | { 155 | path: "/", 156 | name: "app", 157 | component: Index, 158 | redirect: () => { 159 | return { 160 | name: "index", 161 | }; 162 | }, 163 | children: [ 164 | { 165 | path: "", 166 | name: "index", 167 | meta: {}, 168 | component: () => import("@/page/index"), 169 | }, 170 | ...menuRoutes, 171 | ], 172 | }, 173 | { path: "/:pathMatch(.*)*", name: "NotFound", component: () => import("@/page/error/404") }, 174 | ]; 175 | 176 | const router = createRouter({ 177 | history: createWebHistory(), 178 | routes, 179 | }); 180 | 181 | router.beforeEach(to => { 182 | // 设置body样式 183 | const style = putStyle({ "--primary-color": themeOverrides.value.common?.primaryColor }); 184 | document.body.setAttribute("style", style); 185 | 186 | // 参数过滤 187 | filterParams(to.query); 188 | filterParams(to.params); 189 | 190 | NProgress.done().start(); 191 | 192 | setTitle(to.meta.title as string); 193 | 194 | return true; 195 | }); 196 | 197 | const initHistoryLen = history.length; 198 | router.afterEach(() => { 199 | visitedPageNum.value = history.length - initHistoryLen; 200 | NProgress.done(); 201 | }); 202 | 203 | export default router; 204 | -------------------------------------------------------------------------------- /src/service/common.ts: -------------------------------------------------------------------------------- 1 | import config from "@/config"; 2 | import { computed, ref } from "vue"; 3 | import { ConfigProviderProps, createDiscreteApi, darkTheme, GlobalTheme, GlobalThemeOverrides, useOsTheme } from "naive-ui"; 4 | import { localStorage } from "@/helper/storage"; 5 | import { random } from "@/helper"; 6 | 7 | const os = useOsTheme(); 8 | 9 | // 返回顶部按钮 10 | export const isShowBackTop = ref(false); 11 | 12 | // 设置 13 | export const settingOpen = ref(false); 14 | 15 | // history页数 16 | export const visitedPageNum = ref(0); 17 | 18 | export const enum ThemeTypes { 19 | OS = "os", 20 | LIGHT = "light", 21 | DARK = "dark", 22 | } 23 | export const themeTypes = [ 24 | { 25 | label: "跟随系统", 26 | key: ThemeTypes.OS, 27 | }, 28 | { 29 | label: "亮色", 30 | key: ThemeTypes.LIGHT, 31 | }, 32 | { 33 | label: "暗色", 34 | key: ThemeTypes.DARK, 35 | }, 36 | ]; 37 | 38 | export const enum ThemeColors { 39 | RED = "red", 40 | GREEN = "green", 41 | BLUE = "blue", 42 | YELLOW = "yellow", 43 | PURPLE = "purple", 44 | } 45 | export const themeColors = [ 46 | { 47 | label: "红色", 48 | key: ThemeColors.RED, 49 | color: "#f5222d", 50 | hoverColor: "#ff3b45", 51 | pressedColor: "#e01a24", 52 | }, 53 | { 54 | label: "绿色", 55 | key: ThemeColors.GREEN, 56 | color: "#18a058", 57 | hoverColor: "#24b86a", 58 | pressedColor: "#1a7f4a", 59 | }, 60 | { 61 | label: "蓝色", 62 | key: ThemeColors.BLUE, 63 | color: "#2f54eb", 64 | hoverColor: "#4e6ef2", 65 | pressedColor: "#2e49ba", 66 | }, 67 | { 68 | label: "黄色", 69 | key: ThemeColors.YELLOW, 70 | color: "#dcbf00", 71 | hoverColor: "#dbc433", 72 | pressedColor: "#c1aa14", 73 | }, 74 | { 75 | label: "紫色", 76 | key: ThemeColors.PURPLE, 77 | color: "#722ed1", 78 | hoverColor: "#934af8", 79 | pressedColor: "#5a1fab", 80 | }, 81 | ]; 82 | 83 | export const themeOverrides = computed(() => { 84 | const themeColor = themeColors.find(v => v.key === appConfig.value.themeColor); 85 | const common: GlobalThemeOverrides["common"] = { fontSize: "16px" }; 86 | if (themeColor) { 87 | common.primaryColor = themeColor.color; 88 | common.primaryColorHover = themeColor.hoverColor; 89 | common.primaryColorPressed = themeColor.pressedColor; 90 | common.primaryColorSuppl = themeColor.pressedColor; 91 | } 92 | 93 | return { common }; 94 | }); 95 | 96 | // 个性化配置 97 | export interface IConfig { 98 | // 主题 99 | themeType: ThemeTypes; 100 | themeColor: ThemeColors; 101 | os: "dark" | "light" | null; 102 | } 103 | 104 | export const defaultConfig: IConfig = { 105 | themeType: ThemeTypes.OS, 106 | themeColor: ThemeColors.GREEN, 107 | os: os.value, 108 | }; 109 | let localConfig = localStorage.get("appConfig") || defaultConfig; 110 | if (typeof localConfig === "string" || Array.isArray(localConfig)) { 111 | localConfig = defaultConfig; 112 | } 113 | export const appConfig = ref(defaultConfig); 114 | export function setAppConfig(params: Partial) { 115 | if (params.themeType !== undefined) { 116 | const value = params.themeType; 117 | if (themeTypes.some(v => v.key === value)) { 118 | appConfig.value.themeType = value; 119 | } 120 | } 121 | if (params.themeColor !== undefined) { 122 | const value = params.themeColor; 123 | if (themeColors.some(v => v.key === value)) { 124 | appConfig.value.themeColor = value; 125 | } 126 | } 127 | localStorage.set("appConfig", appConfig.value); 128 | } 129 | setAppConfig(localConfig); 130 | 131 | export const globalTheme = computed(() => { 132 | if (appConfig.value.themeType === ThemeTypes.DARK || (appConfig.value.themeType === ThemeTypes.OS && appConfig.value.os === "dark")) { 133 | return darkTheme; 134 | } 135 | return null; 136 | }); 137 | 138 | // 窗口宽度 139 | export const windowWidth = ref(window.innerWidth); 140 | export const isMobileWidth = computed(() => { 141 | return windowWidth.value < 640; 142 | }); 143 | 144 | // 菜单 145 | export const menuCollapsed = ref(isMobileWidth.value); 146 | window.addEventListener("resize", () => { 147 | windowWidth.value = window.innerWidth; 148 | menuCollapsed.value = isMobileWidth.value; 149 | }); 150 | 151 | // 弹框 152 | const configProviderPropsRef = computed(() => ({ 153 | theme: globalTheme.value, 154 | })); 155 | const discrete = createDiscreteApi(["message", "dialog", "notification"], { 156 | configProviderProps: configProviderPropsRef, 157 | }); 158 | export const message = discrete.message; 159 | export const notification = discrete.notification; 160 | export const dialog = discrete.dialog; 161 | 162 | // 设置标题 163 | export const setTitle = (title: string) => { 164 | title = title || config.productName; 165 | document.title = title; 166 | if (config.isElectron) { 167 | electronAPI?.setTitle(title); 168 | } 169 | }; 170 | 171 | // 设置进度条 172 | export function loadingProgressBar(duration = 3 * 1000) { 173 | const interval = 10; 174 | const step = 100 / (duration / interval); 175 | let progress = 0; 176 | const timer = setInterval(() => { 177 | progress = progress + step; 178 | if (progress > 99) { 179 | progress = 99; 180 | clearInterval(timer); 181 | } 182 | electronAPI.setProgressBar(progress / 100); 183 | }, interval); 184 | return () => { 185 | clearInterval(timer); 186 | electronAPI.setProgressBar(0); 187 | }; 188 | } 189 | -------------------------------------------------------------------------------- /src/service/devoloper.ts: -------------------------------------------------------------------------------- 1 | import { TypeItem } from "@/config/type"; 2 | 3 | export const regList: { title: string; code: string }[] = [ 4 | { 5 | title: "邮箱", 6 | code: "^([A-Za-z0-9_\\-\\.])+\\@([A-Za-z0-9_\\-\\.])+\\.([A-Za-z]{2,4})$", 7 | }, 8 | { 9 | title: "手机号", 10 | code: "^[1]([3-9])[0-9]{9}$", 11 | }, 12 | { 13 | title: "固定电话", 14 | code: "(\\(\\d{3,4}\\)|\\d{3,4}-|\\s)?\\d{8}", 15 | }, 16 | { 17 | title: "车牌号", 18 | code: "^([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Za-z]{1}[A-Za-z]{1}(([0-9]{5}[DFdf])|([DFdf]([A-HJ-NP-Za-hj-np-z0-9])[0-9]{4})))|([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Za-z]{1}[A-Za-z]{1}[A-HJ-NP-Za-hj-np-z0-9]{4}[A-HJ-NP-Za-hj-np-z0-9挂学警港澳]{1})$", 19 | }, 20 | { 21 | title: "身份证号", 22 | code: "^[1-9]\\d{5}(18|19|([23]\\d))\\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\\d{3}[0-9Xx]$", 23 | }, 24 | { 25 | title: "域名", 26 | code: "^((http:\\/\\/)|(https:\\/\\/))?([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,6}(\\/)", 27 | }, 28 | { 29 | title: "IP地址", 30 | code: "((?:(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d))", 31 | }, 32 | { 33 | title: "汉字", 34 | code: "^[\\u4e00-\\u9fa5]{0,}$", 35 | }, 36 | ]; 37 | 38 | // 变量名类型 39 | export const enum VariableType { 40 | HUMP, // 驼峰 41 | LINE, // 中横线 42 | UNDERLINE, // 下划线 43 | } 44 | export const variableTypeList: TypeItem[] = [ 45 | { 46 | text: "驼峰命名", 47 | value: VariableType.HUMP, 48 | }, 49 | { 50 | text: "中横线命名", 51 | value: VariableType.LINE, 52 | }, 53 | { 54 | text: "下划线命名", 55 | value: VariableType.UNDERLINE, 56 | }, 57 | ]; 58 | 59 | export const colors = ["yellow", "#ff4848", "blue", "#18a058", "purple", "#8a2be2", "#ff69b4"]; 60 | -------------------------------------------------------------------------------- /src/service/qrcode.ts: -------------------------------------------------------------------------------- 1 | import Db from "@/lib/db"; 2 | import { ref } from "vue"; 3 | 4 | export const logoOpts = { 5 | radius: 6, 6 | border: 4, 7 | bgColor: "#fff", 8 | width: 54, 9 | }; 10 | 11 | const tableName = "logo"; 12 | let db: Db; 13 | 14 | export interface ILogoItem { 15 | id: number; 16 | url: string; 17 | createdAt: number; 18 | } 19 | 20 | // logo列表 21 | export const logoList = ref([]); 22 | 23 | // 获取logo列表 24 | export async function getLogoList() { 25 | if (!db) { 26 | db = await new Db().open(tableName); 27 | } 28 | logoList.value = await db.findAll(tableName); 29 | return logoList.value; 30 | } 31 | 32 | // 添加logo 33 | export async function addLogo(url: string) { 34 | if (!db) { 35 | db = await new Db().open(tableName); 36 | } 37 | await db.insert(tableName, { url }); 38 | await getLogoList(); 39 | return logoList.value; 40 | } 41 | 42 | // 删除logo 43 | export async function deleteLogo(id: number) { 44 | if (!db) { 45 | db = await new Db().open(tableName); 46 | } 47 | await db.delete(tableName, id); 48 | await getLogoList(); 49 | return logoList.value; 50 | } 51 | -------------------------------------------------------------------------------- /src/service/util.ts: -------------------------------------------------------------------------------- 1 | // 时间类型 2 | export const enum DateType { 3 | S = "s", 4 | MS = "ms", 5 | } 6 | 7 | // 数字转大写金额 8 | export function digitUppercase(n: number) { 9 | const fraction = ["角", "分"]; 10 | const digit = ["零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"]; 11 | const unit = [ 12 | ["元", "万", "亿"], 13 | ["", "拾", "佰", "仟"], 14 | ]; 15 | const head = n < 0 ? "欠" : ""; 16 | n = Math.abs(n); 17 | let s = ""; 18 | for (let i = 0; i < fraction.length; i++) { 19 | s += (digit[Math.floor(n * 10 * Math.pow(10, i)) % 10] + fraction[i]).replace(/零./, ""); 20 | } 21 | s = s || "整"; 22 | n = Math.floor(n); 23 | for (let i = 0; i < unit[0].length && n > 0; i++) { 24 | let p = ""; 25 | for (let j = 0; j < unit[1].length && n > 0; j++) { 26 | p = digit[n % 10] + unit[1][j] + p; 27 | n = Math.floor(n / 10); 28 | } 29 | s = p.replace(/(零.)*零$/, "").replace(/^$/, "零") + unit[0][i] + s; 30 | } 31 | return ( 32 | head + 33 | s 34 | .replace(/(零.)*零元/, "元") 35 | .replace(/(零.)+/g, "零") 36 | .replace(/^整$/, "零元整") 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/service/video.ts: -------------------------------------------------------------------------------- 1 | import { TypeItem } from "@/config/type"; 2 | import { Type } from "naive-ui/es/button/src/interface"; 3 | 4 | export const enum DownloadStatus { 5 | WAITING, 6 | DOWNLOADING, 7 | FINISHED, 8 | ERROR, 9 | } 10 | export const downloadStatusList: TypeItem[] = [ 11 | { 12 | text: "等待下载", 13 | value: DownloadStatus.WAITING, 14 | color: "default", 15 | }, 16 | { 17 | text: "下载中", 18 | value: DownloadStatus.DOWNLOADING, 19 | color: "info", 20 | }, 21 | { 22 | text: "下载完成", 23 | value: DownloadStatus.FINISHED, 24 | color: "success", 25 | }, 26 | { 27 | text: "下载失败", 28 | value: DownloadStatus.ERROR, 29 | color: "error", 30 | }, 31 | ]; 32 | 33 | export interface ITsItem { 34 | status: DownloadStatus; 35 | src: string; 36 | m3u8Src: string; 37 | name: string; 38 | filePath: string; 39 | file?: Uint8Array; 40 | duration: number; 41 | } 42 | export interface IM3u8Item { 43 | status: DownloadStatus; 44 | src: string; 45 | name: string; 46 | filePath: string; 47 | percentage: number; 48 | total: number; 49 | doneNum: number; 50 | duration: number; 51 | } 52 | 53 | export const circuits: { label: string; value: string }[] = [ 54 | { 55 | label: "vip解析", 56 | value: "https://www.ckmov.vip/api.php?url=__URL__", 57 | }, 58 | { 59 | label: "云解析", 60 | value: "https://jx.aidouer.net/?url=__URL__", 61 | }, 62 | ]; 63 | export const videoList: { name: string; url: string; type: Type }[] = [ 64 | { 65 | name: "爱奇艺", 66 | url: "https://www.iqiyi.com/", 67 | type: "primary", 68 | }, 69 | { 70 | name: "腾讯视频", 71 | url: "https://v.qq.com/", 72 | type: "info", 73 | }, 74 | { 75 | name: "优酷视频", 76 | url: "https://youku.com/", 77 | type: "success", 78 | }, 79 | { 80 | name: "芒果TV", 81 | url: "https://www.mgtv.com/", 82 | type: "warning", 83 | }, 84 | { 85 | name: "哔哩哔哩", 86 | url: "https://www.bilibili.com/", 87 | type: "error", 88 | }, 89 | ]; 90 | -------------------------------------------------------------------------------- /src/static/image/error-404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hellowmonkey-site/tool-box/9451816a1ef23f3916eee67942f055b90f96afcc/src/static/image/error-404.png -------------------------------------------------------------------------------- /src/static/image/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hellowmonkey-site/tool-box/9451816a1ef23f3916eee67942f055b90f96afcc/src/static/image/logo.png -------------------------------------------------------------------------------- /src/static/image/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hellowmonkey-site/tool-box/9451816a1ef23f3916eee67942f055b90f96afcc/src/static/image/success.png -------------------------------------------------------------------------------- /src/static/style/animate.scss: -------------------------------------------------------------------------------- 1 | @keyframes ani-fade { 2 | 0% { 3 | opacity: 0; 4 | } 5 | 6 | 100% { 7 | opacity: 1; 8 | } 9 | } 10 | 11 | @keyframes ani-zoom { 12 | 0% { 13 | transform: scale(0); 14 | } 15 | 16 | 100% { 17 | transform: scale(1); 18 | } 19 | } 20 | 21 | .fade-enter-active, 22 | .fade-leave-active { 23 | transition: all $animate-time ease-in-out; 24 | } 25 | 26 | .fade-enter-from, 27 | .fade-leave-to { 28 | opacity: 0; 29 | } 30 | 31 | .slider-left-enter-active, 32 | .slider-left-leave-active { 33 | transition: transform $animate-time ease-in-out; 34 | } 35 | 36 | .slider-left-enter-from, 37 | .slider-left-leave-to { 38 | transform: translate(100%, 0); 39 | } 40 | 41 | .slider-right-enter-active, 42 | .slider-right-leave-active { 43 | transition: transform $animate-time ease-in-out; 44 | } 45 | 46 | .slider-right-leave-to, 47 | .slider-right-enter-from { 48 | transform: translate(100%, 0); 49 | } 50 | 51 | .slider-bottom-enter-active, 52 | .slider-bottom-leave-active { 53 | transition: all $animate-time ease-in-out; 54 | } 55 | 56 | .slider-bottom-enter-from, 57 | .slider-bottom-leave-to { 58 | opacity: 0; 59 | transform: translate(0, -100%); 60 | } 61 | 62 | .slider-top-enter-active, 63 | .slider-top-leave-active { 64 | transition: all $animate-time ease-in-out; 65 | } 66 | 67 | .slider-top-enter-from, 68 | .slider-top-leave-to { 69 | opacity: 0; 70 | transform: translate(0, 100%); 71 | } 72 | 73 | .ani-zoom { 74 | animation: ani-zoom $animate-time ease-in-out; 75 | } 76 | 77 | .ani-fade { 78 | animation: ani-fade $animate-time ease-in-out; 79 | } 80 | 81 | .ani { 82 | transition: all $animate-time ease-in-out; 83 | } 84 | -------------------------------------------------------------------------------- /src/static/style/app.scss: -------------------------------------------------------------------------------- 1 | @import "~/normalize.css/normalize.css"; 2 | @import "~/reset-css/reset.css"; 3 | @import "~/nprogress/nprogress.css"; 4 | @import "./var.scss"; 5 | @import "./mixin.scss"; 6 | @import "./font.scss"; 7 | @import "./flex.scss"; 8 | @import "./common.scss"; 9 | @import "./animate.scss"; 10 | 11 | #nprogress .bar { 12 | background: var(--primary-color); 13 | } 14 | 15 | #nprogress .spinner-icon { 16 | border-left-color: var(--primary-color); 17 | border-top-color: var(--primary-color); 18 | } 19 | 20 | #nprogress .peg { 21 | box-shadow: 0 0 20px var(--primary-color), 0 0 15px var(--primary-color); 22 | } 23 | 24 | .logo { 25 | height: 40px; 26 | } 27 | 28 | a { 29 | text-decoration: none; 30 | } 31 | 32 | .content { 33 | width: 95%; 34 | max-width: 1300px; 35 | margin: 20px auto; 36 | } 37 | 38 | .box { 39 | height: 210px; 40 | transition: transform $animate-time ease-in-out; 41 | z-index: 0; 42 | position: relative; 43 | 44 | &:hover { 45 | z-index: 1; 46 | border-color: var(--primary-color); 47 | transform: scale(1.08); 48 | box-shadow: 0 0 10px -3px var(--primary-color); 49 | } 50 | } 51 | 52 | .logo-box { 53 | width: 80px; 54 | height: 80px; 55 | border-radius: 5px; 56 | overflow: hidden; 57 | position: relative; 58 | 59 | .logo-delete-btn { 60 | position: absolute; 61 | right: 0; 62 | bottom: 0; 63 | } 64 | } 65 | 66 | .item-label { 67 | font-size: var(--font-size-base); 68 | margin-right: var(--offset-4); 69 | width: 100px; 70 | text-align: right; 71 | letter-spacing: 2px; 72 | 73 | &::after { 74 | content: ":"; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/static/style/common.scss: -------------------------------------------------------------------------------- 1 | /* stylelint-disable at-rule-no-unknown */ 2 | * { 3 | box-sizing: border-box; 4 | } 5 | 6 | ul { 7 | padding: 0; 8 | margin-bottom: 0; 9 | } 10 | 11 | html { 12 | color: var(--text-color); 13 | } 14 | 15 | @include offset(1, var(--offset-1)); 16 | @include offset(2, var(--offset-2)); 17 | @include offset(3, var(--offset-3)); 18 | @include offset(4, var(--offset-4)); 19 | @include offset(5, var(--offset-5)); 20 | @include offset(6, var(--offset-6)); 21 | @include offset(7, var(--offset-7)); 22 | @include offset(0, 0); 23 | 24 | a { 25 | color: inherit; 26 | 27 | &:hover, 28 | &:active { 29 | color: var(--primary-color); 30 | } 31 | } 32 | 33 | .full-width { 34 | width: 100%; 35 | } 36 | 37 | .half-width { 38 | width: 50%; 39 | } 40 | 41 | .full-height { 42 | height: 100%; 43 | } 44 | 45 | .half-height { 46 | height: 50%; 47 | } 48 | 49 | .full-width-vw { 50 | width: 100vw; 51 | } 52 | 53 | .full-height-vh { 54 | height: 100vh; 55 | } 56 | 57 | .d-node { 58 | display: none; 59 | } 60 | 61 | .d-block { 62 | display: block; 63 | } 64 | 65 | .d-table { 66 | display: table; 67 | } 68 | 69 | .d-inline { 70 | display: inline; 71 | } 72 | 73 | .d-inline-block { 74 | display: inline-block; 75 | } 76 | 77 | .d-initial { 78 | display: initial; 79 | } 80 | 81 | .d-inherit { 82 | display: inherit; 83 | } 84 | 85 | .visibile-hidden { 86 | visibility: hidden; 87 | } 88 | 89 | .visibile-visible { 90 | visibility: visible; 91 | } 92 | 93 | .overflow-hidden { 94 | overflow: hidden; 95 | } 96 | 97 | .overflow-visible { 98 | overflow: visible; 99 | } 100 | 101 | .overflow-auto { 102 | overflow: auto; 103 | } 104 | 105 | .overflow-scroll { 106 | overflow: scroll; 107 | } 108 | 109 | .overflow-x-hidden { 110 | overflow-x: hidden; 111 | } 112 | 113 | .overflow-x-visible { 114 | overflow-x: visible; 115 | } 116 | 117 | .overflow-x-auto { 118 | overflow-x: auto; 119 | } 120 | 121 | .overflow-x-scroll { 122 | overflow-x: scroll; 123 | } 124 | 125 | .overflow-y-hidden { 126 | overflow-y: hidden; 127 | } 128 | 129 | .overflow-y-visible { 130 | overflow-y: visible; 131 | } 132 | 133 | .overflow-y-auto { 134 | overflow-y: auto; 135 | } 136 | 137 | .overflow-y-scroll { 138 | overflow-y: scroll; 139 | } 140 | 141 | .box-border { 142 | box-sizing: border-box; 143 | } 144 | 145 | .box-content { 146 | box-sizing: content-box; 147 | } 148 | 149 | .pos-abs { 150 | position: absolute; 151 | } 152 | 153 | .pos-rel { 154 | position: relative; 155 | } 156 | 157 | .pos-fixed { 158 | position: fixed; 159 | } 160 | 161 | .pos-static { 162 | position: static; 163 | } 164 | 165 | .pos-sticky { 166 | position: sticky; 167 | } 168 | 169 | .pos-v-center { 170 | top: 50%; 171 | transform: translateY(-50%); 172 | } 173 | 174 | .pos-h-center { 175 | left: 50%; 176 | transform: translateX(-50%); 177 | } 178 | 179 | .pos-center { 180 | left: 50%; 181 | top: 50%; 182 | transform: translate(-50%, -50%); 183 | } 184 | 185 | .float-left { 186 | float: left; 187 | } 188 | 189 | .float-right { 190 | float: right; 191 | } 192 | 193 | .float-none { 194 | float: none; 195 | } 196 | 197 | .space-nowrap { 198 | white-space: nowrap; 199 | } 200 | 201 | .space-pre { 202 | white-space: pre; 203 | } 204 | 205 | .space-pre-line { 206 | white-space: pre-line; 207 | } 208 | 209 | .space-pre-wrap { 210 | white-space: pre-wrap; 211 | } 212 | 213 | .break-all { 214 | word-break: break-all; 215 | } 216 | 217 | .break-normal { 218 | word-break: normal; 219 | } 220 | 221 | .break-word { 222 | word-break: break-word; 223 | } 224 | 225 | .text-left { 226 | text-align: left; 227 | } 228 | 229 | .text-right { 230 | text-align: right; 231 | } 232 | 233 | .text-center { 234 | text-align: center; 235 | } 236 | 237 | .text-end { 238 | text-align: end; 239 | } 240 | 241 | .text-start { 242 | text-align: start; 243 | } 244 | 245 | .text-lowercase { 246 | text-transform: lowercase; 247 | } 248 | 249 | .text-uppercase { 250 | text-transform: uppercase; 251 | } 252 | 253 | .text-underline { 254 | text-decoration: underline; 255 | } 256 | 257 | .text-through { 258 | text-decoration: line-through; 259 | } 260 | 261 | .text-none { 262 | text-decoration: none; 263 | } 264 | 265 | .text-dashed { 266 | text-decoration: dashed; 267 | } 268 | 269 | .text-dotted { 270 | text-decoration: dotted; 271 | } 272 | 273 | .text-double { 274 | text-decoration: double; 275 | } 276 | 277 | .text-overline { 278 | text-decoration: overline; 279 | } 280 | 281 | .text-solid { 282 | text-decoration: solid; 283 | } 284 | 285 | .text-wavy { 286 | text-decoration: wavy; 287 | } 288 | 289 | .text-indent-1 { 290 | text-indent: 1em; 291 | } 292 | 293 | .text-indent-2 { 294 | text-indent: 2em; 295 | } 296 | 297 | .text-justify { 298 | text-align: justify; 299 | text-justify: distribute-all-lines; 300 | text-align-last: justify; 301 | } 302 | 303 | .text-elip { 304 | text-overflow: ellipsis; 305 | overflow: hidden; 306 | white-space: nowrap; 307 | } 308 | 309 | .text-elip-2 { 310 | overflow: hidden; 311 | text-overflow: ellipsis; 312 | display: flex; 313 | -webkit-line-clamp: 2; 314 | overflow: hidden; 315 | -webkit-box-orient: vertical; 316 | } 317 | 318 | .text-elip-3 { 319 | overflow: hidden; 320 | text-overflow: ellipsis; 321 | display: flex; 322 | -webkit-line-clamp: 3; 323 | overflow: hidden; 324 | -webkit-box-orient: vertical; 325 | } 326 | 327 | .font-bold { 328 | font-weight: bold; 329 | } 330 | 331 | .font-bolder { 332 | font-weight: bolder; 333 | } 334 | 335 | .font-lighter { 336 | font-weight: lighter; 337 | } 338 | 339 | .font-italic { 340 | font-style: italic; 341 | } 342 | 343 | .font-oblique { 344 | font-style: oblique; 345 | } 346 | 347 | .font-normal { 348 | font-weight: normal; 349 | font-style: normal; 350 | } 351 | 352 | .font-base { 353 | font-size: var(--font-size-base); 354 | } 355 | 356 | .font-large { 357 | font-size: var(--font-size-large); 358 | } 359 | 360 | .font-xlg { 361 | font-size: var(--font-size-xlg); 362 | } 363 | 364 | .font-small { 365 | font-size: var(--font-size-small); 366 | } 367 | 368 | .font-mini { 369 | font-size: var(--font-size-mini); 370 | } 371 | 372 | .font-light { 373 | color: #fff; 374 | } 375 | 376 | .font-gray { 377 | color: var(--gray-color); 378 | } 379 | 380 | .font-dark { 381 | color: var(--text-color); 382 | } 383 | 384 | .font-disabled { 385 | color: var(--text-disabled-color); 386 | } 387 | 388 | .font-primary { 389 | color: var(--primary-color); 390 | } 391 | 392 | .font-second { 393 | color: var(--second-color); 394 | } 395 | 396 | .font-info { 397 | color: var(--info-color); 398 | } 399 | 400 | .font-success { 401 | color: var(--success-color); 402 | } 403 | 404 | .font-warning { 405 | color: var(--warning-color); 406 | } 407 | 408 | .font-error { 409 | color: var(--error-color); 410 | } 411 | 412 | .bg-full-widht { 413 | background-size: 100% auto; 414 | } 415 | 416 | .bg-full-height { 417 | background-size: auto 100%; 418 | } 419 | 420 | .bg-cover, 421 | .bg-contain { 422 | background-position: center; 423 | background-repeat: no-repeat; 424 | } 425 | 426 | .bg-cover { 427 | background-size: cover; 428 | } 429 | 430 | .bg-contain { 431 | background-size: contain; 432 | } 433 | 434 | .bg-position-center { 435 | background-position: center; 436 | } 437 | 438 | .bg-position-bottom { 439 | background-position: bottom; 440 | } 441 | 442 | .bg-position-left { 443 | background-position: left; 444 | } 445 | 446 | .bg-position-right { 447 | background-position: right; 448 | } 449 | 450 | .bg-position-top { 451 | background-position: top; 452 | } 453 | 454 | .bg-repeat-no { 455 | background-repeat: no-repeat; 456 | } 457 | 458 | .bg-repeat { 459 | background-repeat: repeat; 460 | } 461 | 462 | .bg-repeat-round { 463 | background-repeat: round; 464 | } 465 | 466 | .bg-repeat-space { 467 | background-repeat: space; 468 | } 469 | 470 | .bg-repeat-x { 471 | background-repeat: repeat-x; 472 | } 473 | 474 | .bg-repeat-y { 475 | background-repeat: repeat-y; 476 | } 477 | 478 | .bg-light { 479 | background-color: #fff; 480 | } 481 | 482 | .bg-gray { 483 | background-color: var(--gray-color); 484 | } 485 | 486 | .bg-dark { 487 | background-color: var(--text-color); 488 | } 489 | 490 | .bg-disabled { 491 | background-color: var(--text-disabled-color); 492 | } 493 | 494 | .bg-primary { 495 | background-color: var(--primary-color); 496 | } 497 | 498 | .bg-second { 499 | background-color: var(--second-color); 500 | } 501 | 502 | .bg-info { 503 | background-color: var(--info-color); 504 | } 505 | 506 | .bg-success { 507 | background-color: var(--success-color); 508 | } 509 | 510 | .bg-warning { 511 | background-color: var(--warning-color); 512 | } 513 | 514 | .bg-error { 515 | background-color: var(--error-color); 516 | } 517 | 518 | .bg-transparent { 519 | background-color: transparent; 520 | } 521 | 522 | .object-cover { 523 | object-fit: cover; 524 | } 525 | 526 | .object-fill { 527 | object-fit: fill; 528 | } 529 | 530 | .object-contain { 531 | object-fit: contain; 532 | } 533 | 534 | .object-none { 535 | object-fit: none; 536 | } 537 | 538 | .object-scale-down { 539 | object-fit: scale-down; 540 | } 541 | 542 | .border { 543 | border: 1PX solid var(--border-color); 544 | } 545 | 546 | .border-top { 547 | border-top: 1PX solid var(--border-color); 548 | } 549 | 550 | .border-right { 551 | border-right: 1PX solid var(--border-color); 552 | } 553 | 554 | .border-bottom { 555 | border-bottom: 1PX solid var(--border-color); 556 | } 557 | 558 | .border-left { 559 | border-left: 1PX solid var(--border-color); 560 | } 561 | 562 | .border-1 { 563 | border-width: 1PX; 564 | } 565 | 566 | .border-2 { 567 | border-width: 2PX; 568 | } 569 | 570 | .border-3 { 571 | border-width: 3PX; 572 | } 573 | 574 | .border-4 { 575 | border-width: 4PX; 576 | } 577 | 578 | .border-5 { 579 | border-width: 5PX; 580 | } 581 | 582 | .border-dashed { 583 | border-style: dashed; 584 | } 585 | 586 | .border-dotted { 587 | border-style: dotted; 588 | } 589 | 590 | .border-double { 591 | border-style: double; 592 | } 593 | 594 | .border-groove { 595 | border-style: groove; 596 | } 597 | 598 | .border-inset { 599 | border-style: inset; 600 | } 601 | 602 | .border-outset { 603 | border-style: outset; 604 | } 605 | 606 | .border-ridge { 607 | border-style: ridge; 608 | } 609 | 610 | .border-solid { 611 | border-style: solid; 612 | } 613 | 614 | .border-light { 615 | border-color: #fff; 616 | } 617 | 618 | .border-gray { 619 | border-color: var(--gray-color); 620 | } 621 | 622 | .border-dark { 623 | border-color: var(--text-color); 624 | } 625 | 626 | .border-disabled { 627 | border-color: var(--text-disabled-color); 628 | } 629 | 630 | .border-primary { 631 | border-color: var(--primary-color); 632 | } 633 | 634 | .border-second { 635 | border-color: var(--second-color); 636 | } 637 | 638 | .border-info { 639 | border-color: var(--info-color); 640 | } 641 | 642 | .border-success { 643 | border-color: var(--success-color); 644 | } 645 | 646 | .border-warning { 647 | border-color: var(--warning-color); 648 | } 649 | 650 | .border-error { 651 | border-color: var(--error-color); 652 | } 653 | 654 | .border-none { 655 | border: none; 656 | } 657 | 658 | .border-radius-1 { 659 | border-radius: var(--offset-1); 660 | } 661 | 662 | .border-radius-2 { 663 | border-radius: var(--offset-2); 664 | } 665 | 666 | .border-radius-3 { 667 | border-radius: var(--offset-3); 668 | } 669 | 670 | .border-radius-4 { 671 | border-radius: var(--offset-4); 672 | } 673 | 674 | .border-radius-5 { 675 | border-radius: var(--offset-5); 676 | } 677 | 678 | .border-radius-6 { 679 | border-radius: var(--offset-6); 680 | } 681 | 682 | .border-radius-7 { 683 | border-radius: var(--offset-7); 684 | } 685 | 686 | .border-radius-circle { 687 | border-radius: 50%; 688 | } 689 | 690 | // 透明度 691 | .opacity-0 { 692 | opacity: 0; 693 | } 694 | 695 | .opacity-1 { 696 | opacity: 0.1; 697 | } 698 | 699 | .opacity-2 { 700 | opacity: 0.2; 701 | } 702 | 703 | .opacity-3 { 704 | opacity: 0.3; 705 | } 706 | 707 | .opacity-4 { 708 | opacity: 0.4; 709 | } 710 | 711 | .opacity-5 { 712 | opacity: 0.5; 713 | } 714 | 715 | .opacity-6 { 716 | opacity: 0.6; 717 | } 718 | 719 | .opacity-7 { 720 | opacity: 0.7; 721 | } 722 | 723 | .opacity-8 { 724 | opacity: 0.8; 725 | } 726 | 727 | .opacity-9 { 728 | opacity: 0.9; 729 | } 730 | 731 | .opacity-10 { 732 | opacity: 1; 733 | } 734 | 735 | .clearfix::after { 736 | display: table; 737 | height: 0; 738 | content: ""; 739 | clear: both; 740 | } 741 | 742 | .cursor-pointer { 743 | cursor: pointer; 744 | } 745 | 746 | .cursor-default { 747 | cursor: default; 748 | } 749 | 750 | .cursor-help { 751 | cursor: help; 752 | } 753 | 754 | .cursor-move { 755 | cursor: move; 756 | } 757 | 758 | .cursor-copy { 759 | cursor: copy; 760 | } 761 | 762 | .cursor-cell { 763 | cursor: cell; 764 | } 765 | 766 | .cursor-none { 767 | cursor: none; 768 | } 769 | 770 | .cursor-not-allowed { 771 | cursor: not-allowed; 772 | } 773 | 774 | .cursor-text { 775 | cursor: text; 776 | } 777 | 778 | .cursor-wait { 779 | cursor: wait; 780 | } 781 | 782 | .cursor-progress { 783 | cursor: progress; 784 | } 785 | 786 | /** 滚动条 **/ 787 | @media (min-width: #{$mobile-breakpoint}PX) { 788 | ::-webkit-scrollbar { 789 | width: 8PX; 790 | height: 8PX; 791 | } 792 | 793 | ::-webkit-scrollbar-track, 794 | ::-webkit-scrollbar-corner { 795 | background-color: rgba($color: #000, $alpha: 0); 796 | border-radius: 8PX; 797 | } 798 | 799 | ::-webkit-scrollbar-thumb { 800 | border-radius: 8PX; 801 | background-color: rgba($color: #000, $alpha: 0.2); 802 | } 803 | 804 | ::-webkit-scrollbar-button:vertical { 805 | display: none; 806 | } 807 | 808 | ::-webkit-scrollbar-track:hover, 809 | ::-webkit-scrollbar-corner:hover { 810 | background-color: rgba($color: #000, $alpha: 0.2); 811 | } 812 | 813 | ::-webkit-scrollbar-thumb:vertical:active { 814 | background-color: rgba($color: #000, $alpha: 0.35); 815 | } 816 | } 817 | 818 | .slider::-webkit-scrollbar { 819 | width: 0; 820 | height: 0; 821 | } 822 | -------------------------------------------------------------------------------- /src/static/style/flex.scss: -------------------------------------------------------------------------------- 1 | .d-flex-inline { 2 | display: inline-flex; 3 | } 4 | 5 | .d-flex { 6 | display: flex; 7 | 8 | &.direction-row { 9 | flex-direction: row; 10 | 11 | &.reverse { 12 | flex-direction: row-reverse; 13 | } 14 | } 15 | 16 | &.direction-column { 17 | flex-direction: column; 18 | 19 | &.reverse { 20 | flex-direction: column-reverse; 21 | } 22 | } 23 | 24 | &.wrap-nowrap { 25 | flex-wrap: nowrap; 26 | } 27 | 28 | &.wrap { 29 | flex-wrap: wrap; 30 | 31 | &.reverse { 32 | flex-wrap: wrap-reverse; 33 | } 34 | } 35 | 36 | &.justify-start { 37 | justify-content: flex-start; 38 | } 39 | 40 | &.justify-end { 41 | justify-content: flex-end; 42 | } 43 | 44 | &.justify-center { 45 | justify-content: center; 46 | } 47 | 48 | &.justify-between { 49 | justify-content: space-between; 50 | } 51 | 52 | &.justify-around { 53 | justify-content: space-around; 54 | } 55 | 56 | &.align-items-start { 57 | align-items: flex-start; 58 | } 59 | 60 | &.align-items-end { 61 | align-items: flex-end; 62 | } 63 | 64 | &.align-items-center { 65 | align-items: center; 66 | } 67 | 68 | &.align-items-baseline { 69 | align-items: baseline; 70 | } 71 | 72 | &.align-items-stretch { 73 | align-items: stretch; 74 | } 75 | 76 | &.align-content-start { 77 | align-content: flex-start; 78 | } 79 | 80 | &.align-content-end { 81 | align-content: flex-end; 82 | } 83 | 84 | &.align-content-center { 85 | align-content: center; 86 | } 87 | 88 | &.align-content-between { 89 | align-content: space-between; 90 | } 91 | 92 | &.align-content-around { 93 | align-content: space-around; 94 | } 95 | 96 | &.align-content-stretch { 97 | align-content: stretch; 98 | } 99 | 100 | & > .flex-item-extend { 101 | flex: 1; 102 | } 103 | 104 | & > .flex-item-auto { 105 | flex: auto; 106 | } 107 | 108 | & > .flex-item-none { 109 | flex: none; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/static/style/font.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "iconfont"; /* Project id 1809945 */ 3 | src: 4 | url("//at.alicdn.com/t/font_1809945_0jju6x1aywpd.woff2?t=1636257350853") format("woff2"), 5 | url("//at.alicdn.com/t/font_1809945_0jju6x1aywpd.woff?t=1636257350853") format("woff"), 6 | url("//at.alicdn.com/t/font_1809945_0jju6x1aywpd.ttf?t=1636257350853") format("truetype"); 7 | } 8 | 9 | .iconfont { 10 | font-family: "iconfont" !important; 11 | font-style: normal; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | -------------------------------------------------------------------------------- /src/static/style/mixin.scss: -------------------------------------------------------------------------------- 1 | @mixin offset($unit, $offset) { 2 | .pos-t-#{$unit} { 3 | top: $offset; 4 | } 5 | 6 | .pos-r-#{$unit} { 7 | right: $offset; 8 | } 9 | 10 | .pos-b-#{$unit} { 11 | bottom: $offset; 12 | } 13 | 14 | .pos-l-#{$unit} { 15 | left: $offset; 16 | } 17 | 18 | .pad-#{$unit} { 19 | padding: $offset; 20 | } 21 | 22 | .pad-v-#{$unit} { 23 | padding-top: $offset; 24 | padding-bottom: $offset; 25 | } 26 | 27 | .pad-h-#{$unit} { 28 | padding-left: $offset; 29 | padding-right: $offset; 30 | } 31 | 32 | .pad-t-#{$unit} { 33 | padding-top: $offset; 34 | } 35 | 36 | .pad-r-#{$unit} { 37 | padding-right: $offset; 38 | } 39 | 40 | .pad-b-#{$unit} { 41 | padding-bottom: $offset; 42 | } 43 | 44 | .pad-l-#{$unit} { 45 | padding-left: $offset; 46 | } 47 | 48 | .mar-#{$unit} { 49 | margin: $offset; 50 | } 51 | 52 | .mar-v-#{$unit} { 53 | margin-top: $offset; 54 | margin-bottom: $offset; 55 | } 56 | 57 | .mar-h-#{$unit} { 58 | margin-left: $offset; 59 | margin-right: $offset; 60 | } 61 | 62 | .mar-t-#{$unit} { 63 | margin-top: $offset; 64 | } 65 | 66 | .mar-r-#{$unit} { 67 | margin-right: $offset; 68 | } 69 | 70 | .mar-b-#{$unit} { 71 | margin-bottom: $offset; 72 | } 73 | 74 | .mar-l-#{$unit} { 75 | margin-left: $offset; 76 | } 77 | 78 | .border-t-#{$unit} { 79 | border-top: 1PX solid var(--border-color); 80 | padding-top: $offset; 81 | margin-top: $offset; 82 | } 83 | 84 | .border-r-#{$unit} { 85 | border-right: 1PX solid var(--border-color); 86 | padding-right: $offset; 87 | margin-right: $offset; 88 | } 89 | 90 | .border-b-#{$unit} { 91 | border-bottom: 1PX solid var(--border-color); 92 | padding-bottom: $offset; 93 | margin-bottom: $offset; 94 | } 95 | 96 | .border-l-#{$unit} { 97 | border-left: 1PX solid var(--border-color); 98 | padding-left: $offset; 99 | margin-left: $offset; 100 | } 101 | 102 | * { 103 | &>.pad-t-#{$unit}-item:not(:last-child) { 104 | padding-top: $offset; 105 | } 106 | 107 | &>.pad-r-#{$unit}-item:not(:last-child) { 108 | padding-right: $offset; 109 | } 110 | 111 | &>.pad-b-#{$unit}-item:not(:last-child) { 112 | padding-bottom: $offset; 113 | } 114 | 115 | &>.pad-l-#{$unit}-item:not(:last-child) { 116 | padding-left: $offset; 117 | } 118 | 119 | &>.mar-t-#{$unit}-item:not(:last-child) { 120 | margin-top: $offset; 121 | } 122 | 123 | &>.mar-r-#{$unit}-item:not(:last-child) { 124 | margin-right: $offset; 125 | } 126 | 127 | &>.mar-b-#{$unit}-item:not(:last-child) { 128 | margin-bottom: $offset; 129 | } 130 | 131 | &>.mar-l-#{$unit}-item:not(:last-child) { 132 | margin-left: $offset; 133 | } 134 | 135 | &>.border-t-#{$unit}-item:not(:last-child) { 136 | border-top: 1PX solid var(--border-color); 137 | padding-top: $offset; 138 | margin-top: $offset; 139 | } 140 | 141 | &>.border-r-#{$unit}-item:not(:last-child) { 142 | border-right: 1PX solid var(--border-color); 143 | padding-right: $offset; 144 | margin-right: $offset; 145 | } 146 | 147 | &>.border-b-#{$unit}-item:not(:last-child) { 148 | border-bottom: 1PX solid var(--border-color); 149 | padding-bottom: $offset; 150 | margin-bottom: $offset; 151 | } 152 | 153 | &>.border-l-#{$unit}-item:not(:last-child) { 154 | border-left: 1PX solid var(--border-color); 155 | padding-left: $offset; 156 | margin-left: $offset; 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/static/style/var.scss: -------------------------------------------------------------------------------- 1 | $mobile-breakpoint: 750; 2 | $mobile-font-size-base: 26; 3 | $pc-font-size-base: 16; 4 | $ratio: calc($mobile-font-size-base / $pc-font-size-base); 5 | $animate-time: 0.2s; 6 | 7 | :root { 8 | --font-size-base: #{$pc-font-size-base}PX; 9 | --font-size-large: #{$pc-font-size-base + 2}PX; 10 | --font-size-xlg: #{$pc-font-size-base + 4}PX; 11 | --font-size-small: #{$pc-font-size-base - 2}PX; 12 | --font-size-mini: #{$pc-font-size-base - 4}PX; 13 | --offset-1: 2PX; 14 | --offset-2: 5PX; 15 | --offset-3: 10PX; 16 | --offset-4: 15PX; 17 | --offset-5: 20PX; 18 | --offset-6: 25PX; 19 | --offset-7: 30PX; 20 | --opacity-disabled: 0.9; 21 | --text-disabled-color: #969696; 22 | --page-background-color: #f7f7f7; 23 | --border-color: #d9d9d9; 24 | --gray-color: #999; 25 | --text-color: #333; 26 | } 27 | 28 | html { 29 | font-size: var(--font-size-base); 30 | } 31 | 32 | // @media (max-width: #{$mobile-breakpoint}PX) { 33 | // :root { 34 | // --font-size-base: #{$mobile-font-size-base}px; 35 | // --font-size-large: #{($pc-font-size-base + 2) * $ratio}px; 36 | // --font-size-xlg: #{($pc-font-size-base + 4) * $ratio}px; 37 | // --font-size-small: #{($pc-font-size-base - 2) * $ratio}px; 38 | // --font-size-mini: #{($pc-font-size-base - 4) * $ratio}px; 39 | // --offset-1: #{2 * $ratio}px; 40 | // --offset-2: #{5 * $ratio}px; 41 | // --offset-3: #{10 * $ratio}px; 42 | // --offset-4: #{15 * $ratio}px; 43 | // --offset-5: #{20 * $ratio}px; 44 | // --offset-6: #{25 * $ratio}px; 45 | // --offset-7: #{30 * $ratio}px; 46 | // } 47 | 48 | // html { 49 | // font-size: calc(100vw / $mobile-breakpoint * $mobile-font-size-base); 50 | // } 51 | // } 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "lib": ["esnext", "dom"], 13 | "baseUrl": ".", 14 | "paths": { 15 | "@/*": ["./src/*"], 16 | "~/*": ["./node_modules/*"] 17 | }, 18 | "allowSyntheticDefaultImports": true, 19 | "strictNullChecks": true 20 | }, 21 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "electron/**/*.ts"], 22 | "references": [{ "path": "./tsconfig.node.json" }] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import vueJsx from "@vitejs/plugin-vue-jsx"; 4 | import { join } from "path"; 5 | import { copySync, removeSync } from "fs-extra"; 6 | import electron from "vite-plugin-electron"; 7 | 8 | const isElectron = process.env.MODE === "electron"; 9 | const isDev = process.env.NODE_ENV === "development"; 10 | 11 | // https://vitejs.dev/config/ 12 | export default defineConfig({ 13 | plugins: [ 14 | vue(), 15 | vueJsx(), 16 | isElectron 17 | ? { 18 | name: "init", 19 | buildStart() { 20 | removeSync(join(__dirname, "dist/electron")); 21 | copySync(join(__dirname, "electron/resource"), "dist/electron/resource"); 22 | copySync(join(__dirname, "electron/data"), "dist/electron/data"); 23 | }, 24 | } 25 | : null, 26 | isElectron 27 | ? electron({ 28 | main: { 29 | entry: "electron/main/index.ts", 30 | vite: { 31 | build: { 32 | outDir: "dist/electron/main", 33 | minify: false, 34 | }, 35 | }, 36 | }, 37 | preload: { 38 | input: { 39 | index: join(__dirname, "electron/preload/index.ts"), 40 | }, 41 | vite: { 42 | build: { 43 | sourcemap: "inline", 44 | outDir: "dist/electron/preload", 45 | minify: false, 46 | }, 47 | }, 48 | }, 49 | }) 50 | : null, 51 | ], 52 | server: { 53 | host: "0.0.0.0", 54 | port: 3030, 55 | }, 56 | resolve: { 57 | alias: { 58 | "@": "/src", 59 | "~": "/node_modules", 60 | }, 61 | }, 62 | build: { 63 | outDir: "dist/build", 64 | }, 65 | }); 66 | --------------------------------------------------------------------------------