feat: 初始化纯前端在线工具站
- Vite + Vue3 + Pinia + TypeScript + UnoCSS + TDesign 技术栈 - 左侧固定导航 + 暗黑/浅色主题 + PC/移动端响应式布局 - 14 个工具:图片压缩/裁剪、二维码、JSON、时间戳、Base64、 JWT、Cron、进制转换、哈希、身份证、地址解析、URL 解析、OCR - 本地历史记录(localStorage)与通用复制/下载/错误提示组件 - 所有文件处理均在浏览器本地完成,不上传服务器
This commit is contained in:
commit
e3c1534263
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# 日志与临时文件
|
||||
*.log
|
||||
*.local
|
||||
.vite/
|
||||
.tmp/
|
||||
.DS_Store
|
||||
|
||||
# 编辑器
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
23
index.html
Normal file
23
index.html
Normal file
@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta
|
||||
name="description"
|
||||
content="纯前端在线工具箱:图片压缩/裁剪、二维码、JSON 格式化、时间戳、Base64、JWT、Cron、进制转换、哈希、身份证、地址、URL 解析、OCR 等 14 个实用工具,所有数据仅在浏览器本地处理。"
|
||||
/>
|
||||
<title>在线工具箱 · Tool</title>
|
||||
<!-- 防闪烁:应用挂载前先应用本地保存的主题(TDesign 依赖 html[theme-mode] 属性) -->
|
||||
<script>
|
||||
;(function () {
|
||||
var theme = localStorage.getItem('tool-theme') || 'light'
|
||||
document.documentElement.setAttribute('theme-mode', theme)
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
package.json
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "tool.xpcool.com",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "纯前端在线工具站:Vite + Vue3 + Pinia + TypeScript + UnoCSS + TDesign,所有数据在浏览器本地处理",
|
||||
"packageManager": "pnpm@10.12.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit -p tsconfig.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"cron-parser": "^4.9.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
"jsqr": "^1.4.0",
|
||||
"pinia": "^3.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"spark-md5": "^3.0.2",
|
||||
"tdesign-icons-vue-next": "^0.3.4",
|
||||
"tdesign-vue-next": "^1.10.6",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"tesseract.js-core": "^5.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/spark-md5": "^3.0.5",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"sass": "^1.83.0",
|
||||
"typescript": "~5.7.2",
|
||||
"unocss": "^0.65.4",
|
||||
"vite": "^6.0.7",
|
||||
"vue-tsc": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
2804
pnpm-lock.yaml
generated
Normal file
2804
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
src/App.vue
Normal file
8
src/App.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 根组件:仅承载路由出口。
|
||||
// 布局由 MainLayout(路由父组件)统一提供,包含侧边导航与主题切换。
|
||||
</script>
|
||||
69
src/assets/styles/index.scss
Normal file
69
src/assets/styles/index.scss
Normal file
@ -0,0 +1,69 @@
|
||||
/* ============================================================
|
||||
* 全局样式
|
||||
* 颜色一律使用 TDesign 设计令牌 CSS 变量(--td-*),
|
||||
* 当 html[theme-mode='dark'] 时自动切换为暗黑主题配色。
|
||||
* ============================================================ */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
html[theme-mode='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--td-bg-color-page);
|
||||
color: var(--td-text-color-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 4px;
|
||||
}
|
||||
html[theme-mode='dark'] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 工具页通用区块间距 */
|
||||
.tool-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 结果展示区:长文本自动换行 */
|
||||
.result-area {
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 页面标题与描述 */
|
||||
.page-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.page-desc {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
35
src/components/common/CopyButton.vue
Normal file
35
src/components/common/CopyButton.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<t-button
|
||||
variant="outline"
|
||||
size="small"
|
||||
theme="primary"
|
||||
:disabled="!props.text"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<template #icon><CopyIcon /></template>
|
||||
{{ props.label }}
|
||||
</t-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CopyIcon } from 'tdesign-icons-vue-next'
|
||||
import { copyText } from '@/composables/useClipboard'
|
||||
|
||||
/**
|
||||
* 通用复制按钮:点击后复制指定文本并弹出成功提示。
|
||||
* 各工具页面的“复制结果”按钮统一使用本组件。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 要复制的文本内容 */
|
||||
text: string
|
||||
/** 按钮文案 */
|
||||
label?: string
|
||||
}>(),
|
||||
{ label: '复制结果' },
|
||||
)
|
||||
|
||||
async function handleCopy() {
|
||||
if (props.text) await copyText(props.text)
|
||||
}
|
||||
</script>
|
||||
41
src/components/common/DownloadButton.vue
Normal file
41
src/components/common/DownloadButton.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<t-button
|
||||
variant="outline"
|
||||
size="small"
|
||||
theme="success"
|
||||
:disabled="!hasBlob"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<template #icon><DownloadIcon /></template>
|
||||
{{ props.label }}
|
||||
</t-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { DownloadIcon } from 'tdesign-icons-vue-next'
|
||||
import { downloadBlob } from '@/composables/useDownload'
|
||||
|
||||
/**
|
||||
* 通用下载按钮:通过 getBlob 延迟获取要下载的内容(Blob)。
|
||||
* 所有下载均在浏览器本地完成,不经过服务器。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 下载文件名 */
|
||||
filename: string
|
||||
/** 获取要下载的 Blob;返回 null 时按钮禁用 */
|
||||
getBlob: () => Blob | null
|
||||
/** 按钮文案 */
|
||||
label?: string
|
||||
}>(),
|
||||
{ label: '下载' },
|
||||
)
|
||||
|
||||
const hasBlob = computed(() => !!props.getBlob())
|
||||
|
||||
function handleDownload() {
|
||||
const blob = props.getBlob()
|
||||
if (blob) downloadBlob(blob, props.filename)
|
||||
}
|
||||
</script>
|
||||
32
src/components/common/ErrorAlert.vue
Normal file
32
src/components/common/ErrorAlert.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<t-alert
|
||||
v-if="props.message"
|
||||
class="error-alert"
|
||||
theme="error"
|
||||
:message="props.message"
|
||||
close
|
||||
@close="emit('close')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用错误提示组件:各工具页面校验失败时统一展示。
|
||||
* 通过 message 传入错误文本,点击关闭或调用 clear 均可隐藏。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 错误信息;为空时不渲染 */
|
||||
message: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 用户点击关闭按钮 */
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
110
src/components/common/HistoryPanel.vue
Normal file
110
src/components/common/HistoryPanel.vue
Normal file
@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div v-if="props.items.length" class="history-panel">
|
||||
<div class="history-head">
|
||||
<span class="history-title">历史记录</span>
|
||||
<t-button variant="text" size="small" theme="danger" @click="emit('clear')">清空</t-button>
|
||||
</div>
|
||||
<ul class="history-list">
|
||||
<li
|
||||
v-for="(item, index) in props.items"
|
||||
:key="`${item.time}-${index}`"
|
||||
class="history-item"
|
||||
:title="`点击恢复到输入框`"
|
||||
@click="emit('select', item.text)"
|
||||
>
|
||||
<span class="history-text">{{ item.text }}</span>
|
||||
<span class="history-time">{{ formatTime(item.time) }}</span>
|
||||
<t-button
|
||||
variant="text"
|
||||
size="small"
|
||||
theme="danger"
|
||||
class="history-del"
|
||||
@click.stop="emit('remove', index)"
|
||||
>
|
||||
<template #icon><DeleteIcon /></template>
|
||||
</t-button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DeleteIcon } from 'tdesign-icons-vue-next'
|
||||
import type { HistoryItem } from '@/composables/useHistory'
|
||||
import { formatBeijing } from '@/utils'
|
||||
|
||||
/**
|
||||
* 通用历史记录面板:展示某工具的本地历史,支持点击恢复、单条删除、一键清空。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
items: HistoryItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 点击某条历史,回传内容(由页面恢复到输入框) */
|
||||
select: [text: string]
|
||||
/** 删除指定下标的历史 */
|
||||
remove: [index: number]
|
||||
/** 清空全部历史 */
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
/** 将时间戳格式化为北京时间短格式 */
|
||||
function formatTime(ts: number): string {
|
||||
return formatBeijing(new Date(ts)).slice(5, 19)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-panel {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.history-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.history-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
border-radius: 6px;
|
||||
background: var(--td-bg-color-container);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.history-item:hover {
|
||||
border-color: var(--td-brand-color);
|
||||
}
|
||||
.history-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.history-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
.history-del {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
35
src/composables/useClipboard.ts
Normal file
35
src/composables/useClipboard.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板。
|
||||
* 优先使用现代 Clipboard API,非安全上下文(如 http 局域网调试)回退到
|
||||
* textarea + execCommand 方案,保证移动端 H5 同样可用。
|
||||
*
|
||||
* @returns 是否复制成功
|
||||
*/
|
||||
export async function copyText(text: string, tip = '已复制到剪贴板'): Promise<boolean> {
|
||||
if (!text) {
|
||||
MessagePlugin.warning('暂无可复制的内容')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
// 回退方案
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
MessagePlugin.success(tip)
|
||||
return true
|
||||
} catch {
|
||||
MessagePlugin.error('复制失败,请手动复制')
|
||||
return false
|
||||
}
|
||||
}
|
||||
33
src/composables/useDownload.ts
Normal file
33
src/composables/useDownload.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 浏览器端文件下载工具函数。
|
||||
* 所有 Blob 均生成于本地内存,通过临时 <a> 标签触发下载,
|
||||
* 全程不经过任何服务器。
|
||||
*/
|
||||
|
||||
/** 下载 Blob 内容 */
|
||||
export function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
// 延迟释放对象 URL,避免部分浏览器在点击完成前回收
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
/** 下载文本内容 */
|
||||
export function downloadText(text: string, filename: string, mime = 'text/plain;charset=utf-8') {
|
||||
downloadBlob(new Blob([text], { type: mime }), filename)
|
||||
}
|
||||
|
||||
/** 下载 DataURL(如 canvas 导出的图片) */
|
||||
export function downloadDataUrl(dataUrl: string, filename: string) {
|
||||
const a = document.createElement('a')
|
||||
a.href = dataUrl
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
73
src/composables/useHistory.ts
Normal file
73
src/composables/useHistory.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface HistoryItem {
|
||||
/** 历史内容(各工具自定义,通常是可恢复的输入文本) */
|
||||
text: string
|
||||
/** 记录时间戳 */
|
||||
time: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用本地历史记录 Composable。
|
||||
* 每个工具使用独立的 key(建议与路由 path 一致),
|
||||
* 数据保存在 localStorage 中,刷新页面不丢失。
|
||||
*
|
||||
* @param key 工具唯一标识
|
||||
* @param max 最多保留条数,默认 10
|
||||
*/
|
||||
export function useHistory(key: string, max = 10) {
|
||||
const storageKey = `tool-history:${key}`
|
||||
// 使用 reactive 数组:模板中可直接传给 HistoryPanel,无需 .value 解包
|
||||
const items = reactive<HistoryItem[]>(load())
|
||||
|
||||
function load(): HistoryItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
const list = raw ? (JSON.parse(raw) as HistoryItem[]) : []
|
||||
return Array.isArray(list) ? list : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(items.slice(0, max)))
|
||||
} catch {
|
||||
// localStorage 配额已满时静默忽略,不影响工具主流程
|
||||
}
|
||||
}
|
||||
|
||||
/** 新增一条历史:文本去重,最新在前 */
|
||||
function add(text: string) {
|
||||
const t = text.trim()
|
||||
if (!t) return
|
||||
items.unshift({ text: t, time: Date.now() })
|
||||
// 去重并裁剪到上限
|
||||
const seen = new Set<string>()
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (seen.has(items[i].text)) {
|
||||
items.splice(i, 1)
|
||||
i--
|
||||
} else {
|
||||
seen.add(items[i].text)
|
||||
}
|
||||
}
|
||||
if (items.length > max) items.splice(max)
|
||||
save()
|
||||
}
|
||||
|
||||
/** 删除指定位置的历史 */
|
||||
function remove(index: number) {
|
||||
items.splice(index, 1)
|
||||
save()
|
||||
}
|
||||
|
||||
/** 清空历史 */
|
||||
function clear() {
|
||||
items.splice(0, items.length)
|
||||
save()
|
||||
}
|
||||
|
||||
return { items, add, remove, clear }
|
||||
}
|
||||
7
src/env.d.ts
vendored
Normal file
7
src/env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// UnoCSS 虚拟模块声明
|
||||
declare module 'virtual:uno.css'
|
||||
|
||||
// 若某些第三方库缺少类型声明,可在此处补充,例如:
|
||||
// declare module 'cron-parser'
|
||||
176
src/layout/MainLayout.vue
Normal file
176
src/layout/MainLayout.vue
Normal file
@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<!-- 顶部栏:移动端菜单按钮 + Logo + 主题切换 -->
|
||||
<header class="app-header">
|
||||
<div class="header-inner">
|
||||
<t-button variant="text" class="menu-btn" aria-label="打开导航" @click="drawerVisible = true">
|
||||
<template #icon><MenuIcon /></template>
|
||||
</t-button>
|
||||
|
||||
<div class="logo" @click="router.push('/')">
|
||||
<span class="logo-icon">
|
||||
<AppIcon />
|
||||
</span>
|
||||
<span class="logo-text">在线工具箱</span>
|
||||
</div>
|
||||
|
||||
<t-button
|
||||
variant="outline"
|
||||
shape="circle"
|
||||
:title="theme.mode === 'light' ? '切换到暗黑模式' : '切换到浅色模式'"
|
||||
@click="theme.toggle()"
|
||||
>
|
||||
<template #icon>
|
||||
<MoonIcon v-if="theme.mode === 'light'" />
|
||||
<SunnyIcon v-else />
|
||||
</template>
|
||||
</t-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="body-row">
|
||||
<!-- PC 端:左侧固定导航 -->
|
||||
<aside class="app-aside">
|
||||
<SideNav />
|
||||
</aside>
|
||||
|
||||
<!-- 主体内容区域 -->
|
||||
<main class="app-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">纯前端处理 · 数据不出浏览器 · 不上传服务器</footer>
|
||||
|
||||
<!-- 移动端:抽屉式导航 -->
|
||||
<t-drawer
|
||||
v-model:visible="drawerVisible"
|
||||
placement="left"
|
||||
:size="240"
|
||||
:footer="false"
|
||||
:header="false"
|
||||
:close-btn="true"
|
||||
>
|
||||
<SideNav @navigate="drawerVisible = false" />
|
||||
</t-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { MenuIcon, MoonIcon, SunnyIcon, AppIcon } from 'tdesign-icons-vue-next'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import SideNav from './SideNav.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const theme = useThemeStore()
|
||||
|
||||
/** 移动端抽屉导航显隐 */
|
||||
const drawerVisible = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------- 顶部栏 ---------- */
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 56px;
|
||||
background: var(--td-bg-color-container);
|
||||
border-bottom: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
.header-inner {
|
||||
height: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--td-brand-color), #36b37e);
|
||||
}
|
||||
.logo-text {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.menu-btn {
|
||||
display: none;
|
||||
}
|
||||
.header-inner > :last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---------- 主体 ---------- */
|
||||
.body-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
/* 左侧固定导航(PC 端) */
|
||||
.app-aside {
|
||||
width: 224px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
height: calc(100vh - 56px);
|
||||
overflow-y: auto;
|
||||
background: var(--td-bg-color-container);
|
||||
border-right: 1px solid var(--td-border-level-1-color);
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
border-top: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
|
||||
/* ---------- 响应式:移动端 H5 ---------- */
|
||||
@media (max-width: 767px) {
|
||||
.app-aside {
|
||||
display: none;
|
||||
}
|
||||
.menu-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
.app-content {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
.logo-text {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
src/layout/SideNav.vue
Normal file
79
src/layout/SideNav.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<nav class="side-nav">
|
||||
<router-link to="/" class="nav-item" :class="{ active: route.path === '/' }" @click="emit('navigate')">
|
||||
<HomeIcon class="nav-icon" />
|
||||
<span>首页</span>
|
||||
</router-link>
|
||||
|
||||
<div class="nav-group-title">全部工具</div>
|
||||
|
||||
<router-link
|
||||
v-for="tool in tools"
|
||||
:key="tool.path"
|
||||
:to="`/${tool.path}`"
|
||||
class="nav-item"
|
||||
:class="{ active: route.path === `/${tool.path}` }"
|
||||
@click="emit('navigate')"
|
||||
>
|
||||
<component :is="tool.icon" class="nav-icon" />
|
||||
<span>{{ tool.name }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { HomeIcon } from 'tdesign-icons-vue-next'
|
||||
import { tools } from '@/router/tools'
|
||||
|
||||
/**
|
||||
* 侧边导航:数据源为工具注册表 tools.ts。
|
||||
* PC 端渲染在左侧固定栏,移动端渲染在抽屉内(点击后由父组件关闭抽屉)。
|
||||
*/
|
||||
const route = useRoute()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 点击菜单项后通知父组件(移动端用于关闭抽屉) */
|
||||
navigate: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.side-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.nav-group-title {
|
||||
padding: 8px 12px 4px;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--td-brand-color-light);
|
||||
color: var(--td-brand-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
23
src/main.ts
Normal file
23
src/main.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import TDesign from 'tdesign-vue-next'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// 样式:TDesign 组件样式 + UnoCSS 原子类 + 全局自定义样式
|
||||
import 'tdesign-vue-next/es/style/index.css'
|
||||
import 'virtual:uno.css'
|
||||
import './assets/styles/index.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
// 全量注册 TDesign 组件(工具站体积不敏感,全量注册开发效率最高)
|
||||
app.use(TDesign)
|
||||
|
||||
// 主题初始化:与 index.html 内联脚本保持一致(读取本地存储,刷新不丢失)
|
||||
const savedTheme = localStorage.getItem('tool-theme') || 'light'
|
||||
document.documentElement.setAttribute('theme-mode', savedTheme)
|
||||
|
||||
app.mount('#app')
|
||||
46
src/router/index.ts
Normal file
46
src/router/index.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import MainLayout from '@/layout/MainLayout.vue'
|
||||
import { tools } from './tools'
|
||||
|
||||
/**
|
||||
* 路由约定:每个工具对应一个独立路由,路径与 tools.ts 中的 path 一致。
|
||||
* 所有工具页面均嵌套在 MainLayout 下,共享侧边导航与主题切换。
|
||||
* 未匹配路径统一重定向到首页。
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
meta: { title: '首页' },
|
||||
},
|
||||
// 由工具注册表自动生成路由,无需手动维护
|
||||
...tools.map((t) => ({
|
||||
path: t.path,
|
||||
name: t.path,
|
||||
component: t.component,
|
||||
meta: { title: t.name },
|
||||
})),
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
// HTML5 History 模式(部署到 Nginx 等需配置 try_files 回退,见 README)
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 页面切换后同步更新浏览器标题
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta.title as string | undefined) ?? '首页'
|
||||
document.title = `${title} · 在线工具箱`
|
||||
})
|
||||
|
||||
export default router
|
||||
140
src/router/tools.ts
Normal file
140
src/router/tools.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
ImageIcon,
|
||||
FrameIcon,
|
||||
QrcodeIcon,
|
||||
CodeIcon,
|
||||
TimeIcon,
|
||||
FileIcon,
|
||||
LockOnIcon,
|
||||
CalendarIcon,
|
||||
CalculatorIcon,
|
||||
FingerprintIcon,
|
||||
CreditcardIcon,
|
||||
LocationIcon,
|
||||
LinkIcon,
|
||||
BrowseIcon,
|
||||
} from 'tdesign-icons-vue-next'
|
||||
|
||||
/**
|
||||
* 工具注册表:网站内所有工具的“唯一数据源”。
|
||||
* 侧边导航、首页卡片、路由表均由此文件生成。
|
||||
*
|
||||
* ⚙️ 新增工具只需三步:
|
||||
* 1. 在 src/views/<工具名>/ 下创建页面组件(可参考现有工具页面);
|
||||
* 2. 在本文件的 tools 数组中追加一条配置(path/name/desc/icon/component);
|
||||
* 3. 无需修改路由文件、首页与侧边栏,它们会自动渲染新工具。
|
||||
*/
|
||||
export interface ToolItem {
|
||||
/** 路由路径(相对根路径,不含前导斜杠) */
|
||||
path: string
|
||||
/** 工具名称(菜单、卡片、页面标题使用) */
|
||||
name: string
|
||||
/** 一句话描述(首页卡片展示) */
|
||||
desc: string
|
||||
/** 菜单/卡片图标(TDesign 图标组件) */
|
||||
icon: Component
|
||||
/** 页面组件(懒加载,减小首屏体积) */
|
||||
component: () => Promise<Component>
|
||||
}
|
||||
|
||||
export const tools: ToolItem[] = [
|
||||
{
|
||||
path: 'image-compress',
|
||||
name: '图片压缩',
|
||||
desc: '本地压缩 JPG/PNG/WebP,可调质量,压缩后直接下载',
|
||||
icon: ImageIcon,
|
||||
component: () => import('@/views/image-compress/ImageCompressView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'image-cropper',
|
||||
name: '图片裁剪',
|
||||
desc: '按比例裁剪图片,实时预览,导出 JPG/PNG',
|
||||
icon: FrameIcon,
|
||||
component: () => import('@/views/image-cropper/ImageCropperView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'qrcode',
|
||||
name: '二维码生成解析',
|
||||
desc: '文本/链接生成二维码,上传图片解析二维码内容',
|
||||
icon: QrcodeIcon,
|
||||
component: () => import('@/views/qrcode/QrcodeView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'json-format',
|
||||
name: 'JSON 格式化',
|
||||
desc: 'JSON 格式化、压缩、校验,一键复制结果',
|
||||
icon: CodeIcon,
|
||||
component: () => import('@/views/json-format/JsonFormatView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'timestamp',
|
||||
name: '时间戳转换',
|
||||
desc: '时间戳与北京时间互转,支持秒/毫秒与批量转换',
|
||||
icon: TimeIcon,
|
||||
component: () => import('@/views/timestamp/TimestampView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'base64',
|
||||
name: 'Base64 编解码',
|
||||
desc: '文本 Base64 编解码,图片文件与 Base64 互转',
|
||||
icon: FileIcon,
|
||||
component: () => import('@/views/base64/Base64View.vue'),
|
||||
},
|
||||
{
|
||||
path: 'jwt-parse',
|
||||
name: 'JWT 解析',
|
||||
desc: '解析 JWT 的 Header 与 Payload,展示过期时间',
|
||||
icon: LockOnIcon,
|
||||
component: () => import('@/views/jwt-parse/JwtParseView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'cron',
|
||||
name: 'Cron 表达式',
|
||||
desc: '解析 Cron 下次执行时间,可视化生成表达式',
|
||||
icon: CalendarIcon,
|
||||
component: () => import('@/views/cron/CronView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'radix-convert',
|
||||
name: '进制转换',
|
||||
desc: '二进制、八进制、十进制、十六进制互转',
|
||||
icon: CalculatorIcon,
|
||||
component: () => import('@/views/radix-convert/RadixConvertView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'hash',
|
||||
name: '哈希计算',
|
||||
desc: '文本/文件计算 MD5、SHA1、SHA256、SHA512',
|
||||
icon: FingerprintIcon,
|
||||
component: () => import('@/views/hash/HashView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'idcard',
|
||||
name: '身份证解析',
|
||||
desc: '校验身份证合法性,解析生日、性别、年龄、地区',
|
||||
icon: CreditcardIcon,
|
||||
component: () => import('@/views/idcard/IdcardView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'address-parse',
|
||||
name: '地址解析',
|
||||
desc: '拆分中文地址的省、市、区与详细地址',
|
||||
icon: LocationIcon,
|
||||
component: () => import('@/views/address-parse/AddressParseView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'url-parse',
|
||||
name: 'URL 解析',
|
||||
desc: '解析协议/域名/路径/参数,也支持构造 URL',
|
||||
icon: LinkIcon,
|
||||
component: () => import('@/views/url-parse/UrlParseView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'ocr',
|
||||
name: '图片 OCR 识别',
|
||||
desc: '本地识别图片文字,支持中英文(WASM)',
|
||||
icon: BrowseIcon,
|
||||
component: () => import('@/views/ocr/OcrView.vue'),
|
||||
},
|
||||
]
|
||||
30
src/stores/theme.ts
Normal file
30
src/stores/theme.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ThemeMode = 'light' | 'dark'
|
||||
|
||||
/** 主题本地存储 key(与 index.html 内联脚本保持一致) */
|
||||
const STORAGE_KEY = 'tool-theme'
|
||||
|
||||
/**
|
||||
* 主题状态管理:暗黑/浅色主题切换。
|
||||
* TDesign 通过 html 元素的 `theme-mode` 属性控制主题,
|
||||
* 这里同步该属性并持久化到 localStorage,刷新页面不丢失。
|
||||
*/
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<ThemeMode>((localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'light')
|
||||
|
||||
/** 应用主题:同步 DOM 属性 + 持久化 */
|
||||
function apply(next: ThemeMode) {
|
||||
mode.value = next
|
||||
document.documentElement.setAttribute('theme-mode', next)
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
}
|
||||
|
||||
/** 切换主题 */
|
||||
function toggle() {
|
||||
apply(mode.value === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
return { mode, apply, toggle }
|
||||
})
|
||||
80
src/utils/index.ts
Normal file
80
src/utils/index.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/** 通用工具函数集合 */
|
||||
|
||||
/** 读取 File 为 DataURL(base64 形式) */
|
||||
export function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
/** 读取 File 为 ArrayBuffer */
|
||||
export function fileToArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
/** DataURL 转 Blob */
|
||||
export function dataUrlToBlob(dataUrl: string): Blob {
|
||||
const [meta, b64 = ''] = dataUrl.split(',')
|
||||
const mime = /data:(.*?)(;|$)/.exec(meta)?.[1] || 'application/octet-stream'
|
||||
const bin = atob(b64)
|
||||
const bytes = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||
return new Blob([bytes], { type: mime })
|
||||
}
|
||||
|
||||
/** ArrayBuffer 转十六进制字符串(用于展示 Web Crypto 摘要) */
|
||||
export function bufferToHex(buffer: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** 字节数格式化为可读文本 */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const units = ['KB', 'MB', 'GB']
|
||||
let v = bytes
|
||||
let i = -1
|
||||
do {
|
||||
v /= 1024
|
||||
i++
|
||||
} while (v >= 1024 && i < units.length - 1)
|
||||
return `${v.toFixed(2)} ${units[i]}`
|
||||
}
|
||||
|
||||
/** 生成带时间戳的文件名 */
|
||||
export function genFilename(prefix: string, ext: string): string {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
||||
return `${prefix}_${stamp}.${ext}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Date 格式化为北京时间字符串(与本地时区无关)。
|
||||
* @param withMs 是否携带毫秒
|
||||
*/
|
||||
export function formatBeijing(d: Date, withMs = false): string {
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(d)
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ''
|
||||
const base = `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`
|
||||
return withMs ? `${base}.${String(d.getMilliseconds()).padStart(3, '0')}` : base
|
||||
}
|
||||
130
src/views/HomeView.vue
Normal file
130
src/views/HomeView.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<!-- 站点头部介绍 -->
|
||||
<div class="hero">
|
||||
<h1 class="hero-title">在线工具箱</h1>
|
||||
<p class="hero-desc">
|
||||
14 个常用工具,全部在浏览器本地完成计算与文件处理,不上传任何数据到服务器。
|
||||
</p>
|
||||
<div class="hero-badges">
|
||||
<t-tag theme="success" variant="light">纯前端</t-tag>
|
||||
<t-tag theme="primary" variant="light">数据不出浏览器</t-tag>
|
||||
<t-tag theme="warning" variant="light">免费使用</t-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具卡片导航 -->
|
||||
<div class="tool-grid">
|
||||
<router-link
|
||||
v-for="tool in tools"
|
||||
:key="tool.path"
|
||||
:to="`/${tool.path}`"
|
||||
class="tool-card"
|
||||
>
|
||||
<span class="tool-icon">
|
||||
<component :is="tool.icon" />
|
||||
</span>
|
||||
<span class="tool-name">{{ tool.name }}</span>
|
||||
<span class="tool-desc">{{ tool.desc }}</span>
|
||||
<span class="tool-arrow">
|
||||
<ChevronRightIcon />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
|
||||
import { tools } from '@/router/tools'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 24px 0 32px;
|
||||
}
|
||||
.hero-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.hero-desc {
|
||||
margin: 0 auto 14px;
|
||||
max-width: 560px;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.hero-badges {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 工具卡片栅格:响应式,移动端单列 */
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.tool-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
border-radius: 10px;
|
||||
background: var(--td-bg-color-container);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.tool-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--td-brand-color);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
html[theme-mode='dark'] .tool-card:hover {
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.tool-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: var(--td-brand-color);
|
||||
background: var(--td-brand-color-light);
|
||||
}
|
||||
.tool-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.tool-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.tool-arrow {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.hero-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.tool-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
236
src/views/address-parse/AddressParseView.vue
Normal file
236
src/views/address-parse/AddressParseView.vue
Normal file
@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">地址解析</h2>
|
||||
<p class="page-desc">输入中文地址,自动拆分省、市、区与详细地址(纯前端规则解析,支持批量多行)。</p>
|
||||
|
||||
<t-card class="tool-section" title="输入地址">
|
||||
<t-textarea
|
||||
v-model="input"
|
||||
placeholder="每行一个地址,例如: 北京市朝阳区望京街道阜通东大街 6 号 广东省深圳市南山区科技园南区深南大道 9988 号"
|
||||
:autosize="{ minRows: 5, maxRows: 12 }"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" @click="parse">解析</t-button>
|
||||
<t-button variant="outline" @click="loadExample">示例</t-button>
|
||||
<t-button variant="outline" @click="clearAll">清空</t-button>
|
||||
</div>
|
||||
<t-alert
|
||||
class="notice"
|
||||
theme="info"
|
||||
message="基于内置省市区规则的纯前端近似解析,不含地图数据库,复杂地址结果仅供参考。"
|
||||
/>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<t-card v-if="parsedList.length" class="tool-section" title="解析结果">
|
||||
<div v-for="(p, i) in parsedList" :key="i" class="addr-item">
|
||||
<div class="addr-head">
|
||||
<span class="addr-title">地址 {{ i + 1 }}</span>
|
||||
<CopyButton :text="formatParsed(p)" label="复制结果" />
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">省</span>
|
||||
<span class="info-value">{{ p.province || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">市</span>
|
||||
<span class="info-value">{{ p.city || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">区/县</span>
|
||||
<span class="info-value">{{ p.district || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item info-wide">
|
||||
<span class="info-label">详细地址</span>
|
||||
<span class="info-value">{{ p.detail || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
|
||||
interface ParsedAddress {
|
||||
province: string
|
||||
city: string
|
||||
district: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
const input = ref('')
|
||||
const error = ref('')
|
||||
const parsedList = ref<ParsedAddress[]>([])
|
||||
|
||||
const history = useHistory('address-parse')
|
||||
|
||||
const EXAMPLE = '北京市朝阳区望京街道阜通东大街6号\n广东省深圳市南山区科技园南区深南大道9988号'
|
||||
|
||||
/** 省级行政区全称(含常见简称/俗称别名处理) */
|
||||
const PROVINCES = [
|
||||
'北京市', '天津市', '上海市', '重庆市',
|
||||
'河北省', '山西省', '辽宁省', '吉林省', '黑龙江省',
|
||||
'江苏省', '浙江省', '安徽省', '福建省', '江西省', '山东省',
|
||||
'河南省', '湖北省', '湖南省', '广东省', '海南省',
|
||||
'四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省', '台湾省',
|
||||
'内蒙古自治区', '广西壮族自治区', '西藏自治区', '宁夏回族自治区', '新疆维吾尔自治区',
|
||||
'香港特别行政区', '澳门特别行政区',
|
||||
]
|
||||
|
||||
/** 直辖市集合(直辖市没有地级市一级) */
|
||||
const MUNICIPALITIES = new Set(['北京市', '天津市', '上海市', '重庆市'])
|
||||
|
||||
/**
|
||||
* 解析单条地址:省 → 市 → 区县 → 详细地址。
|
||||
* 纯规则匹配,无法识别的部分整体归入详细地址。
|
||||
*/
|
||||
function parseOne(raw: string): ParsedAddress {
|
||||
let rest = raw.trim().replace(/\s+/g, '')
|
||||
let province = ''
|
||||
let city = ''
|
||||
let district = ''
|
||||
|
||||
// 1. 匹配省份(全称优先;也兼容「内蒙古/广西/西藏/宁夏/新疆/香港/澳门」等俗称)
|
||||
const pFull = PROVINCES.find((p) => rest.startsWith(p))
|
||||
if (pFull) {
|
||||
province = pFull
|
||||
rest = rest.slice(pFull.length)
|
||||
} else {
|
||||
const aliasMap: [string, string][] = [
|
||||
['内蒙古', '内蒙古自治区'], ['广西', '广西壮族自治区'], ['西藏', '西藏自治区'],
|
||||
['宁夏', '宁夏回族自治区'], ['新疆', '新疆维吾尔自治区'],
|
||||
['香港', '香港特别行政区'], ['澳门', '澳门特别行政区'],
|
||||
]
|
||||
const alias = aliasMap.find(([a]) => rest.startsWith(a))
|
||||
if (alias) {
|
||||
province = alias[1]
|
||||
rest = rest.slice(alias[0].length)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 匹配市(直辖市跳过;支持 市/自治州/地区/盟)
|
||||
if (province && !MUNICIPALITIES.has(province)) {
|
||||
const m = /^(.{1,12}?(?:市|自治州|地区|盟))/.exec(rest)
|
||||
if (m) {
|
||||
city = m[1]
|
||||
rest = rest.slice(m[1].length)
|
||||
}
|
||||
} else if (province && MUNICIPALITIES.has(province)) {
|
||||
// 直辖市:市一级等同于直辖市本身
|
||||
city = province
|
||||
}
|
||||
|
||||
// 3. 匹配区县(区/县/旗/县级市)
|
||||
const d = /^(.{1,12}?(?:区|县|旗|市))/.exec(rest)
|
||||
if (d) {
|
||||
district = d[1]
|
||||
rest = rest.slice(d[1].length)
|
||||
}
|
||||
|
||||
return { province, city, district, detail: rest }
|
||||
}
|
||||
|
||||
function parse() {
|
||||
error.value = ''
|
||||
parsedList.value = []
|
||||
const lines = input.value.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
if (!lines.length) {
|
||||
error.value = '请输入至少一个地址'
|
||||
return
|
||||
}
|
||||
parsedList.value = lines.map(parseOne)
|
||||
history.add(input.value)
|
||||
}
|
||||
|
||||
/** 单条解析结果格式化为文本(供复制) */
|
||||
function formatParsed(p: ParsedAddress): string {
|
||||
return `省:${p.province || '-'}\n市:${p.city || '-'}\n区/县:${p.district || '-'}\n详细地址:${p.detail || '-'}`
|
||||
}
|
||||
|
||||
function loadExample() {
|
||||
input.value = EXAMPLE
|
||||
}
|
||||
|
||||
function onHistorySelect(text: string) {
|
||||
input.value = text
|
||||
parse()
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
input.value = ''
|
||||
error.value = ''
|
||||
parsedList.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.addr-item {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed var(--td-border-level-1-color);
|
||||
}
|
||||
.addr-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.addr-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.addr-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
.info-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
.info-value {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
235
src/views/base64/Base64View.vue
Normal file
235
src/views/base64/Base64View.vue
Normal file
@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">Base64 编解码</h2>
|
||||
<p class="page-desc">文本 Base64 编码/解码;图片文件与 Base64 字符串互转,全部在浏览器本地完成。</p>
|
||||
|
||||
<t-tabs v-model="tab">
|
||||
<!-- 文本编解码 -->
|
||||
<t-tab-panel value="text" label="文本编解码">
|
||||
<t-card class="tool-section" title="输入">
|
||||
<t-textarea
|
||||
v-model="textInput"
|
||||
placeholder="输入要编码/解码的文本"
|
||||
:autosize="{ minRows: 5, maxRows: 10 }"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" @click="encodeText">编码(文本 → Base64)</t-button>
|
||||
<t-button theme="primary" variant="outline" @click="decodeText">解码(Base64 → 文本)</t-button>
|
||||
<t-button variant="outline" @click="clearText">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<t-card v-if="textOutput" class="tool-section" title="结果">
|
||||
<t-textarea v-model="textOutput" :autosize="{ minRows: 5, maxRows: 10 }" readonly />
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="textOutput" />
|
||||
</div>
|
||||
</t-card>
|
||||
</t-tab-panel>
|
||||
|
||||
<!-- 图片互转 -->
|
||||
<t-tab-panel value="image" label="图片互转">
|
||||
<!-- 图片 -> Base64 -->
|
||||
<t-card class="tool-section" title="图片 → Base64">
|
||||
<t-upload
|
||||
v-model="fileList"
|
||||
theme="file"
|
||||
:auto-upload="false"
|
||||
:show-upload-progress="false"
|
||||
:max="1"
|
||||
accept="image/*"
|
||||
placeholder="点击上传或拖拽图片到此处"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="imgError" @close="imgError = ''" />
|
||||
|
||||
<t-card v-if="imgBase64" class="tool-section" title="Base64 结果">
|
||||
<t-textarea v-model="imgBase64" :autosize="{ minRows: 4, maxRows: 8 }" readonly />
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="imgBase64" />
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<!-- Base64 -> 图片 -->
|
||||
<t-card class="tool-section" title="Base64 → 图片">
|
||||
<t-textarea
|
||||
v-model="b64Input"
|
||||
placeholder="粘贴图片的 Base64 字符串(可包含 data:image/...;base64, 前缀)"
|
||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" @click="b64ToImage">转换为图片</t-button>
|
||||
<t-button variant="outline" @click="clearImage">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="b64Error" @close="b64Error = ''" />
|
||||
|
||||
<t-card v-if="imgUrl" class="tool-section" title="转换结果">
|
||||
<img :src="imgUrl" alt="Base64 还原的图片" class="result-img" />
|
||||
<div class="form-actions">
|
||||
<DownloadButton :filename="imgFilename" :get-blob="getImgBlob" label="下载图片" />
|
||||
</div>
|
||||
</t-card>
|
||||
</t-tab-panel>
|
||||
</t-tabs>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { UploadFile } from 'tdesign-vue-next'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import DownloadButton from '@/components/common/DownloadButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { dataUrlToBlob, fileToDataUrl } from '@/utils'
|
||||
|
||||
const tab = ref('text')
|
||||
|
||||
/* ---------- 文本编解码 ---------- */
|
||||
const textInput = ref('')
|
||||
const textOutput = ref('')
|
||||
const error = ref('')
|
||||
|
||||
const history = useHistory('base64')
|
||||
|
||||
/** 编码:UTF-8 安全的 base64(先转字节再 btoa,避免中文报错) */
|
||||
function encodeText() {
|
||||
error.value = ''
|
||||
textOutput.value = ''
|
||||
if (!textInput.value) {
|
||||
error.value = '请输入要编码的文本'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const bytes = new TextEncoder().encode(textInput.value)
|
||||
let bin = ''
|
||||
bytes.forEach((b) => (bin += String.fromCharCode(b)))
|
||||
textOutput.value = btoa(bin)
|
||||
history.add(textInput.value)
|
||||
} catch {
|
||||
error.value = '编码失败:输入包含无法处理的字符'
|
||||
}
|
||||
}
|
||||
|
||||
function decodeText() {
|
||||
error.value = ''
|
||||
textOutput.value = ''
|
||||
if (!textInput.value.trim()) {
|
||||
error.value = '请输入要解码的 Base64 文本'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const bin = atob(textInput.value.trim())
|
||||
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))
|
||||
textOutput.value = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
|
||||
} catch {
|
||||
error.value = '解码失败:不是合法的 Base64 字符串'
|
||||
}
|
||||
}
|
||||
|
||||
function clearText() {
|
||||
textInput.value = ''
|
||||
textOutput.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
/* ---------- 图片互转 ---------- */
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
const imgBase64 = ref('')
|
||||
const imgError = ref('')
|
||||
|
||||
const b64Input = ref('')
|
||||
const b64Error = ref('')
|
||||
const imgUrl = ref('')
|
||||
const imgMime = ref('image/png')
|
||||
|
||||
const imgFilename = computed(() => {
|
||||
const ext = imgMime.value.split('/')[1] ?? 'png'
|
||||
return `decoded_image.${ext}`
|
||||
})
|
||||
|
||||
async function onFileChange(list: UploadFile[]) {
|
||||
fileList.value = list
|
||||
imgError.value = ''
|
||||
imgBase64.value = ''
|
||||
const raw = list[0]?.raw
|
||||
if (!(raw instanceof File)) return
|
||||
try {
|
||||
imgBase64.value = await fileToDataUrl(raw)
|
||||
} catch {
|
||||
imgError.value = '读取图片失败,请重试'
|
||||
}
|
||||
}
|
||||
|
||||
/** Base64 -> 图片:校验格式后渲染预览并生成 Blob */
|
||||
function b64ToImage() {
|
||||
b64Error.value = ''
|
||||
imgUrl.value = ''
|
||||
const raw = b64Input.value.trim()
|
||||
if (!raw) {
|
||||
b64Error.value = '请粘贴图片的 Base64 字符串'
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 兼容带 data:image 前缀与纯 Base64 两种输入
|
||||
const dataUrl = raw.startsWith('data:') ? raw : `data:image/png;base64,${raw}`
|
||||
const blob = dataUrlToBlob(dataUrl)
|
||||
if (!blob.type.startsWith('image/')) throw new Error('not image')
|
||||
imgMime.value = blob.type
|
||||
imgUrl.value = dataUrl
|
||||
} catch {
|
||||
b64Error.value = '转换失败:Base64 数据不合法或不是图片'
|
||||
}
|
||||
}
|
||||
|
||||
function getImgBlob(): Blob | null {
|
||||
if (!imgUrl.value) return null
|
||||
try {
|
||||
return dataUrlToBlob(imgUrl.value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
b64Input.value = ''
|
||||
b64Error.value = ''
|
||||
imgUrl.value = ''
|
||||
}
|
||||
|
||||
function onHistorySelect(text: string) {
|
||||
tab.value = 'text'
|
||||
textInput.value = text
|
||||
encodeText()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.result-img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
285
src/views/cron/CronView.vue
Normal file
285
src/views/cron/CronView.vue
Normal file
@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">Cron 表达式工具</h2>
|
||||
<p class="page-desc">解析 Cron 表达式的下次执行时间,或通过可视化表单生成 Cron 表达式。</p>
|
||||
|
||||
<t-tabs v-model="tab">
|
||||
<!-- ==================== 解析 ==================== -->
|
||||
<t-tab-panel value="parse" label="解析表达式">
|
||||
<t-card class="tool-section" title="输入表达式">
|
||||
<t-input v-model="expr" placeholder="例如 0 9 * * 1-5(每工作日 9 点执行)" clearable />
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" @click="parse">解析</t-button>
|
||||
<t-button variant="outline" @click="clearParse">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<t-card v-if="nextTimes.length" class="tool-section" title="后续执行时间(北京时间)">
|
||||
<div class="field-table" v-if="fields.length">
|
||||
<div v-for="(f, i) in fields" :key="i" class="field-row">
|
||||
<span class="field-label">{{ f.label }}</span>
|
||||
<span class="field-value">{{ f.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="time-list">
|
||||
<li v-for="(t, i) in nextTimes" :key="i">{{ t }}</li>
|
||||
</ol>
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="nextTimes.join('\n')" label="复制执行时间" />
|
||||
</div>
|
||||
</t-card>
|
||||
</t-tab-panel>
|
||||
|
||||
<!-- ==================== 可视化生成 ==================== -->
|
||||
<t-tab-panel value="generate" label="可视化生成">
|
||||
<t-card class="tool-section" title="快捷预设">
|
||||
<div class="preset-list">
|
||||
<t-button v-for="p in presets" :key="p.label" variant="outline" size="small" @click="applyPreset(p.expr)">
|
||||
{{ p.label }}
|
||||
</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card class="tool-section" title="字段配置">
|
||||
<div class="form-row" style="margin-bottom: 10px">
|
||||
<t-checkbox v-model="withSeconds">包含秒字段(6 段表达式)</t-checkbox>
|
||||
</div>
|
||||
<div v-for="f in genFields" :key="f.key" class="form-row gen-row">
|
||||
<span class="form-label">{{ f.label }}</span>
|
||||
<t-select v-model="genValues[f.key]" :options="f.options" class="form-select" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" @click="buildExpr">生成表达式</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="genError" @close="genError = ''" />
|
||||
|
||||
<t-card v-if="genResult" class="tool-section" title="生成的表达式">
|
||||
<div class="code-block">{{ genResult }}</div>
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="genResult" label="复制表达式" />
|
||||
<t-button variant="outline" size="small" @click="useGenerated">到「解析」中查看执行时间</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
</t-tab-panel>
|
||||
</t-tabs>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { parseExpression } from 'cron-parser'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { formatBeijing } from '@/utils'
|
||||
|
||||
const tab = ref('parse')
|
||||
const history = useHistory('cron')
|
||||
|
||||
/* ---------- 解析 ---------- */
|
||||
const expr = ref('')
|
||||
const error = ref('')
|
||||
const nextTimes = ref<string[]>([])
|
||||
const fields = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
/** 字段标签:6 段时首段为秒,5 段时依次为分/时/日/月/周 */
|
||||
function fieldLabels(segCount: number): string[] {
|
||||
const base = ['分钟', '小时', '日', '月', '星期']
|
||||
return segCount === 6 ? ['秒', ...base] : base
|
||||
}
|
||||
|
||||
function parse() {
|
||||
error.value = ''
|
||||
nextTimes.value = []
|
||||
fields.value = []
|
||||
const raw = expr.value.trim()
|
||||
if (!raw) {
|
||||
error.value = '请输入 Cron 表达式'
|
||||
return
|
||||
}
|
||||
try {
|
||||
// cron-parser:解析失败会抛异常
|
||||
const interval = parseExpression(raw, { currentDate: new Date() })
|
||||
const segs = raw.split(/\s+/)
|
||||
fields.value = fieldLabels(segs.length).map((label, i) => ({ label, value: segs[i] ?? '' }))
|
||||
const times: string[] = []
|
||||
for (let i = 0; i < 6; i++) {
|
||||
times.push(formatBeijing(interval.next().toDate(), true))
|
||||
}
|
||||
nextTimes.value = times
|
||||
history.add(raw)
|
||||
} catch (e) {
|
||||
error.value = `表达式不合法:${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
}
|
||||
|
||||
function clearParse() {
|
||||
expr.value = ''
|
||||
error.value = ''
|
||||
nextTimes.value = []
|
||||
fields.value = []
|
||||
}
|
||||
|
||||
/* ---------- 可视化生成 ---------- */
|
||||
interface GenField {
|
||||
key: 'second' | 'minute' | 'hour' | 'dayOfMonth' | 'month' | 'dayOfWeek'
|
||||
label: string
|
||||
range: [number, number]
|
||||
}
|
||||
|
||||
const FIELD_DEFS: GenField[] = [
|
||||
{ key: 'second', label: '秒', range: [0, 59] },
|
||||
{ key: 'minute', label: '分钟', range: [0, 59] },
|
||||
{ key: 'hour', label: '小时', range: [0, 23] },
|
||||
{ key: 'dayOfMonth', label: '日', range: [1, 31] },
|
||||
{ key: 'month', label: '月', range: [1, 12] },
|
||||
{ key: 'dayOfWeek', label: '星期', range: [0, 6] },
|
||||
]
|
||||
|
||||
const withSeconds = ref(false)
|
||||
const genValues = reactive<Record<GenField['key'], string>>({
|
||||
second: '*',
|
||||
minute: '*',
|
||||
hour: '*',
|
||||
dayOfMonth: '*',
|
||||
month: '*',
|
||||
dayOfWeek: '*',
|
||||
})
|
||||
const genError = ref('')
|
||||
const genResult = ref('')
|
||||
|
||||
/** 每个字段的选项:任意 + 全量取值范围(星期用 0-6 表示周日-周六) */
|
||||
const genFields = computed(() =>
|
||||
FIELD_DEFS.filter((f) => f.key !== 'second' || withSeconds.value).map((f) => ({
|
||||
...f,
|
||||
options: [
|
||||
{ label: '任意(*)', value: '*' },
|
||||
...Array.from({ length: f.range[1] - f.range[0] + 1 }, (_, i) => {
|
||||
const v = String(f.range[0] + i)
|
||||
return f.key === 'dayOfWeek'
|
||||
? { label: `周${['日', '一', '二', '三', '四', '五', '六'][f.range[0] + i]}`, value: v }
|
||||
: { label: v, value: v }
|
||||
}),
|
||||
],
|
||||
})),
|
||||
)
|
||||
|
||||
/** 快捷预设(标准 5 段) */
|
||||
const presets = [
|
||||
{ label: '每分钟', expr: '* * * * *' },
|
||||
{ label: '每小时', expr: '0 * * * *' },
|
||||
{ label: '每天 0 点', expr: '0 0 * * *' },
|
||||
{ label: '每工作日 9 点', expr: '0 9 * * 1-5' },
|
||||
{ label: '每月 1 日 0 点', expr: '0 0 1 * *' },
|
||||
{ label: '每周一 9 点', expr: '0 9 * * 1' },
|
||||
]
|
||||
|
||||
function applyPreset(e: string) {
|
||||
const segs = e.split(' ')
|
||||
withSeconds.value = false
|
||||
genValues.minute = segs[0]
|
||||
genValues.hour = segs[1]
|
||||
genValues.dayOfMonth = segs[2]
|
||||
genValues.month = segs[3]
|
||||
genValues.dayOfWeek = segs[4]
|
||||
buildExpr()
|
||||
}
|
||||
|
||||
function buildExpr() {
|
||||
genError.value = ''
|
||||
genResult.value = ''
|
||||
try {
|
||||
const keys: GenField['key'][] = withSeconds.value
|
||||
? ['second', 'minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek']
|
||||
: ['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek']
|
||||
const segs = keys.map((k) => genValues[k])
|
||||
const e = segs.join(' ')
|
||||
// 用 cron-parser 校验生成的表达式合法性
|
||||
parseExpression(e)
|
||||
genResult.value = e
|
||||
history.add(e)
|
||||
} catch {
|
||||
genError.value = '生成的表达式不合法,请检查字段配置'
|
||||
}
|
||||
}
|
||||
|
||||
function useGenerated() {
|
||||
tab.value = 'parse'
|
||||
expr.value = genResult.value
|
||||
parse()
|
||||
}
|
||||
|
||||
function onHistorySelect(text: string) {
|
||||
tab.value = 'parse'
|
||||
expr.value = text
|
||||
parse()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.form-label {
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.form-select {
|
||||
width: 200px;
|
||||
}
|
||||
.gen-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.preset-list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.field-table {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.field-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
.field-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.time-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
</style>
|
||||
208
src/views/hash/HashView.vue
Normal file
208
src/views/hash/HashView.vue
Normal file
@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">哈希计算</h2>
|
||||
<p class="page-desc">对文本或文件计算 MD5、SHA1、SHA256、SHA512 哈希值,全部在浏览器本地完成。</p>
|
||||
|
||||
<t-tabs v-model="tab">
|
||||
<!-- 文本哈希 -->
|
||||
<t-tab-panel value="text" label="文本哈希">
|
||||
<t-card class="tool-section" title="输入文本">
|
||||
<t-textarea
|
||||
v-model="textInput"
|
||||
placeholder="输入要计算哈希的文本"
|
||||
:autosize="{ minRows: 4, maxRows: 10 }"
|
||||
/>
|
||||
<div class="form-row" style="margin-top: 12px">
|
||||
<t-checkbox v-model="uppercase">大写输出</t-checkbox>
|
||||
<t-button theme="primary" @click="computeText">计算</t-button>
|
||||
<t-button variant="outline" @click="clearText">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
</t-tab-panel>
|
||||
|
||||
<!-- 文件哈希 -->
|
||||
<t-tab-panel value="file" label="文件哈希">
|
||||
<t-card class="tool-section" title="上传文件">
|
||||
<t-upload
|
||||
v-model="fileList"
|
||||
theme="file"
|
||||
:auto-upload="false"
|
||||
:show-upload-progress="false"
|
||||
:max="1"
|
||||
placeholder="点击上传或拖拽任意文件到此处"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<p v-if="file" class="file-meta">
|
||||
{{ file.name }} · {{ formatBytes(file.size) }}
|
||||
</p>
|
||||
</t-card>
|
||||
</t-tab-panel>
|
||||
</t-tabs>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<t-card v-if="hashResults.length" class="tool-section" title="哈希结果">
|
||||
<div v-for="r in hashResults" :key="r.alg" class="hash-row">
|
||||
<div class="hash-head">
|
||||
<span class="hash-alg">{{ r.alg }}</span>
|
||||
<CopyButton :text="r.value" label="复制" />
|
||||
</div>
|
||||
<div class="code-block">{{ r.value }}</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="allResults" label="复制全部" />
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { UploadFile } from 'tdesign-vue-next'
|
||||
import SparkMD5 from 'spark-md5'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { bufferToHex, formatBytes } from '@/utils'
|
||||
|
||||
const tab = ref('text')
|
||||
const textInput = ref('')
|
||||
const uppercase = ref(false)
|
||||
const error = ref('')
|
||||
const hashResults = ref<{ alg: string; value: string }[]>([])
|
||||
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
const file = ref<File | null>(null)
|
||||
|
||||
const history = useHistory('hash')
|
||||
|
||||
const allResults = computed(() => hashResults.value.map((r) => `${r.alg}: ${r.value}`).join('\n'))
|
||||
|
||||
/** 统一大小写输出 */
|
||||
function fmt(hex: string): string {
|
||||
return uppercase.value ? hex.toUpperCase() : hex
|
||||
}
|
||||
|
||||
/** Web Crypto 计算 SHA 系列摘要 */
|
||||
async function shaHex(alg: 'SHA-1' | 'SHA-256' | 'SHA-512', data: BufferSource): Promise<string> {
|
||||
const buf = await crypto.subtle.digest(alg, data)
|
||||
return bufferToHex(buf)
|
||||
}
|
||||
|
||||
/** 对字节数据计算四种哈希 */
|
||||
async function computeHashes(data: Uint8Array) {
|
||||
// MD5:spark-md5(同步);SHA 系列:Web Crypto(异步)
|
||||
const md5 = SparkMD5.ArrayBuffer.hash(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength))
|
||||
const [sha1, sha256, sha512] = await Promise.all([
|
||||
shaHex('SHA-1', data),
|
||||
shaHex('SHA-256', data),
|
||||
shaHex('SHA-512', data),
|
||||
])
|
||||
hashResults.value = [
|
||||
{ alg: 'MD5', value: fmt(md5) },
|
||||
{ alg: 'SHA1', value: fmt(sha1) },
|
||||
{ alg: 'SHA256', value: fmt(sha256) },
|
||||
{ alg: 'SHA512', value: fmt(sha512) },
|
||||
]
|
||||
}
|
||||
|
||||
async function computeText() {
|
||||
error.value = ''
|
||||
hashResults.value = []
|
||||
if (!textInput.value) {
|
||||
error.value = '请输入要计算哈希的文本'
|
||||
return
|
||||
}
|
||||
try {
|
||||
await computeHashes(new TextEncoder().encode(textInput.value))
|
||||
history.add(textInput.value)
|
||||
} catch {
|
||||
error.value = '计算失败,请重试'
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileChange(list: UploadFile[]) {
|
||||
fileList.value = list
|
||||
error.value = ''
|
||||
hashResults.value = []
|
||||
const raw = list[0]?.raw
|
||||
if (!(raw instanceof File)) {
|
||||
file.value = null
|
||||
return
|
||||
}
|
||||
file.value = raw
|
||||
try {
|
||||
// 大文件按 4MB 分片读取,避免一次性占用过多内存
|
||||
const chunkSize = 4 * 1024 * 1024
|
||||
const spark = new SparkMD5.ArrayBuffer()
|
||||
for (let start = 0; start < raw.size; start += chunkSize) {
|
||||
const chunk = await raw.slice(start, start + chunkSize).arrayBuffer()
|
||||
spark.append(chunk)
|
||||
}
|
||||
const md5 = spark.end()
|
||||
// SHA 系列需要完整字节流
|
||||
const buf = await raw.arrayBuffer()
|
||||
const [sha1, sha256, sha512] = await Promise.all([
|
||||
shaHex('SHA-1', buf),
|
||||
shaHex('SHA-256', buf),
|
||||
shaHex('SHA-512', buf),
|
||||
])
|
||||
hashResults.value = [
|
||||
{ alg: 'MD5', value: fmt(md5) },
|
||||
{ alg: 'SHA1', value: fmt(sha1) },
|
||||
{ alg: 'SHA256', value: fmt(sha256) },
|
||||
{ alg: 'SHA512', value: fmt(sha512) },
|
||||
]
|
||||
} catch {
|
||||
error.value = '文件读取失败,请重试'
|
||||
}
|
||||
}
|
||||
|
||||
function onHistorySelect(text: string) {
|
||||
tab.value = 'text'
|
||||
textInput.value = text
|
||||
computeText()
|
||||
}
|
||||
|
||||
function clearText() {
|
||||
textInput.value = ''
|
||||
error.value = ''
|
||||
hashResults.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.file-meta {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.hash-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hash-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.hash-alg {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
203
src/views/idcard/IdcardView.vue
Normal file
203
src/views/idcard/IdcardView.vue
Normal file
@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">身份证解析</h2>
|
||||
<p class="page-desc">输入 18 位身份证号,校验合法性并解析出生日期、性别、年龄、地区等信息(仅本地计算)。</p>
|
||||
|
||||
<t-card class="tool-section" title="输入身份证号">
|
||||
<t-input
|
||||
v-model="id"
|
||||
placeholder="请输入 18 位身份证号"
|
||||
:maxlength="18"
|
||||
clearable
|
||||
@enter="parse"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" @click="parse">解析</t-button>
|
||||
<t-button variant="outline" @click="clearAll">清空</t-button>
|
||||
</div>
|
||||
<t-alert
|
||||
class="notice"
|
||||
theme="info"
|
||||
message="身份证号仅在浏览器本地解析,不会上传到服务器。"
|
||||
/>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<t-card v-if="result" class="tool-section" title="解析结果">
|
||||
<div class="info-grid">
|
||||
<div v-for="item in result" :key="item.label" class="info-item">
|
||||
<span class="info-label">{{ item.label }}</span>
|
||||
<span class="info-value">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
|
||||
const id = ref('')
|
||||
const error = ref('')
|
||||
const result = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
const history = useHistory('idcard')
|
||||
|
||||
/** 前两位行政区划代码 -> 省级行政区(覆盖全部省/自治区/直辖市/特别行政区) */
|
||||
const PROVINCES: Record<string, string> = {
|
||||
'11': '北京市', '12': '天津市', '13': '河北省', '14': '山西省', '15': '内蒙古自治区',
|
||||
'21': '辽宁省', '22': '吉林省', '23': '黑龙江省',
|
||||
'31': '上海市', '32': '江苏省', '33': '浙江省', '34': '安徽省', '35': '福建省', '36': '江西省', '37': '山东省',
|
||||
'41': '河南省', '42': '湖北省', '43': '湖南省', '44': '广东省', '45': '广西壮族自治区', '46': '海南省',
|
||||
'50': '重庆市', '51': '四川省', '52': '贵州省', '53': '云南省', '54': '西藏自治区',
|
||||
'61': '陕西省', '62': '甘肃省', '63': '青海省', '64': '宁夏回族自治区', '65': '新疆维吾尔自治区',
|
||||
'71': '台湾省', '81': '香港特别行政区', '82': '澳门特别行政区',
|
||||
}
|
||||
|
||||
/** 校验位权重与校验码表(GB 11643-1999) */
|
||||
const WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
const CHECK_CODES = '10X98765432'
|
||||
|
||||
const ZODIAC = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪']
|
||||
const CONSTELLATIONS: { name: string; start: [number, number]; end: [number, number] }[] = [
|
||||
{ name: '摩羯座', start: [1, 1], end: [1, 19] },
|
||||
{ name: '水瓶座', start: [1, 20], end: [2, 18] },
|
||||
{ name: '双鱼座', start: [2, 19], end: [3, 20] },
|
||||
{ name: '白羊座', start: [3, 21], end: [4, 19] },
|
||||
{ name: '金牛座', start: [4, 20], end: [5, 20] },
|
||||
{ name: '双子座', start: [5, 21], end: [6, 21] },
|
||||
{ name: '巨蟹座', start: [6, 22], end: [7, 22] },
|
||||
{ name: '狮子座', start: [7, 23], end: [8, 22] },
|
||||
{ name: '处女座', start: [8, 23], end: [9, 22] },
|
||||
{ name: '天秤座', start: [9, 23], end: [10, 23] },
|
||||
{ name: '天蝎座', start: [10, 24], end: [11, 22] },
|
||||
{ name: '射手座', start: [11, 23], end: [12, 21] },
|
||||
{ name: '摩羯座', start: [12, 22], end: [12, 31] },
|
||||
]
|
||||
|
||||
/** 计算周岁年龄 */
|
||||
function calcAge(birth: Date): number {
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - birth.getFullYear()
|
||||
const beforeBirthday =
|
||||
now.getMonth() < birth.getMonth() ||
|
||||
(now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate())
|
||||
if (beforeBirthday) age--
|
||||
return age
|
||||
}
|
||||
|
||||
function constellation(month: number, day: number): string {
|
||||
return (
|
||||
CONSTELLATIONS.find((c) => {
|
||||
const [sm, sd] = c.start
|
||||
const [em, ed] = c.end
|
||||
return (month === sm && day >= sd) || (month === em && day <= ed)
|
||||
})?.name ?? '-'
|
||||
)
|
||||
}
|
||||
|
||||
function parse() {
|
||||
error.value = ''
|
||||
result.value = []
|
||||
const raw = id.value.trim().toUpperCase()
|
||||
|
||||
// 1. 格式校验
|
||||
if (!/^\d{17}[\dX]$/.test(raw)) {
|
||||
error.value = '格式不正确:身份证号应为 18 位,由 17 位数字加 1 位数字或 X 组成'
|
||||
return
|
||||
}
|
||||
// 2. 校验位校验
|
||||
const sum = raw
|
||||
.slice(0, 17)
|
||||
.split('')
|
||||
.reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0)
|
||||
if (CHECK_CODES[sum % 11] !== raw[17]) {
|
||||
error.value = '校验失败:校验位不匹配,身份证号可能输入有误'
|
||||
return
|
||||
}
|
||||
// 3. 出生日期合法性
|
||||
const year = Number(raw.slice(6, 10))
|
||||
const month = Number(raw.slice(10, 12))
|
||||
const day = Number(raw.slice(12, 14))
|
||||
const birth = new Date(year, month - 1, day)
|
||||
if (
|
||||
birth.getFullYear() !== year ||
|
||||
birth.getMonth() !== month - 1 ||
|
||||
birth.getDate() !== day
|
||||
) {
|
||||
error.value = '出生日期不合法'
|
||||
return
|
||||
}
|
||||
|
||||
const province = PROVINCES[raw.slice(0, 2)] ?? `未知代码 ${raw.slice(0, 2)}`
|
||||
const gender = Number(raw[16]) % 2 === 1 ? '男' : '女'
|
||||
const zodiac = ZODIAC[(year - 4) % 12]
|
||||
|
||||
result.value = [
|
||||
{ label: '合法性', value: '校验通过' },
|
||||
{ label: '所属地区', value: province },
|
||||
{ label: '出生日期', value: `${year}年${month}月${day}日` },
|
||||
{ label: '性别', value: gender },
|
||||
{ label: '年龄', value: `${calcAge(birth)} 岁` },
|
||||
{ label: '生肖', value: `属${zodiac}` },
|
||||
{ label: '星座', value: constellation(month, day) },
|
||||
]
|
||||
history.add(id.value.trim())
|
||||
}
|
||||
|
||||
function onHistorySelect(text: string) {
|
||||
id.value = text
|
||||
parse()
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
id.value = ''
|
||||
error.value = ''
|
||||
result.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
</style>
|
||||
228
src/views/image-compress/ImageCompressView.vue
Normal file
228
src/views/image-compress/ImageCompressView.vue
Normal file
@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">图片压缩</h2>
|
||||
<p class="page-desc">上传图片,调节压缩质量与输出格式,全部在浏览器本地完成,图片不会上传服务器。</p>
|
||||
|
||||
<t-card class="tool-section" title="上传图片">
|
||||
<t-upload
|
||||
v-model="fileList"
|
||||
theme="file"
|
||||
:auto-upload="false"
|
||||
:show-upload-progress="false"
|
||||
:max="1"
|
||||
accept="image/*"
|
||||
placeholder="点击上传或拖拽图片到此处"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</t-card>
|
||||
|
||||
<t-card v-if="file" class="tool-section" title="压缩设置">
|
||||
<div class="form-row">
|
||||
<span class="form-label">压缩质量</span>
|
||||
<t-slider
|
||||
v-model="quality"
|
||||
class="form-slider"
|
||||
:min="0.1"
|
||||
:max="1"
|
||||
:step="0.05"
|
||||
:label="(v: number) => `${Math.round(v * 100)}%`"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<span class="form-label">输出格式</span>
|
||||
<t-select v-model="outputType" :options="typeOptions" class="form-select" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" :loading="busy" @click="compress">开始压缩</t-button>
|
||||
<t-button variant="outline" @click="clearAll">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<t-card v-if="outUrl" class="tool-section" title="压缩结果">
|
||||
<div class="result-compare">
|
||||
<div class="compare-item">
|
||||
<p class="compare-title">原图({{ formatBytes(file?.size ?? 0) }})</p>
|
||||
<img :src="previewUrl" alt="原图预览" class="compare-img" />
|
||||
</div>
|
||||
<div class="compare-item">
|
||||
<p class="compare-title">
|
||||
压缩后({{ formatBytes(outFile?.size ?? 0) }},节省 {{ savings }}%)
|
||||
</p>
|
||||
<img :src="outUrl" alt="压缩后预览" class="compare-img" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<DownloadButton :filename="outName" :get-blob="getOutBlob" label="下载压缩图" />
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { UploadFile } from 'tdesign-vue-next'
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import DownloadButton from '@/components/common/DownloadButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { formatBytes } from '@/utils'
|
||||
|
||||
/** 输出格式选项(MIME -> 展示名) */
|
||||
const typeOptions = [
|
||||
{ label: 'JPG', value: 'image/jpeg' },
|
||||
{ label: 'PNG', value: 'image/png' },
|
||||
{ label: 'WebP', value: 'image/webp' },
|
||||
]
|
||||
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
const file = ref<File | null>(null)
|
||||
const quality = ref(0.8)
|
||||
const outputType = ref('image/jpeg')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const previewUrl = ref('')
|
||||
const outUrl = ref('')
|
||||
const outFile = ref<File | null>(null)
|
||||
|
||||
/** 历史记录:保存「文件名 | 质量 | 格式」,点击可恢复压缩参数 */
|
||||
const history = useHistory('image-compress')
|
||||
|
||||
const outName = computed(() => {
|
||||
const base = (file.value?.name ?? 'image').replace(/\.[^.]+$/, '')
|
||||
const ext = outputType.value.split('/')[1]
|
||||
return `${base}_compressed.${ext}`
|
||||
})
|
||||
|
||||
const savings = computed(() => {
|
||||
if (!file.value || !outFile.value) return 0
|
||||
return Math.max(0, Math.round((1 - outFile.value.size / file.value.size) * 100))
|
||||
})
|
||||
|
||||
function onFileChange(list: UploadFile[]) {
|
||||
error.value = ''
|
||||
resetOutput()
|
||||
fileList.value = list
|
||||
const raw = list[0]?.raw
|
||||
if (raw instanceof File) {
|
||||
file.value = raw
|
||||
previewUrl.value = URL.createObjectURL(raw)
|
||||
} else {
|
||||
file.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 压缩:仅按质量单次压缩,保持原始分辨率(browser-image-compression) */
|
||||
async function compress() {
|
||||
if (!file.value) {
|
||||
error.value = '请先上传图片'
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await imageCompression(file.value, {
|
||||
initialQuality: quality.value,
|
||||
maxSizeMB: Infinity, // 不按大小迭代压缩,仅应用质量参数
|
||||
useWebWorker: true,
|
||||
fileType: outputType.value,
|
||||
alwaysKeepResolution: true,
|
||||
})
|
||||
outFile.value = result
|
||||
outUrl.value = URL.createObjectURL(result)
|
||||
// 写入本地历史
|
||||
const ext = outputType.value.split('/')[1]
|
||||
history.add(`${file.value.name} | 质量${Math.round(quality.value * 100)}% | ${ext}`)
|
||||
} catch {
|
||||
// 常见原因:浏览器不支持输出格式(如部分 Safari 的 WebP 编码)
|
||||
error.value = '压缩失败:当前浏览器可能不支持所选输出格式,请换一种格式重试'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getOutBlob(): Blob | null {
|
||||
return outFile.value
|
||||
}
|
||||
|
||||
/** 点击历史:恢复质量与输出格式参数 */
|
||||
function onHistorySelect(text: string) {
|
||||
const qMatch = /质量(\d+)%/.exec(text)
|
||||
if (qMatch) quality.value = Number(qMatch[1]) / 100
|
||||
if (/| jpg/.test(text)) outputType.value = 'image/jpeg'
|
||||
else if (/| png/.test(text)) outputType.value = 'image/png'
|
||||
else if (/| webp/.test(text)) outputType.value = 'image/webp'
|
||||
}
|
||||
|
||||
function resetOutput() {
|
||||
outUrl.value && URL.revokeObjectURL(outUrl.value)
|
||||
outUrl.value = ''
|
||||
outFile.value = null
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
fileList.value = []
|
||||
file.value = null
|
||||
error.value = ''
|
||||
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
resetOutput()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-label {
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.form-slider {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
}
|
||||
.form-select {
|
||||
width: 200px;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.result-compare {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.result-compare {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.compare-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.compare-img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
background-image: linear-gradient(45deg, #f0f0f0 25%, transparent 25%, transparent 75%, #f0f0f0 75%),
|
||||
linear-gradient(45deg, #f0f0f0 25%, transparent 25%, transparent 75%, #f0f0f0 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
}
|
||||
</style>
|
||||
210
src/views/image-cropper/ImageCropperView.vue
Normal file
210
src/views/image-cropper/ImageCropperView.vue
Normal file
@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">图片裁剪</h2>
|
||||
<p class="page-desc">上传图片,选择裁剪比例,拖动选框调整区域,裁剪结果本地生成并下载。</p>
|
||||
|
||||
<t-card class="tool-section" title="上传图片">
|
||||
<t-upload
|
||||
v-model="fileList"
|
||||
theme="file"
|
||||
:auto-upload="false"
|
||||
:show-upload-progress="false"
|
||||
:max="1"
|
||||
accept="image/*"
|
||||
placeholder="点击上传或拖拽图片到此处"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</t-card>
|
||||
|
||||
<t-card v-if="imageUrl" class="tool-section" title="裁剪设置">
|
||||
<div class="form-row">
|
||||
<span class="form-label">裁剪比例</span>
|
||||
<t-select v-model="ratio" :options="ratioOptions" class="form-select" @change="onRatioChange" />
|
||||
<span class="form-label">导出格式</span>
|
||||
<t-select v-model="outType" :options="typeOptions" class="form-select" />
|
||||
</div>
|
||||
<div class="cropper-wrap">
|
||||
<img ref="imgRef" :src="imageUrl" alt="待裁剪图片" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" @click="doCrop">裁剪并预览</t-button>
|
||||
<t-button variant="outline" @click="clearAll">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<t-card v-if="outUrl" class="tool-section" title="裁剪结果">
|
||||
<img :src="outUrl" alt="裁剪结果预览" class="result-img" />
|
||||
<div class="form-actions">
|
||||
<DownloadButton :filename="outName" :get-blob="getOutBlob" label="下载裁剪图" />
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
import type { UploadFile } from 'tdesign-vue-next'
|
||||
import Cropper from 'cropperjs'
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import DownloadButton from '@/components/common/DownloadButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { dataUrlToBlob, genFilename } from '@/utils'
|
||||
|
||||
/** 比例选项:'free' 表示自由裁剪 */
|
||||
const ratioOptions = [
|
||||
{ label: '自由比例', value: 'free' },
|
||||
{ label: '1:1', value: '1:1' },
|
||||
{ label: '4:3', value: '4:3' },
|
||||
{ label: '3:4', value: '3:4' },
|
||||
{ label: '16:9', value: '16:9' },
|
||||
{ label: '9:16', value: '9:16' },
|
||||
]
|
||||
const typeOptions = [
|
||||
{ label: 'JPG', value: 'image/jpeg' },
|
||||
{ label: 'PNG', value: 'image/png' },
|
||||
]
|
||||
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
const imageUrl = ref('')
|
||||
const ratio = ref('free')
|
||||
const outType = ref('image/jpeg')
|
||||
const error = ref('')
|
||||
const outUrl = ref('')
|
||||
|
||||
const imgRef = ref<HTMLImageElement | null>(null)
|
||||
let cropper: Cropper | null = null
|
||||
|
||||
const history = useHistory('image-cropper')
|
||||
|
||||
const outName = computed(() => genFilename('cropped', outType.value.split('/')[1]))
|
||||
|
||||
function ratioValue(v: string): number {
|
||||
if (v === 'free') return NaN
|
||||
const [w, h] = v.split(':').map(Number)
|
||||
return w / h
|
||||
}
|
||||
|
||||
function onFileChange(list: UploadFile[]) {
|
||||
error.value = ''
|
||||
outUrl.value = ''
|
||||
fileList.value = list
|
||||
const raw = list[0]?.raw
|
||||
if (!(raw instanceof File)) {
|
||||
imageUrl.value && URL.revokeObjectURL(imageUrl.value)
|
||||
imageUrl.value = ''
|
||||
return
|
||||
}
|
||||
imageUrl.value && URL.revokeObjectURL(imageUrl.value)
|
||||
imageUrl.value = URL.createObjectURL(raw)
|
||||
// 图片加载后初始化 Cropper 实例
|
||||
nextTick(() => {
|
||||
cropper?.destroy()
|
||||
cropper = null
|
||||
if (imgRef.value) {
|
||||
cropper = new Cropper(imgRef.value, {
|
||||
aspectRatio: ratioValue(ratio.value),
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
background: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onRatioChange(v: string) {
|
||||
ratio.value = v
|
||||
cropper?.setAspectRatio(ratioValue(v))
|
||||
}
|
||||
|
||||
/** 执行裁剪:生成结果 DataURL 供预览,同时保存 Blob 供下载 */
|
||||
function doCrop() {
|
||||
if (!cropper) {
|
||||
error.value = '请先上传图片'
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
const canvas = cropper.getCroppedCanvas({ maxWidth: 2400, maxHeight: 2400 })
|
||||
outUrl.value = canvas.toDataURL(outType.value, 0.92)
|
||||
const name = fileList.value[0]?.name ?? 'image'
|
||||
history.add(`${name} | ${ratio.value === 'free' ? '自由' : ratio.value} | ${outType.value.split('/')[1]}`)
|
||||
}
|
||||
|
||||
function getOutBlob(): Blob | null {
|
||||
if (!outUrl.value) return null
|
||||
try {
|
||||
return dataUrlToBlob(outUrl.value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击历史:恢复比例与格式参数 */
|
||||
function onHistorySelect(text: string) {
|
||||
const rMatch = /(自由|\d+:\d+)/.exec(text)
|
||||
if (rMatch) ratio.value = rMatch[1] === '自由' ? 'free' : rMatch[1]
|
||||
if (/| jpg/.test(text)) outType.value = 'image/jpeg'
|
||||
else if (/| png/.test(text)) outType.value = 'image/png'
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
cropper?.destroy()
|
||||
cropper = null
|
||||
fileList.value = []
|
||||
imageUrl.value && URL.revokeObjectURL(imageUrl.value)
|
||||
imageUrl.value = ''
|
||||
outUrl.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cropper?.destroy()
|
||||
cropper = null
|
||||
imageUrl.value && URL.revokeObjectURL(imageUrl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-label {
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.form-select {
|
||||
width: 140px;
|
||||
}
|
||||
.cropper-wrap {
|
||||
margin: 16px 0;
|
||||
max-height: 420px;
|
||||
overflow: hidden;
|
||||
border: 1px dashed var(--td-border-level-2-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cropper-wrap img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.result-img {
|
||||
max-width: 100%;
|
||||
max-height: 320px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
174
src/views/json-format/JsonFormatView.vue
Normal file
174
src/views/json-format/JsonFormatView.vue
Normal file
@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">JSON 格式化</h2>
|
||||
<p class="page-desc">粘贴 JSON 文本,一键格式化、压缩、校验并复制结果,数据仅在浏览器本地处理。</p>
|
||||
|
||||
<t-card class="tool-section" title="输入">
|
||||
<t-textarea
|
||||
v-model="input"
|
||||
placeholder="在此粘贴 JSON 文本,例如 {"name":"张三","age":18}"
|
||||
:autosize="{ minRows: 8, maxRows: 16 }"
|
||||
/>
|
||||
<div class="form-row" style="margin-top: 12px">
|
||||
<span class="form-label">缩进</span>
|
||||
<t-select v-model="indent" :options="indentOptions" class="form-select" />
|
||||
<t-button variant="outline" @click="loadExample">示例</t-button>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" @click="doFormat">格式化</t-button>
|
||||
<t-button variant="outline" @click="doMinify">压缩</t-button>
|
||||
<t-button variant="outline" @click="doValidate">校验</t-button>
|
||||
<t-button variant="outline" @click="clearAll">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<t-card v-if="output" class="tool-section" title="结果">
|
||||
<div class="result-head">
|
||||
<t-tag v-if="stats" theme="success" variant="light">{{ stats }}</t-tag>
|
||||
<div class="form-actions result-actions">
|
||||
<CopyButton :text="output" />
|
||||
<t-button variant="outline" size="small" @click="downloadResult">
|
||||
<template #icon><DownloadIcon /></template>
|
||||
下载
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<t-textarea v-model="output" :autosize="{ minRows: 8, maxRows: 20 }" readonly />
|
||||
</t-card>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import { DownloadIcon } from 'tdesign-icons-vue-next'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { downloadText } from '@/composables/useDownload'
|
||||
import { genFilename } from '@/utils'
|
||||
|
||||
const indentOptions = [
|
||||
{ label: '2 空格', value: 2 },
|
||||
{ label: '4 空格', value: 4 },
|
||||
{ label: 'Tab', value: 'tab' },
|
||||
]
|
||||
|
||||
const input = ref('')
|
||||
const output = ref('')
|
||||
const indent = ref<number | 'tab'>(2)
|
||||
const error = ref('')
|
||||
const stats = ref('')
|
||||
|
||||
const history = useHistory('json-format')
|
||||
|
||||
const EXAMPLE = `{"name":"张三","age":18,"tags":["vue","vite"],"address":{"city":"北京","district":"朝阳区"}}`
|
||||
|
||||
function loadExample() {
|
||||
input.value = EXAMPLE
|
||||
}
|
||||
|
||||
/** 解析输入并返回 JSON 对象;失败时设置错误信息并返回 null */
|
||||
function parseInput(): unknown | null {
|
||||
error.value = ''
|
||||
if (!input.value.trim()) {
|
||||
error.value = '请输入 JSON 文本'
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(input.value)
|
||||
} catch (e) {
|
||||
error.value = `JSON 校验失败:${e instanceof Error ? e.message : String(e)}`
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function doFormat() {
|
||||
const data = parseInput()
|
||||
if (data === null) return
|
||||
const space = indent.value === 'tab' ? '\t' : indent.value
|
||||
output.value = JSON.stringify(data, null, space)
|
||||
stats.value = `合法 JSON · ${countNodes(data)} 个节点`
|
||||
history.add(input.value)
|
||||
}
|
||||
|
||||
function doMinify() {
|
||||
const data = parseInput()
|
||||
if (data === null) return
|
||||
output.value = JSON.stringify(data)
|
||||
stats.value = `合法 JSON · ${countNodes(data)} 个节点`
|
||||
history.add(input.value)
|
||||
}
|
||||
|
||||
function doValidate() {
|
||||
const data = parseInput()
|
||||
if (data === null) return
|
||||
stats.value = `校验通过 · ${countNodes(data)} 个节点`
|
||||
MessagePlugin.success('JSON 校验通过')
|
||||
history.add(input.value)
|
||||
}
|
||||
|
||||
/** 递归统计 JSON 节点数量(用于结果展示) */
|
||||
function countNodes(data: unknown): number {
|
||||
let n = 1
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((item) => (n += countNodes(item)))
|
||||
} else if (data && typeof data === 'object') {
|
||||
Object.values(data as Record<string, unknown>).forEach((v) => (n += countNodes(v)))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function downloadResult() {
|
||||
if (output.value) downloadText(output.value, genFilename('json', 'json'), 'application/json')
|
||||
}
|
||||
|
||||
function onHistorySelect(text: string) {
|
||||
input.value = text
|
||||
doFormat()
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
input.value = ''
|
||||
output.value = ''
|
||||
error.value = ''
|
||||
stats.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-label {
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.form-select {
|
||||
width: 130px;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.result-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
231
src/views/jwt-parse/JwtParseView.vue
Normal file
231
src/views/jwt-parse/JwtParseView.vue
Normal file
@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">JWT 解析</h2>
|
||||
<p class="page-desc">
|
||||
输入 JWT 字符串,解析并展示 Header 与 Payload(仅解码,不做签名校验与解密)。
|
||||
</p>
|
||||
|
||||
<t-card class="tool-section" title="输入 JWT">
|
||||
<t-textarea
|
||||
v-model="jwt"
|
||||
placeholder="粘贴 JWT 字符串,例如 eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature"
|
||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" @click="parse">解析</t-button>
|
||||
<t-button variant="outline" @click="clearAll">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<template v-if="parsed">
|
||||
<!-- 概览信息 -->
|
||||
<t-card class="tool-section" title="Token 概览">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">算法 alg</span>
|
||||
<span class="info-value">{{ header.alg || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">类型 typ</span>
|
||||
<span class="info-value">{{ header.typ || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">签发者 iss</span>
|
||||
<span class="info-value">{{ payload.iss || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">主题 sub</span>
|
||||
<span class="info-value">{{ payload.sub || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">受众 aud</span>
|
||||
<span class="info-value">{{ String(payload.aud ?? '-') }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">状态</span>
|
||||
<span class="info-value">
|
||||
<t-tag :theme="expired === null ? 'default' : expired ? 'danger' : 'success'" variant="light">
|
||||
{{ expired === null ? '无过期时间' : expired ? '已过期' : '有效期内' }}
|
||||
</t-tag>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="time-row" v-if="payload.iat !== undefined">
|
||||
<span class="info-label">签发时间 iat</span>
|
||||
<span class="info-value">{{ formatTime(payload.iat) }}</span>
|
||||
</div>
|
||||
<div class="time-row" v-if="payload.nbf !== undefined">
|
||||
<span class="info-label">生效时间 nbf</span>
|
||||
<span class="info-value">{{ formatTime(payload.nbf) }}</span>
|
||||
</div>
|
||||
<div class="time-row" v-if="payload.exp !== undefined">
|
||||
<span class="info-label">过期时间 exp</span>
|
||||
<span class="info-value">{{ formatTime(payload.exp) }}</span>
|
||||
</div>
|
||||
<t-alert
|
||||
class="notice"
|
||||
theme="info"
|
||||
message="本工具仅对 JWT 做 Base64 解码展示,不校验签名,请勿用于安全验证场景。"
|
||||
/>
|
||||
</t-card>
|
||||
|
||||
<!-- Header / Payload 详情 -->
|
||||
<t-card class="tool-section" title="Header">
|
||||
<div class="code-block">{{ headerText }}</div>
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="headerText" label="复制 Header" />
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card class="tool-section" title="Payload">
|
||||
<div class="code-block">{{ payloadText }}</div>
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="payloadText" label="复制 Payload" />
|
||||
</div>
|
||||
</t-card>
|
||||
</template>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { formatBeijing } from '@/utils'
|
||||
|
||||
const jwt = ref('')
|
||||
const error = ref('')
|
||||
|
||||
const header = ref<Record<string, unknown>>({})
|
||||
const payload = ref<Record<string, unknown>>({})
|
||||
const headerText = ref('')
|
||||
const payloadText = ref('')
|
||||
const parsed = ref(false)
|
||||
|
||||
const history = useHistory('jwt-parse')
|
||||
|
||||
/** 是否过期:exp 与当前时间比较 */
|
||||
const expired = computed<boolean | null>(() => {
|
||||
const exp = payload.value.exp
|
||||
if (typeof exp !== 'number') return null
|
||||
return exp * 1000 < Date.now()
|
||||
})
|
||||
|
||||
/** Base64Url 解码(JWT 专用:自动补位 + UTF-8 解码) */
|
||||
function base64UrlDecode(seg: string): string {
|
||||
const b64 = seg.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
|
||||
const bin = atob(padded)
|
||||
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
function parse() {
|
||||
error.value = ''
|
||||
resetResult()
|
||||
const raw = jwt.value.trim()
|
||||
if (!raw) {
|
||||
error.value = '请输入 JWT 字符串'
|
||||
return
|
||||
}
|
||||
const parts = raw.split('.')
|
||||
if (parts.length !== 3) {
|
||||
error.value = '格式不正确:JWT 应由「Header.Payload.Signature」三段组成'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const h = JSON.parse(base64UrlDecode(parts[0]))
|
||||
const p = JSON.parse(base64UrlDecode(parts[1]))
|
||||
if (typeof h !== 'object' || h === null || typeof p !== 'object' || p === null) {
|
||||
throw new Error('not object')
|
||||
}
|
||||
header.value = h as Record<string, unknown>
|
||||
payload.value = p as Record<string, unknown>
|
||||
headerText.value = JSON.stringify(header.value, null, 2)
|
||||
payloadText.value = JSON.stringify(payload.value, null, 2)
|
||||
parsed.value = true
|
||||
history.add(raw)
|
||||
} catch {
|
||||
error.value = '解析失败:Header/Payload 不是合法的 Base64Url 编码 JSON'
|
||||
}
|
||||
}
|
||||
|
||||
/** 时间戳字段(秒)转北京时间;非数字时原样展示 */
|
||||
function formatTime(ts: unknown): string {
|
||||
if (typeof ts !== 'number') return String(ts ?? '-')
|
||||
const d = new Date(ts * 1000)
|
||||
return Number.isNaN(d.getTime()) ? String(ts) : formatBeijing(d, true)
|
||||
}
|
||||
|
||||
function resetResult() {
|
||||
parsed.value = false
|
||||
header.value = {}
|
||||
payload.value = {}
|
||||
headerText.value = ''
|
||||
payloadText.value = ''
|
||||
}
|
||||
|
||||
function onHistorySelect(text: string) {
|
||||
jwt.value = text
|
||||
parse()
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
jwt.value = ''
|
||||
error.value = ''
|
||||
resetResult()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
.time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
.info-value {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
201
src/views/ocr/OcrView.vue
Normal file
201
src/views/ocr/OcrView.vue
Normal file
@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">图片 OCR 识别</h2>
|
||||
<p class="page-desc">
|
||||
上传图片,使用 WASM 版 Tesseract 在浏览器本地识别中英文文字,图片不会上传服务器。
|
||||
</p>
|
||||
|
||||
<t-card class="tool-section" title="上传图片">
|
||||
<t-upload
|
||||
v-model="fileList"
|
||||
theme="file"
|
||||
:auto-upload="false"
|
||||
:show-upload-progress="false"
|
||||
:max="1"
|
||||
accept="image/*"
|
||||
placeholder="点击上传或拖拽图片到此处(支持中文/英文识别)"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<img v-if="previewUrl" :src="previewUrl" alt="待识别图片" class="preview-img" />
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" :loading="busy" :disabled="!file" @click="recognize">
|
||||
开始识别
|
||||
</t-button>
|
||||
<t-button variant="outline" @click="clearAll">清空</t-button>
|
||||
</div>
|
||||
<t-progress v-if="busy" class="progress" :percentage="progress" :label="progressLabel" />
|
||||
<t-alert
|
||||
class="notice"
|
||||
theme="info"
|
||||
message="首次识别需从官方语言包源下载中英文语言数据(约 20MB,浏览器会缓存)。如需完全离线部署,可将语言包放入 public/traineddata 并修改 LANG_PATH 常量(见 agents.md)。"
|
||||
/>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<t-card v-if="resultText" class="tool-section" title="识别结果">
|
||||
<t-textarea v-model="resultText" :autosize="{ minRows: 6, maxRows: 16 }" readonly />
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="resultText" />
|
||||
<t-button variant="outline" size="small" @click="downloadResult">
|
||||
<template #icon><DownloadIcon /></template>
|
||||
下载 TXT
|
||||
</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="(t: string) => (resultText = t)"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import type { UploadFile } from 'tdesign-vue-next'
|
||||
import { DownloadIcon } from 'tdesign-icons-vue-next'
|
||||
import { createWorker } from 'tesseract.js'
|
||||
// 将 worker 与 wasm 核心打包进产物(依赖全部来自 npm,不引入 CDN 脚本)
|
||||
import TesseractWorker from 'tesseract.js/dist/worker.min.js?url'
|
||||
import TesseractCore from 'tesseract.js-core/tesseract-core.wasm.js?url'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { downloadText } from '@/composables/useDownload'
|
||||
import { genFilename } from '@/utils'
|
||||
|
||||
/**
|
||||
* 语言包路径。语言数据属于运行时资源(非代码库),默认使用官方托管源;
|
||||
* 如需完全离线:将 *.traineddata.gz 放入 public/traineddata/ 后改为
|
||||
* `${window.location.origin}/traineddata`。
|
||||
*/
|
||||
const LANG_PATH = 'https://tessdata.projectnaptha.com/4.0.0'
|
||||
|
||||
/** Worker 实例类型(避免依赖内部类型导出,保证类型兼容) */
|
||||
type WorkerInstance = Awaited<ReturnType<typeof createWorker>>
|
||||
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
const file = ref<File | null>(null)
|
||||
const previewUrl = ref('')
|
||||
const busy = ref(false)
|
||||
const progress = ref(0)
|
||||
const progressLabel = ref('')
|
||||
const error = ref('')
|
||||
const resultText = ref('')
|
||||
|
||||
const history = useHistory('ocr')
|
||||
|
||||
let worker: WorkerInstance | null = null
|
||||
|
||||
/** 懒创建并复用 worker,避免每次识别重复初始化 */
|
||||
async function getWorker(): Promise<WorkerInstance> {
|
||||
if (worker) return worker
|
||||
worker = await createWorker(['chi_sim', 'eng'], 1, {
|
||||
workerPath: TesseractWorker,
|
||||
corePath: TesseractCore,
|
||||
langPath: LANG_PATH,
|
||||
gzip: true,
|
||||
logger: (m: { status: string; progress: number }) => {
|
||||
// 仅在识别阶段更新进度条
|
||||
if (m.status === 'recognizing text') {
|
||||
progress.value = Math.round(m.progress * 100)
|
||||
progressLabel.value = `识别中 ${progress.value}%`
|
||||
}
|
||||
},
|
||||
})
|
||||
return worker
|
||||
}
|
||||
|
||||
function onFileChange(list: UploadFile[]) {
|
||||
fileList.value = list
|
||||
error.value = ''
|
||||
resultText.value = ''
|
||||
const raw = list[0]?.raw
|
||||
if (raw instanceof File) {
|
||||
file.value = raw
|
||||
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = URL.createObjectURL(raw)
|
||||
} else {
|
||||
file.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function recognize() {
|
||||
if (!file.value) {
|
||||
error.value = '请先上传图片'
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
progress.value = 0
|
||||
progressLabel.value = '初始化引擎…'
|
||||
error.value = ''
|
||||
try {
|
||||
const w = await getWorker()
|
||||
const { data } = await w.recognize(file.value)
|
||||
resultText.value = data.text.trim()
|
||||
if (!resultText.value) {
|
||||
error.value = '未识别到文字,请尝试更清晰的图片'
|
||||
} else {
|
||||
history.add(resultText.value.slice(0, 500))
|
||||
}
|
||||
} catch {
|
||||
error.value = '识别失败:请检查网络(首次需下载语言包)或换一张图片重试'
|
||||
} finally {
|
||||
busy.value = false
|
||||
progress.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function downloadResult() {
|
||||
if (resultText.value) downloadText(resultText.value, genFilename('ocr', 'txt'))
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
fileList.value = []
|
||||
file.value = null
|
||||
error.value = ''
|
||||
resultText.value = ''
|
||||
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
// 页面卸载时释放 worker 资源
|
||||
if (worker) {
|
||||
try {
|
||||
await worker.terminate()
|
||||
} catch {
|
||||
/* 忽略终止异常 */
|
||||
}
|
||||
worker = null
|
||||
}
|
||||
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.preview-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
margin-top: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
.progress {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
251
src/views/qrcode/QrcodeView.vue
Normal file
251
src/views/qrcode/QrcodeView.vue
Normal file
@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">二维码生成 / 解析</h2>
|
||||
<p class="page-desc">输入文本或链接生成二维码;上传二维码图片解析其内容。全部在浏览器本地完成。</p>
|
||||
|
||||
<t-tabs v-model="tab">
|
||||
<!-- ==================== 生成 ==================== -->
|
||||
<t-tab-panel value="generate" label="生成二维码">
|
||||
<t-card class="tool-section" title="内容与样式">
|
||||
<t-textarea
|
||||
v-model="text"
|
||||
placeholder="输入要生成二维码的文本或链接,例如 https://example.com"
|
||||
:maxlength="2000"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
/>
|
||||
<div class="form-row" style="margin-top: 12px">
|
||||
<span class="form-label">尺寸</span>
|
||||
<t-select v-model="size" :options="sizeOptions" class="form-select" />
|
||||
<span class="form-label">容错率</span>
|
||||
<t-select v-model="level" :options="levelOptions" class="form-select" />
|
||||
</div>
|
||||
<div class="form-row" style="margin-top: 12px">
|
||||
<span class="form-label">前景色</span>
|
||||
<input v-model="dark" type="color" class="color-input" />
|
||||
<span class="form-label">背景色</span>
|
||||
<input v-model="light" type="color" class="color-input" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" :loading="genBusy" @click="generate">生成二维码</t-button>
|
||||
<t-button variant="outline" @click="clearGen">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="genError" @close="genError = ''" />
|
||||
|
||||
<t-card v-if="qrUrl" class="tool-section" title="生成结果">
|
||||
<div class="qr-result">
|
||||
<img :src="qrUrl" alt="生成的二维码" class="qr-img" />
|
||||
<div class="form-actions">
|
||||
<DownloadButton :filename="`qrcode_${size}.png`" :get-blob="getQrBlob" label="下载 PNG" />
|
||||
<CopyButton :text="text" label="复制内容" />
|
||||
</div>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="(t: string) => (text = t)"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</t-tab-panel>
|
||||
|
||||
<!-- ==================== 解析 ==================== -->
|
||||
<t-tab-panel value="parse" label="解析二维码">
|
||||
<t-card class="tool-section" title="上传二维码图片">
|
||||
<t-upload
|
||||
v-model="parseFileList"
|
||||
theme="file"
|
||||
:auto-upload="false"
|
||||
:show-upload-progress="false"
|
||||
:max="1"
|
||||
accept="image/*"
|
||||
placeholder="点击上传或拖拽二维码图片到此处"
|
||||
@change="onParseFileChange"
|
||||
/>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="parseError" @close="parseError = ''" />
|
||||
|
||||
<t-card v-if="parseResult" class="tool-section" title="解析结果">
|
||||
<div class="code-block">{{ parseResult }}</div>
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="parseResult" />
|
||||
</div>
|
||||
</t-card>
|
||||
<t-card v-else-if="parsed" class="tool-section" title="解析结果">
|
||||
<t-alert theme="warning" message="未能从图片中识别出二维码,请确认图片清晰且包含完整二维码" />
|
||||
</t-card>
|
||||
|
||||
<HistoryPanel
|
||||
:items="parseHistory.items"
|
||||
@select="(t: string) => (parseResult = t)"
|
||||
@remove="parseHistory.remove"
|
||||
@clear="parseHistory.clear"
|
||||
/>
|
||||
</t-tab-panel>
|
||||
</t-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { UploadFile } from 'tdesign-vue-next'
|
||||
import QRCode from 'qrcode'
|
||||
import jsQR from 'jsqr'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import DownloadButton from '@/components/common/DownloadButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { dataUrlToBlob } from '@/utils'
|
||||
|
||||
const tab = ref('generate')
|
||||
|
||||
/* ---------- 生成 ---------- */
|
||||
const sizeOptions = [
|
||||
{ label: '128 × 128', value: 128 },
|
||||
{ label: '256 × 256', value: 256 },
|
||||
{ label: '512 × 512', value: 512 },
|
||||
{ label: '1024 × 1024', value: 1024 },
|
||||
]
|
||||
const levelOptions = ['L', 'M', 'Q', 'H'].map((v) => ({ label: v, value: v }))
|
||||
|
||||
const text = ref('')
|
||||
const size = ref(256)
|
||||
const level = ref('M')
|
||||
const dark = ref('#000000')
|
||||
const light = ref('#ffffff')
|
||||
const genBusy = ref(false)
|
||||
const genError = ref('')
|
||||
const qrUrl = ref('')
|
||||
|
||||
const history = useHistory('qrcode-generate')
|
||||
|
||||
/** 生成二维码:qrcode 库渲染到离屏 canvas 后导出 DataURL */
|
||||
async function generate() {
|
||||
if (!text.value.trim()) {
|
||||
genError.value = '请输入要生成二维码的内容'
|
||||
return
|
||||
}
|
||||
genBusy.value = true
|
||||
genError.value = ''
|
||||
qrUrl.value = ''
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
await QRCode.toCanvas(canvas, text.value, {
|
||||
width: size.value,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: level.value as 'L' | 'M' | 'Q' | 'H',
|
||||
color: { dark: dark.value, light: light.value },
|
||||
})
|
||||
qrUrl.value = canvas.toDataURL('image/png')
|
||||
history.add(text.value)
|
||||
} catch {
|
||||
genError.value = '生成失败:内容过长或参数异常,请缩短内容后重试'
|
||||
} finally {
|
||||
genBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getQrBlob(): Blob | null {
|
||||
if (!qrUrl.value) return null
|
||||
try {
|
||||
return dataUrlToBlob(qrUrl.value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clearGen() {
|
||||
text.value = ''
|
||||
qrUrl.value = ''
|
||||
genError.value = ''
|
||||
}
|
||||
|
||||
/* ---------- 解析 ---------- */
|
||||
const parseFileList = ref<UploadFile[]>([])
|
||||
const parseError = ref('')
|
||||
const parseResult = ref('')
|
||||
const parsed = ref(false)
|
||||
const parseHistory = useHistory('qrcode-parse')
|
||||
|
||||
async function onParseFileChange(list: UploadFile[]) {
|
||||
parseFileList.value = list
|
||||
parseError.value = ''
|
||||
parseResult.value = ''
|
||||
parsed.value = false
|
||||
const raw = list[0]?.raw
|
||||
if (!(raw instanceof File)) return
|
||||
|
||||
try {
|
||||
// 将图片绘制到 canvas 获取像素数据,再用 jsqr 识别
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(raw)
|
||||
})
|
||||
const img = new Image()
|
||||
img.src = dataUrl
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve
|
||||
img.onerror = () => reject(new Error('图片加载失败'))
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('无法创建画布')
|
||||
ctx.drawImage(img, 0, 0)
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const result = jsQR(imageData.data, imageData.width, imageData.height)
|
||||
parsed.value = true
|
||||
if (result?.data) {
|
||||
parseResult.value = result.data
|
||||
parseHistory.add(result.data)
|
||||
}
|
||||
} catch {
|
||||
parseError.value = '解析失败:图片格式不受支持,请换一张图片重试'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-label {
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.form-select {
|
||||
width: 150px;
|
||||
}
|
||||
.color-input {
|
||||
width: 44px;
|
||||
height: 32px;
|
||||
padding: 0 2px;
|
||||
border: 1px solid var(--td-border-level-2-color);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.qr-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.qr-img {
|
||||
max-width: 280px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
</style>
|
||||
150
src/views/radix-convert/RadixConvertView.vue
Normal file
150
src/views/radix-convert/RadixConvertView.vue
Normal file
@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">进制转换</h2>
|
||||
<p class="page-desc">二进制、八进制、十进制、十六进制互转,支持大整数(BigInt)与负数。</p>
|
||||
|
||||
<t-card class="tool-section" title="输入">
|
||||
<t-input v-model="input" placeholder="输入要转换的数字,例如 255 / 0xFF / 11111111" clearable />
|
||||
<div class="form-row" style="margin-top: 12px">
|
||||
<span class="form-label">输入进制</span>
|
||||
<t-select v-model="fromBase" :options="baseOptions" class="form-select" />
|
||||
<t-button theme="primary" @click="convert">转换</t-button>
|
||||
<t-button variant="outline" @click="clearAll">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<t-card v-if="results" class="tool-section" title="转换结果">
|
||||
<div v-for="r in results" :key="r.label" class="result-row">
|
||||
<div class="result-head">
|
||||
<span class="result-label">{{ r.label }}</span>
|
||||
<CopyButton :text="r.value" label="复制" />
|
||||
</div>
|
||||
<div class="code-block">{{ r.value }}</div>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
|
||||
const baseOptions = [
|
||||
{ label: '二进制 (base 2)', value: 2 },
|
||||
{ label: '八进制 (base 8)', value: 8 },
|
||||
{ label: '十进制 (base 10)', value: 10 },
|
||||
{ label: '十六进制 (base 16)', value: 16 },
|
||||
]
|
||||
|
||||
const input = ref('')
|
||||
const fromBase = ref(10)
|
||||
const error = ref('')
|
||||
const results = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
const history = useHistory('radix-convert')
|
||||
|
||||
/** 单个字符的数字值(16 进制以内) */
|
||||
function digitValue(ch: string): number {
|
||||
const code = ch.charCodeAt(0)
|
||||
if (code >= 48 && code <= 57) return code - 48
|
||||
if (code >= 97 && code <= 102) return code - 87
|
||||
if (code >= 65 && code <= 70) return code - 55
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 按任意进制解析为 BigInt。
|
||||
* 支持负号、0x/0o/0b 前缀、下划线分隔,避免 Number 精度丢失。
|
||||
*/
|
||||
function parseBigInt(raw: string, base: number): bigint | null {
|
||||
let s = raw.trim()
|
||||
if (!s) return null
|
||||
let negative = false
|
||||
if (s.startsWith('-')) {
|
||||
negative = true
|
||||
s = s.slice(1)
|
||||
}
|
||||
if (base === 16) s = s.replace(/^0x/i, '')
|
||||
else if (base === 8) s = s.replace(/^0o/i, '')
|
||||
else if (base === 2) s = s.replace(/^0b/i, '')
|
||||
s = s.replace(/_/g, '')
|
||||
if (!s) return null
|
||||
let acc = 0n
|
||||
const b = BigInt(base)
|
||||
for (const ch of s) {
|
||||
const d = digitValue(ch)
|
||||
if (d < 0 || d >= base) return null
|
||||
acc = acc * b + BigInt(d)
|
||||
}
|
||||
return negative ? -acc : acc
|
||||
}
|
||||
|
||||
function convert() {
|
||||
error.value = ''
|
||||
results.value = []
|
||||
const value = parseBigInt(input.value, fromBase.value)
|
||||
if (value === null) {
|
||||
error.value = `输入不是合法的${baseOptions.find((b) => b.value === fromBase.value)?.label ?? ''}数字`
|
||||
return
|
||||
}
|
||||
results.value = [
|
||||
{ label: '二进制 (base 2)', value: value.toString(2) },
|
||||
{ label: '八进制 (base 8)', value: value.toString(8) },
|
||||
{ label: '十进制 (base 10)', value: value.toString(10) },
|
||||
{ label: '十六进制 (base 16)', value: value.toString(16).toUpperCase() },
|
||||
]
|
||||
history.add(`${input.value} [base ${fromBase.value}]`)
|
||||
}
|
||||
|
||||
function onHistorySelect(text: string) {
|
||||
const m = /^(.*) \[base (\d+)\]$/.exec(text)
|
||||
if (m) {
|
||||
input.value = m[1]
|
||||
fromBase.value = Number(m[2])
|
||||
convert()
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
input.value = ''
|
||||
error.value = ''
|
||||
results.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-label {
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.form-select {
|
||||
width: 200px;
|
||||
}
|
||||
.result-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.result-label {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
219
src/views/timestamp/TimestampView.vue
Normal file
219
src/views/timestamp/TimestampView.vue
Normal file
@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">时间戳转换</h2>
|
||||
<p class="page-desc">时间戳与北京时间(UTC+8)互转,支持秒/毫秒与批量转换。</p>
|
||||
|
||||
<t-tabs v-model="tab">
|
||||
<!-- 时间戳 -> 时间 -->
|
||||
<t-tab-panel value="ts2date" label="时间戳 → 时间">
|
||||
<t-card class="tool-section" title="转换">
|
||||
<t-input v-model="tsInput" placeholder="输入时间戳,例如 1704067200" clearable />
|
||||
<div class="form-row" style="margin-top: 12px">
|
||||
<span class="form-label">单位</span>
|
||||
<t-select v-model="unit" :options="unitOptions" class="form-select" />
|
||||
<t-button variant="outline" @click="fillNow">当前时间</t-button>
|
||||
<t-button theme="primary" @click="convertOne">转换</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
<t-card v-if="oneResult" class="tool-section" title="转换结果">
|
||||
<div class="code-block">{{ oneResult }}</div>
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="oneResult" />
|
||||
</div>
|
||||
</t-card>
|
||||
</t-tab-panel>
|
||||
|
||||
<!-- 时间 -> 时间戳 -->
|
||||
<t-tab-panel value="date2ts" label="时间 → 时间戳">
|
||||
<t-card class="tool-section" title="选择北京时间">
|
||||
<t-date-picker
|
||||
v-model="dateValue"
|
||||
enable-time-picker
|
||||
allow-input
|
||||
clearable
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-type="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择日期时间"
|
||||
/>
|
||||
<div class="form-row" style="margin-top: 12px">
|
||||
<span class="form-label">单位</span>
|
||||
<t-select v-model="unit2" :options="unitOptions" class="form-select" />
|
||||
<t-button theme="primary" @click="convertDate">转换</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
<ErrorAlert :message="error2" @close="error2 = ''" />
|
||||
<t-card v-if="dateResult" class="tool-section" title="转换结果">
|
||||
<div class="code-block">{{ dateResult }}</div>
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="dateResult" />
|
||||
</div>
|
||||
</t-card>
|
||||
</t-tab-panel>
|
||||
|
||||
<!-- 批量转换 -->
|
||||
<t-tab-panel value="batch" label="批量转换">
|
||||
<t-card class="tool-section" title="批量时间戳 → 北京时间">
|
||||
<t-textarea
|
||||
v-model="batchInput"
|
||||
placeholder="每行一个时间戳,例如: 1704067200 1704067200000"
|
||||
:autosize="{ minRows: 6, maxRows: 12 }"
|
||||
/>
|
||||
<div class="form-row" style="margin-top: 12px">
|
||||
<span class="form-label">单位</span>
|
||||
<t-select v-model="unit3" :options="unitOptions" class="form-select" />
|
||||
<t-button theme="primary" @click="convertBatch">批量转换</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
<t-card v-if="batchResult" class="tool-section" title="批量结果">
|
||||
<t-textarea v-model="batchResult" :autosize="{ minRows: 6, maxRows: 16 }" readonly />
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="batchResult" />
|
||||
<t-button variant="outline" size="small" @click="downloadBatch">
|
||||
<template #icon><DownloadIcon /></template>
|
||||
下载
|
||||
</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
</t-tab-panel>
|
||||
</t-tabs>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { DownloadIcon } from 'tdesign-icons-vue-next'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { downloadText } from '@/composables/useDownload'
|
||||
import { formatBeijing, genFilename } from '@/utils'
|
||||
|
||||
const unitOptions = [
|
||||
{ label: '秒', value: 's' },
|
||||
{ label: '毫秒', value: 'ms' },
|
||||
]
|
||||
|
||||
const tab = ref('ts2date')
|
||||
|
||||
/* ---------- 时间戳 -> 时间 ---------- */
|
||||
const tsInput = ref('')
|
||||
const unit = ref('s')
|
||||
const error = ref('')
|
||||
const oneResult = ref('')
|
||||
|
||||
const history = useHistory('timestamp')
|
||||
|
||||
function fillNow() {
|
||||
tsInput.value = String(unit.value === 's' ? Math.floor(Date.now() / 1000) : Date.now())
|
||||
}
|
||||
|
||||
/** 将输入按单位转换为时间戳(毫秒) */
|
||||
function parseTs(input: string, u: string): number | null {
|
||||
const n = Number(input.trim())
|
||||
if (!input.trim() || !Number.isFinite(n)) return null
|
||||
return u === 's' ? n * 1000 : n
|
||||
}
|
||||
|
||||
function convertOne() {
|
||||
error.value = ''
|
||||
oneResult.value = ''
|
||||
const ts = parseTs(tsInput.value, unit.value)
|
||||
if (ts === null) {
|
||||
error.value = '请输入合法的数字时间戳'
|
||||
return
|
||||
}
|
||||
const d = new Date(ts)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
error.value = '时间戳超出可表示范围'
|
||||
return
|
||||
}
|
||||
oneResult.value = `北京时间:${formatBeijing(d, true)}\n本地时间:${d.toLocaleString()}`
|
||||
history.add(tsInput.value)
|
||||
}
|
||||
|
||||
/* ---------- 时间 -> 时间戳 ---------- */
|
||||
const dateValue = ref('')
|
||||
const unit2 = ref('s')
|
||||
const error2 = ref('')
|
||||
const dateResult = ref('')
|
||||
|
||||
function convertDate() {
|
||||
error2.value = ''
|
||||
dateResult.value = ''
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}):(\d{2}))?$/.exec(dateValue.value.trim())
|
||||
if (!m) {
|
||||
error2.value = '请选择合法的日期时间'
|
||||
return
|
||||
}
|
||||
const [y, mo, d, h = 0, mi = 0, s = 0] = m.slice(1).map(Number)
|
||||
// 将输入的北京时间按 UTC+8 换算为 UTC 毫秒时间戳
|
||||
const ts = Date.UTC(y, mo - 1, d, h, mi, s) - 8 * 3600 * 1000
|
||||
dateResult.value =
|
||||
unit2.value === 's' ? `${Math.floor(ts / 1000)}(秒)` : `${ts}(毫秒)`
|
||||
history.add(dateValue.value)
|
||||
}
|
||||
|
||||
/* ---------- 批量转换 ---------- */
|
||||
const batchInput = ref('')
|
||||
const unit3 = ref('s')
|
||||
const batchResult = ref('')
|
||||
|
||||
function convertBatch() {
|
||||
batchResult.value = batchInput.value
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const t = line.trim()
|
||||
if (!t) return ''
|
||||
const ts = parseTs(t, unit3.value)
|
||||
if (ts === null) return `${t} => 【无效时间戳】`
|
||||
const d = new Date(ts)
|
||||
if (Number.isNaN(d.getTime())) return `${t} => 【超出范围】`
|
||||
return `${t} => ${formatBeijing(d, true)}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function downloadBatch() {
|
||||
if (batchResult.value) downloadText(batchResult.value, genFilename('timestamp', 'txt'))
|
||||
}
|
||||
|
||||
function onHistorySelect(text: string) {
|
||||
// 历史内容若为时间戳数字则恢复到「时间戳→时间」,否则恢复到「时间→时间戳」
|
||||
if (/^\d+$/.test(text.trim())) {
|
||||
tab.value = 'ts2date'
|
||||
tsInput.value = text
|
||||
convertOne()
|
||||
} else {
|
||||
tab.value = 'date2ts'
|
||||
dateValue.value = text
|
||||
convertDate()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-label {
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.form-select {
|
||||
width: 110px;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
287
src/views/url-parse/UrlParseView.vue
Normal file
287
src/views/url-parse/UrlParseView.vue
Normal file
@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">URL 解析</h2>
|
||||
<p class="page-desc">解析 URL 的协议、域名、路径与查询参数,也支持通过表单构造 URL。</p>
|
||||
|
||||
<t-tabs v-model="tab">
|
||||
<!-- ==================== 解析 ==================== -->
|
||||
<t-tab-panel value="parse" label="解析 URL">
|
||||
<t-card class="tool-section" title="输入 URL">
|
||||
<t-input v-model="urlInput" placeholder="例如 https://www.example.com:8080/path/to?name=张三&page=2#section" clearable />
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" @click="parseUrl">解析</t-button>
|
||||
<t-button variant="outline" @click="clearParse">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="error" @close="error = ''" />
|
||||
|
||||
<template v-if="parsed">
|
||||
<t-card class="tool-section" title="基本信息">
|
||||
<div class="info-grid">
|
||||
<div v-for="item in basicInfo" :key="item.label" class="info-item">
|
||||
<span class="info-label">{{ item.label }}</span>
|
||||
<span class="info-value">{{ item.value || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card class="tool-section" title="查询参数">
|
||||
<div v-if="params.length" class="param-list">
|
||||
<div v-for="(p, i) in params" :key="i" class="param-row">
|
||||
<span class="param-key">{{ p.key }}</span>
|
||||
<span class="param-value">{{ p.value }}</span>
|
||||
<CopyButton :text="p.value" label="复制值" />
|
||||
</div>
|
||||
</div>
|
||||
<t-empty v-else description="无查询参数" />
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="paramsText" label="复制参数(JSON)" />
|
||||
</div>
|
||||
</t-card>
|
||||
</template>
|
||||
</t-tab-panel>
|
||||
|
||||
<!-- ==================== 构造 ==================== -->
|
||||
<t-tab-panel value="build" label="构造 URL">
|
||||
<t-card class="tool-section" title="基础信息">
|
||||
<div class="form-row">
|
||||
<span class="form-label">协议</span>
|
||||
<t-select v-model="buildScheme" :options="schemeOptions" class="form-select" />
|
||||
<span class="form-label">域名</span>
|
||||
<t-input v-model="buildHost" placeholder="example.com" class="form-input" />
|
||||
</div>
|
||||
<div class="form-row" style="margin-top: 12px">
|
||||
<span class="form-label">路径</span>
|
||||
<t-input v-model="buildPath" placeholder="/path/to" class="form-input" />
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<t-card class="tool-section" title="查询参数">
|
||||
<div v-for="(row, i) in buildParams" :key="i" class="param-edit-row">
|
||||
<t-input v-model="row.key" placeholder="参数名" class="param-input" />
|
||||
<t-input v-model="row.value" placeholder="参数值" class="param-input" />
|
||||
<t-button variant="outline" size="small" theme="danger" @click="buildParams.splice(i, 1)">
|
||||
<template #icon><DeleteIcon /></template>
|
||||
</t-button>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<t-button variant="outline" @click="buildParams.push({ key: '', value: '' })">
|
||||
<template #icon><AddIcon /></template>
|
||||
添加参数
|
||||
</t-button>
|
||||
<t-button theme="primary" @click="buildUrl">生成 URL</t-button>
|
||||
<t-button variant="outline" @click="clearBuild">清空</t-button>
|
||||
</div>
|
||||
</t-card>
|
||||
|
||||
<ErrorAlert :message="buildError" @close="buildError = ''" />
|
||||
|
||||
<t-card v-if="builtUrl" class="tool-section" title="生成的 URL">
|
||||
<div class="code-block">{{ builtUrl }}</div>
|
||||
<div class="form-actions">
|
||||
<CopyButton :text="builtUrl" label="复制 URL" />
|
||||
</div>
|
||||
</t-card>
|
||||
</t-tab-panel>
|
||||
</t-tabs>
|
||||
|
||||
<HistoryPanel
|
||||
:items="history.items"
|
||||
@select="onHistorySelect"
|
||||
@remove="history.remove"
|
||||
@clear="history.clear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { AddIcon, DeleteIcon } from 'tdesign-icons-vue-next'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
|
||||
const tab = ref('parse')
|
||||
const history = useHistory('url-parse')
|
||||
|
||||
/* ---------- 解析 ---------- */
|
||||
const urlInput = ref('')
|
||||
const error = ref('')
|
||||
const parsed = ref(false)
|
||||
const basicInfo = ref<{ label: string; value: string }[]>([])
|
||||
const params = ref<{ key: string; value: string }[]>([])
|
||||
|
||||
const paramsText = computed(() =>
|
||||
JSON.stringify(Object.fromEntries(params.value.map((p) => [p.key, p.value])), null, 2),
|
||||
)
|
||||
|
||||
function parseUrl() {
|
||||
error.value = ''
|
||||
resetParse()
|
||||
const raw = urlInput.value.trim()
|
||||
if (!raw) {
|
||||
error.value = '请输入要解析的 URL'
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 未带协议时自动补全 https://
|
||||
const u = new URL(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw) ? raw : `https://${raw}`)
|
||||
basicInfo.value = [
|
||||
{ label: '协议', value: u.protocol },
|
||||
{ label: '域名', value: u.hostname },
|
||||
{ label: '主机(含端口)', value: u.host },
|
||||
{ label: '端口', value: u.port },
|
||||
{ label: '路径', value: u.pathname },
|
||||
{ label: 'Hash 片段', value: u.hash },
|
||||
]
|
||||
params.value = Array.from(u.searchParams.entries()).map(([key, value]) => ({ key, value }))
|
||||
parsed.value = true
|
||||
history.add(raw)
|
||||
} catch {
|
||||
error.value = '解析失败:URL 格式不合法'
|
||||
}
|
||||
}
|
||||
|
||||
function resetParse() {
|
||||
parsed.value = false
|
||||
basicInfo.value = []
|
||||
params.value = []
|
||||
}
|
||||
|
||||
function clearParse() {
|
||||
urlInput.value = ''
|
||||
error.value = ''
|
||||
resetParse()
|
||||
}
|
||||
|
||||
/* ---------- 构造 ---------- */
|
||||
const schemeOptions = [
|
||||
{ label: 'https://', value: 'https' },
|
||||
{ label: 'http://', value: 'http' },
|
||||
]
|
||||
const buildScheme = ref('https')
|
||||
const buildHost = ref('')
|
||||
const buildPath = ref('/')
|
||||
const buildParams = ref<{ key: string; value: string }[]>([{ key: '', value: '' }])
|
||||
const buildError = ref('')
|
||||
const builtUrl = ref('')
|
||||
|
||||
function buildUrl() {
|
||||
buildError.value = ''
|
||||
builtUrl.value = ''
|
||||
const host = buildHost.value.trim()
|
||||
if (!host) {
|
||||
buildError.value = '请输入域名'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const base = `${buildScheme.value}://${host}`
|
||||
const u = new URL(base)
|
||||
// 路径允许为空或自动补前导斜杠
|
||||
const path = buildPath.value.trim()
|
||||
u.pathname = path ? (path.startsWith('/') ? path : `/${path}`) : '/'
|
||||
buildParams.value.forEach((row) => {
|
||||
if (row.key.trim()) u.searchParams.append(row.key.trim(), row.value)
|
||||
})
|
||||
builtUrl.value = u.toString()
|
||||
history.add(builtUrl.value)
|
||||
} catch {
|
||||
buildError.value = '生成失败:域名或路径不合法'
|
||||
}
|
||||
}
|
||||
|
||||
function clearBuild() {
|
||||
buildHost.value = ''
|
||||
buildPath.value = '/'
|
||||
buildParams.value = [{ key: '', value: '' }]
|
||||
buildError.value = ''
|
||||
builtUrl.value = ''
|
||||
}
|
||||
|
||||
/* ---------- 历史 ---------- */
|
||||
function onHistorySelect(text: string) {
|
||||
tab.value = 'parse'
|
||||
urlInput.value = text
|
||||
parseUrl()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.form-label {
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.form-select {
|
||||
width: 130px;
|
||||
}
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
.info-value {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.param-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
.param-key {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--td-brand-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.param-value {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.param-edit-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.param-input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||
}
|
||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "unocss.config.ts"]
|
||||
}
|
||||
18
unocss.config.ts
Normal file
18
unocss.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, presetUno, transformerDirectives } from 'unocss'
|
||||
|
||||
/**
|
||||
* UnoCSS 配置:仅使用 presetUno(类 Tailwind 原子类),
|
||||
* 颜色值统一走 TDesign 的 CSS 变量,自动适配暗黑/浅色主题。
|
||||
*/
|
||||
export default defineConfig({
|
||||
presets: [presetUno()],
|
||||
transformers: [transformerDirectives()],
|
||||
shortcuts: {
|
||||
// 工具页面通用容器
|
||||
'page-wrap': 'mx-auto w-full max-w-5xl px-4 py-6',
|
||||
// 表单行:标签 + 控件 的通用栅格(响应式)
|
||||
'form-row': 'flex flex-col gap-2 md:flex-row md:items-center md:gap-4',
|
||||
// 结果代码块
|
||||
'code-block': 'rounded-md bg-[var(--td-bg-color-secondarycontainer)] p-3 font-mono text-13px break-all whitespace-pre-wrap',
|
||||
},
|
||||
})
|
||||
32
vite.config.ts
Normal file
32
vite.config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import UnoCSS from 'unocss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), UnoCSS()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// '@' 指向 src 目录,方便统一引用
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true, // 便于局域网/移动端真机调试
|
||||
},
|
||||
build: {
|
||||
// tesseract.js / cropperjs 等库体积较大,放宽默认告警阈值
|
||||
chunkSizeWarningLimit: 2000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// 体积较大的工具库单独分包,改善首屏加载
|
||||
tesseract: ['tesseract.js'],
|
||||
'tdesign-vendor': ['tdesign-vue-next', 'tdesign-icons-vue-next'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user