feat: 初始化纯前端在线工具站

- Vite + Vue3 + Pinia + TypeScript + UnoCSS + TDesign 技术栈
- 左侧固定导航 + 暗黑/浅色主题 + PC/移动端响应式布局
- 14 个工具:图片压缩/裁剪、二维码、JSON、时间戳、Base64、
  JWT、Cron、进制转换、哈希、身份证、地址解析、URL 解析、OCR
- 本地历史记录(localStorage)与通用复制/下载/错误提示组件
- 所有文件处理均在浏览器本地完成,不上传服务器
This commit is contained in:
夏犀麟 2026-08-21 12:15:33 +08:00
commit e3c1534263
40 changed files with 7248 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# 依赖与构建产物
node_modules/
dist/
# 日志与临时文件
*.log
*.local
.vite/
.tmp/
.DS_Store
# 编辑器
.idea/
.vscode/*
!.vscode/extensions.json

23
index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

8
src/App.vue Normal file
View File

@ -0,0 +1,8 @@
<template>
<router-view />
</template>
<script setup lang="ts">
//
// MainLayout
</script>

View 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);
}

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,80 @@
/** 通用工具函数集合 */
/** 读取 File 为 DataURLbase64 形式) */
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
View 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>

View 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="每行一个地址,例如:&#10;北京市朝阳区望京街道阜通东大街 6 号&#10;广东省深圳市南山区科技园南区深南大道 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>

View 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
View 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
View File

@ -0,0 +1,208 @@
<template>
<div class="page-wrap">
<h2 class="page-title">哈希计算</h2>
<p class="page-desc">对文本或文件计算 MD5SHA1SHA256SHA512 哈希值全部在浏览器本地完成</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) {
// MD5spark-md5SHA 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>

View 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>

View 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>

View 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>

View 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 文本,例如 {&quot;name&quot;:&quot;张三&quot;,&quot;age&quot;: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>

View 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
View 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>

View 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>

View 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>

View 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="每行一个时间戳,例如:&#10;1704067200&#10;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>

View 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
View 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
View 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
View 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
View 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'],
},
},
},
},
})