Initial release of Web Tools 0.0.5
This commit is contained in:
+70
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRight, Menu, Moon, Sun, X } from '@lucide/vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import { categoryById } from '@/tools/registry'
|
||||
import { useNavigation } from '@/composables/useNavigation'
|
||||
|
||||
const route = useRoute()
|
||||
const { navigation, loadNavigation } = useNavigation()
|
||||
const menuOpen = ref(false)
|
||||
const darkMode = ref(false)
|
||||
|
||||
const breadcrumbCategory = computed(() => {
|
||||
if (route.name === 'setting') return '配置'
|
||||
return categoryById.get(String(route.meta.categoryId))?.title ?? '工具箱'
|
||||
})
|
||||
|
||||
function applyTheme(isDark: boolean) {
|
||||
darkMode.value = isDark
|
||||
document.documentElement.dataset.theme = isDark ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
applyTheme(!darkMode.value)
|
||||
localStorage.setItem('web-tools-theme', darkMode.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
menuOpen.value = false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem('web-tools-theme')
|
||||
applyTheme(saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
void loadNavigation()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout" :class="{ 'menu-open': menuOpen }">
|
||||
<AppSidebar :navigation="navigation" @navigate="menuOpen = false" />
|
||||
<button v-if="menuOpen" class="menu-scrim" type="button" aria-label="关闭工具菜单" @click="menuOpen = false" />
|
||||
<main class="app-main">
|
||||
<header class="topbar">
|
||||
<button
|
||||
class="icon-button mobile-menu"
|
||||
type="button"
|
||||
:aria-label="menuOpen ? '关闭工具菜单' : '打开工具菜单'"
|
||||
:aria-expanded="menuOpen"
|
||||
aria-controls="tool-navigation"
|
||||
@click="menuOpen = !menuOpen"
|
||||
>
|
||||
<X v-if="menuOpen" :size="18" />
|
||||
<Menu v-else :size="18" />
|
||||
</button>
|
||||
<span>{{ breadcrumbCategory }}</span>
|
||||
<ChevronRight :size="14" aria-hidden="true" />
|
||||
<strong>{{ route.meta.title }}</strong>
|
||||
<span class="topbar-spacer" />
|
||||
<button class="icon-button" type="button" :aria-label="darkMode ? '切换浅色主题' : '切换深色主题'" @click="toggleTheme">
|
||||
<Sun v-if="darkMode" :size="17" />
|
||||
<Moon v-else :size="17" />
|
||||
</button>
|
||||
</header>
|
||||
<RouterView />
|
||||
<footer class="app-footer">Copyright © mid All Rights Reserved.</footer>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { Info, Search, Wrench } from '@lucide/vue'
|
||||
import packageJson from '../../package.json'
|
||||
import { computed, ref, type DeepReadonly } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { categoryById, toolById } from '@/tools/registry'
|
||||
import type { NavigationConfig } from '@/types'
|
||||
|
||||
const props = defineProps<{ navigation: DeepReadonly<NavigationConfig> }>()
|
||||
const emit = defineEmits<{ navigate: [] }>()
|
||||
const route = useRoute()
|
||||
const query = ref('')
|
||||
const appVersion = packageJson.version
|
||||
|
||||
const sections = computed(() => props.navigation.categories.map((navigationCategory) => {
|
||||
const category = categoryById.get(navigationCategory.id)
|
||||
const tools = navigationCategory.tools
|
||||
.map((toolId) => toolById.get(toolId))
|
||||
.filter((tool) => tool && (!query.value.trim() || tool.title.toLowerCase().includes(query.value.trim().toLowerCase())))
|
||||
return { category, tools }
|
||||
}).filter((section) => section.category && section.tools.length > 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside id="tool-navigation" class="app-sidebar" aria-label="工具导航">
|
||||
<div class="brand-row">
|
||||
<span class="brand-mark"><Wrench :size="17" aria-hidden="true" /></span>
|
||||
<span><strong>Web Tools</strong><small>Personal workspace</small></span>
|
||||
</div>
|
||||
<label class="sidebar-search">
|
||||
<Search :size="15" aria-hidden="true" />
|
||||
<input v-model="query" type="search" placeholder="搜索工具" aria-label="搜索工具" />
|
||||
</label>
|
||||
<nav class="tool-navigation">
|
||||
<section v-for="section in sections" :key="section.category!.id">
|
||||
<h2>{{ section.category!.title }}</h2>
|
||||
<RouterLink
|
||||
v-for="(tool, index) in section.tools"
|
||||
:key="tool!.id"
|
||||
:to="`/tools/${tool!.id}`"
|
||||
:class="{ 'is-active': route.meta.toolId === tool!.id }"
|
||||
@click="emit('navigate')"
|
||||
>
|
||||
<component :is="tool!.iconComponent" :size="16" aria-hidden="true" />
|
||||
<span>{{ tool!.title }}</span>
|
||||
<small>{{ String(index + 1).padStart(2, '0') }}</small>
|
||||
</RouterLink>
|
||||
</section>
|
||||
<p v-if="sections.length === 0" class="empty-search">没有匹配的工具</p>
|
||||
</nav>
|
||||
<div class="sidebar-local"><Info :size="14" aria-hidden="true" />版本 {{ appVersion }}</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts" generic="T extends string">
|
||||
import type { ModeOption } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
modelValue: T
|
||||
options: ModeOption<T>[]
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: T] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mode-tabs" role="group" :aria-label="label">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="mode-tab"
|
||||
:class="{ 'is-active': modelValue === option.value }"
|
||||
:aria-pressed="modelValue === option.value"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { tokenizeSyntaxForDisplay, type SyntaxLanguage } from '@/lib/syntax'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value: string
|
||||
label: string
|
||||
language: SyntaxLanguage
|
||||
format?: string
|
||||
meta?: string
|
||||
}>(), {
|
||||
format: '',
|
||||
meta: '',
|
||||
})
|
||||
|
||||
const tokens = computed(() => tokenizeSyntaxForDisplay(props.value, props.language))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="syntax-viewer" :aria-label="label">
|
||||
<span class="editor-header">
|
||||
<strong>{{ label }}</strong>
|
||||
<span v-if="format">{{ format }}</span>
|
||||
<span v-if="meta" class="editor-meta">{{ meta }}</span>
|
||||
</span>
|
||||
<pre tabindex="0"><code><template v-if="tokens"><span v-for="(token, index) in tokens" :key="index" :class="`syntax-${token.type}`">{{ token.text }}</span></template><template v-else>{{ value }}</template></code></pre>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
label: string
|
||||
format?: string
|
||||
meta?: string
|
||||
placeholder?: string
|
||||
readonly?: boolean
|
||||
rows?: number
|
||||
}>(), {
|
||||
format: '',
|
||||
meta: '',
|
||||
placeholder: '',
|
||||
readonly: false,
|
||||
rows: 9,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
function update(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="editor-panel">
|
||||
<span class="editor-header">
|
||||
<strong>{{ label }}</strong>
|
||||
<span v-if="format">{{ format }}</span>
|
||||
<span v-if="meta" class="editor-meta">{{ meta }}</span>
|
||||
</span>
|
||||
<textarea
|
||||
:value="props.modelValue"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:rows="rows"
|
||||
spellcheck="false"
|
||||
@input="update"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleCheck, LockKeyhole } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
category: string
|
||||
title: string
|
||||
description: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tool-page">
|
||||
<header class="tool-heading">
|
||||
<div>
|
||||
<p class="tool-category">{{ category }}</p>
|
||||
<h1>{{ title }}</h1>
|
||||
<p class="tool-description">{{ description }}</p>
|
||||
</div>
|
||||
<span class="local-state"><CircleCheck :size="15" />本地处理</span>
|
||||
</header>
|
||||
<slot />
|
||||
<div class="privacy-note">
|
||||
<LockKeyhole :size="16" aria-hidden="true" />
|
||||
<span><strong>输入不会上传。</strong> 内容只保留在当前浏览器页面。</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
import defaultNavigationData from '@shared/default-navigation.json'
|
||||
import type { NavigationConfig } from '@/types'
|
||||
|
||||
const navigation = ref<NavigationConfig>(structuredClone(defaultNavigationData) as NavigationConfig)
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
let loaded = false
|
||||
let pendingLoad: Promise<void> | undefined
|
||||
|
||||
export function useNavigation() {
|
||||
async function loadNavigation(force = false) {
|
||||
if (loaded && !force) return
|
||||
if (pendingLoad && !force) return pendingLoad
|
||||
pendingLoad = (async () => {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const response = await fetch('/api/navigation', { headers: { Accept: 'application/json' } })
|
||||
if (!response.ok) throw new Error(`读取配置失败(${response.status})`)
|
||||
navigation.value = await response.json() as NavigationConfig
|
||||
loaded = true
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '读取配置失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
pendingLoad = undefined
|
||||
}
|
||||
})()
|
||||
return pendingLoad
|
||||
}
|
||||
|
||||
async function saveNavigation(next: NavigationConfig) {
|
||||
const response = await fetch('/api/navigation', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(next),
|
||||
})
|
||||
const payload = await response.json().catch(() => ({})) as NavigationConfig & { error?: string }
|
||||
if (!response.ok) throw new Error(payload.error || `保存配置失败(${response.status})`)
|
||||
navigation.value = payload
|
||||
loaded = true
|
||||
return payload
|
||||
}
|
||||
|
||||
function resetLocalNavigation() {
|
||||
return structuredClone(defaultNavigationData) as NavigationConfig
|
||||
}
|
||||
|
||||
return {
|
||||
navigation: readonly(navigation),
|
||||
loading: readonly(loading),
|
||||
loadError: readonly(loadError),
|
||||
loadNavigation,
|
||||
saveNavigation,
|
||||
resetLocalNavigation,
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export async function copyText(value: string): Promise<void> {
|
||||
if (!value) throw new Error('没有可复制的内容')
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
return
|
||||
}
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand('copy')
|
||||
textarea.remove()
|
||||
if (!copied) throw new Error('浏览器不支持自动复制,请手动选择结果')
|
||||
}
|
||||
|
||||
export async function pasteText(): Promise<string> {
|
||||
if (!navigator.clipboard?.readText) throw new Error('当前连接不支持读取剪贴板,请使用 Ctrl+V')
|
||||
return navigator.clipboard.readText()
|
||||
}
|
||||
|
||||
export function downloadBytes(data: Uint8Array, filename: string, type: string): void {
|
||||
const blob = new Blob([Uint8Array.from(data).buffer], { type })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export function parseHex(input: string, exactBytes?: number): Uint8Array {
|
||||
const trimmed = input.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
if (exactBytes === 0 || exactBytes === undefined) return new Uint8Array()
|
||||
throw new Error(`需要 ${exactBytes} 字节,当前为 0 字节`)
|
||||
}
|
||||
|
||||
const tokens = trimmed.split(/[\s,,]+/)
|
||||
const normalizedTokens = tokens.map((token) => token.replace(/^0x/i, ''))
|
||||
const emptyToken = normalizedTokens.findIndex((token) => !token)
|
||||
if (emptyToken !== -1) throw new Error(`第 ${emptyToken + 1} 个十六进制片段缺少数值`)
|
||||
const normalized = normalizedTokens.join('')
|
||||
const invalid = normalized.match(/[^0-9a-f]/i)
|
||||
if (invalid) throw new Error(`包含无效的十六进制字符“${invalid[0]}”`)
|
||||
if (normalized.length % 2 !== 0) throw new Error('十六进制字符数量必须为偶数')
|
||||
|
||||
const byteLength = normalized.length / 2
|
||||
if (exactBytes !== undefined && byteLength !== exactBytes) {
|
||||
throw new Error(`需要 ${exactBytes} 字节,当前为 ${byteLength} 字节`)
|
||||
}
|
||||
|
||||
const result = new Uint8Array(byteLength)
|
||||
for (let index = 0; index < byteLength; index += 1) {
|
||||
result[index] = Number.parseInt(normalized.slice(index * 2, index * 2 + 2), 16)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array, delimiter = ' ', uppercase = true): string {
|
||||
const value = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(delimiter)
|
||||
return uppercase ? value.toUpperCase() : value
|
||||
}
|
||||
|
||||
export type ByteDisplayFormat = 'continuous-hex' | 'spaced-hex' | 'byte-array'
|
||||
|
||||
export interface FormatBytesOptions {
|
||||
format?: ByteDisplayFormat
|
||||
uppercase?: boolean
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: Uint8Array, options: FormatBytesOptions = {}): string {
|
||||
const format = options.format ?? 'continuous-hex'
|
||||
const values = Array.from(bytes, (byte) => {
|
||||
const hex = byte.toString(16).padStart(2, '0')
|
||||
const formatted = options.uppercase === false ? hex : hex.toUpperCase()
|
||||
return format === 'byte-array' ? `0x${formatted}` : formatted
|
||||
})
|
||||
|
||||
if (format === 'spaced-hex') return values.join(' ')
|
||||
if (format === 'byte-array') return values.join(', ')
|
||||
return values.join('')
|
||||
}
|
||||
|
||||
export function textToBytes(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value)
|
||||
}
|
||||
|
||||
export function bytesToText(value: Uint8Array): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(value)
|
||||
} catch {
|
||||
throw new Error('字节内容不是有效的 UTF-8 文本')
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBinary(bytes: Uint8Array): string {
|
||||
const chunkSize = 0x8000
|
||||
let result = ''
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
result += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function encodeBase64(bytes: Uint8Array, urlSafe = false): string {
|
||||
const encoded = btoa(bytesToBinary(bytes))
|
||||
return urlSafe ? encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') : encoded
|
||||
}
|
||||
|
||||
export function decodeBase64(input: string, urlSafe = false): Uint8Array {
|
||||
let normalized = input.replace(/\s+/g, '')
|
||||
if (urlSafe) normalized = normalized.replace(/-/g, '+').replace(/_/g, '/')
|
||||
if (!normalized) return new Uint8Array()
|
||||
if (/[^A-Za-z0-9+/=]/.test(normalized)) throw new Error('包含无效的 Base64 字符')
|
||||
if (normalized.length % 4 === 1) throw new Error('Base64 长度无效')
|
||||
normalized = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) throw new Error('Base64 填充位置无效')
|
||||
try {
|
||||
const binary = atob(normalized)
|
||||
if (btoa(binary) !== normalized) throw new Error('non-canonical')
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
} catch {
|
||||
throw new Error('Base64 内容无效')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AES } from '@stablelib/aes'
|
||||
import { CMAC } from '@stablelib/cmac'
|
||||
|
||||
export function aes128Cmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
if (key.length !== 16) throw new Error(`AES-128 密钥必须是 16 字节,当前为 ${key.length} 字节`)
|
||||
const aes = new AES(key)
|
||||
const cmac = new CMAC(aes)
|
||||
try {
|
||||
cmac.update(message)
|
||||
return cmac.digest()
|
||||
} finally {
|
||||
cmac.clean()
|
||||
aes.clean()
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
export interface CrcParameters {
|
||||
width: number
|
||||
polynomial: bigint
|
||||
initialValue: bigint
|
||||
inputReflected: boolean
|
||||
outputReflected: boolean
|
||||
xorOut: bigint
|
||||
}
|
||||
|
||||
export interface CrcPreset extends CrcParameters {
|
||||
id: string
|
||||
name: string
|
||||
check: bigint
|
||||
}
|
||||
|
||||
export const CRC_PRESETS: CrcPreset[] = [
|
||||
{ id: 'crc-32-iso-hdlc', name: 'CRC-32 / ISO-HDLC', width: 32, polynomial: 0x04c11db7n, initialValue: 0xffffffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffffffn, check: 0xcbf43926n },
|
||||
{ id: 'crc-16-ccitt-false', name: 'CRC-16 / CCITT-FALSE', width: 16, polynomial: 0x1021n, initialValue: 0xffffn, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x29b1n },
|
||||
{ id: 'crc-16-modbus', name: 'CRC-16 / MODBUS', width: 16, polynomial: 0x8005n, initialValue: 0xffffn, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0x4b37n },
|
||||
{ id: 'crc-16-arc', name: 'CRC-16 / ARC', width: 16, polynomial: 0x8005n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0xbb3dn },
|
||||
{ id: 'crc-16-xmodem', name: 'CRC-16 / XMODEM', width: 16, polynomial: 0x1021n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x31c3n },
|
||||
{ id: 'crc-8', name: 'CRC-8', width: 8, polynomial: 0x07n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0xf4n },
|
||||
]
|
||||
|
||||
function reflectBigInt(value: bigint, width: number): bigint {
|
||||
let reflected = 0n
|
||||
for (let bit = 0; bit < width; bit += 1) {
|
||||
if (value & (1n << BigInt(bit))) reflected |= 1n << BigInt(width - 1 - bit)
|
||||
}
|
||||
return reflected
|
||||
}
|
||||
|
||||
function validateParameters(parameters: CrcParameters) {
|
||||
if (!Number.isInteger(parameters.width) || parameters.width < 8 || parameters.width > 64) {
|
||||
throw new Error('CRC 位宽必须是 8 至 64 的整数')
|
||||
}
|
||||
const mask = (1n << BigInt(parameters.width)) - 1n
|
||||
if (parameters.polynomial <= 0n || parameters.polynomial > mask) throw new Error('CRC 多项式超出当前位宽')
|
||||
if (parameters.initialValue < 0n || parameters.initialValue > mask) throw new Error('CRC 初始值超出当前位宽')
|
||||
if (parameters.xorOut < 0n || parameters.xorOut > mask) throw new Error('CRC 异或输出值超出当前位宽')
|
||||
}
|
||||
|
||||
function reflectNumber(value: number, width: number): number {
|
||||
let current = value >>> 0
|
||||
let reflected = 0
|
||||
for (let bit = 0; bit < width; bit += 1) {
|
||||
reflected = ((reflected * 2) + (current & 1)) >>> 0
|
||||
current >>>= 1
|
||||
}
|
||||
return reflected
|
||||
}
|
||||
|
||||
function calculateNumberCrc(chunks: Iterable<Uint8Array>, parameters: CrcParameters): bigint {
|
||||
const width = parameters.width
|
||||
const mask = width === 32 ? 0xffffffff : (2 ** width) - 1
|
||||
const trim = (value: number) => width === 32 ? value >>> 0 : (value & mask) >>> 0
|
||||
const polynomial = Number(parameters.polynomial) >>> 0
|
||||
const table = new Uint32Array(256)
|
||||
let remainder = trim(Number(parameters.initialValue))
|
||||
|
||||
if (parameters.inputReflected) {
|
||||
const reflectedPolynomial = reflectNumber(polynomial, width)
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let entry = index
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
entry = entry & 1 ? (entry >>> 1) ^ reflectedPolynomial : entry >>> 1
|
||||
}
|
||||
table[index] = trim(entry)
|
||||
}
|
||||
for (const data of chunks) {
|
||||
for (const byte of data) {
|
||||
remainder = trim((remainder >>> 8) ^ table[(remainder ^ byte) & 0xff])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const topBit = 2 ** (width - 1)
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let entry = trim(index << (width - 8))
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
entry = trim(entry & topBit ? (entry << 1) ^ polynomial : entry << 1)
|
||||
}
|
||||
table[index] = entry
|
||||
}
|
||||
for (const data of chunks) {
|
||||
for (const byte of data) {
|
||||
const tableIndex = ((remainder >>> (width - 8)) ^ byte) & 0xff
|
||||
remainder = trim((remainder << 8) ^ table[tableIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parameters.inputReflected !== parameters.outputReflected) remainder = reflectNumber(remainder, width)
|
||||
return BigInt(trim(remainder ^ Number(parameters.xorOut)))
|
||||
}
|
||||
|
||||
function calculateBigIntCrc(chunks: Iterable<Uint8Array>, parameters: CrcParameters): bigint {
|
||||
const width = parameters.width
|
||||
const shift = BigInt(width - 8)
|
||||
const mask = (1n << BigInt(width)) - 1n
|
||||
const table: bigint[] = []
|
||||
let remainder = parameters.initialValue & mask
|
||||
|
||||
if (parameters.inputReflected) {
|
||||
const reflectedPolynomial = reflectBigInt(parameters.polynomial, width)
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let entry = BigInt(index)
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
entry = entry & 1n ? (entry >> 1n) ^ reflectedPolynomial : entry >> 1n
|
||||
}
|
||||
table[index] = entry & mask
|
||||
}
|
||||
for (const data of chunks) {
|
||||
for (const byte of data) {
|
||||
remainder = ((remainder >> 8n) ^ table[Number((remainder ^ BigInt(byte)) & 0xffn)]) & mask
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const topBit = 1n << BigInt(width - 1)
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let entry = BigInt(index) << shift
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
entry = entry & topBit ? (entry << 1n) ^ parameters.polynomial : entry << 1n
|
||||
entry &= mask
|
||||
}
|
||||
table[index] = entry
|
||||
}
|
||||
for (const data of chunks) {
|
||||
for (const byte of data) {
|
||||
const tableIndex = Number(((remainder >> shift) ^ BigInt(byte)) & 0xffn)
|
||||
remainder = ((remainder << 8n) ^ table[tableIndex]) & mask
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parameters.inputReflected !== parameters.outputReflected) remainder = reflectBigInt(remainder, width)
|
||||
return (remainder ^ parameters.xorOut) & mask
|
||||
}
|
||||
|
||||
export function calculateCrcChunks(chunks: Iterable<Uint8Array>, parameters: CrcParameters): bigint {
|
||||
validateParameters(parameters)
|
||||
return parameters.width <= 32
|
||||
? calculateNumberCrc(chunks, parameters)
|
||||
: calculateBigIntCrc(chunks, parameters)
|
||||
}
|
||||
|
||||
export function calculateCrc(data: Uint8Array, parameters: CrcParameters): bigint {
|
||||
return calculateCrcChunks([data], parameters)
|
||||
}
|
||||
|
||||
export function formatCrc(value: bigint, width: number): string {
|
||||
return value.toString(16).toUpperCase().padStart(Math.ceil(width / 4), '0')
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { calculateCrc, calculateCrcChunks, formatCrc, type CrcParameters } from '@/lib/crc'
|
||||
|
||||
export interface CrcWorkerBlock {
|
||||
startAddress: number
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export interface CrcWorkerParseRequest {
|
||||
type: 'parse'
|
||||
id: number
|
||||
file: File
|
||||
}
|
||||
|
||||
export interface CrcWorkerCalculateRequest {
|
||||
type: 'calculate'
|
||||
id: number
|
||||
parameters: CrcParameters
|
||||
}
|
||||
|
||||
export type CrcWorkerRequest = CrcWorkerParseRequest | CrcWorkerCalculateRequest
|
||||
|
||||
export interface CrcBlockResult {
|
||||
startAddress: number
|
||||
endAddress: number
|
||||
length: number
|
||||
crc: string
|
||||
}
|
||||
|
||||
export type CrcWorkerResponse = {
|
||||
type: 'parsed'
|
||||
id: number
|
||||
format: 'Intel HEX' | 'BIN'
|
||||
totalBytes: number
|
||||
blockCount: number
|
||||
warnings: string[]
|
||||
} | {
|
||||
type: 'calculated'
|
||||
id: number
|
||||
combinedResult: string
|
||||
blockResults: CrcBlockResult[]
|
||||
} | {
|
||||
type: 'error'
|
||||
id: number
|
||||
operation: CrcWorkerRequest['type']
|
||||
error: string
|
||||
}
|
||||
|
||||
export function calculateCrcReport(blocks: CrcWorkerBlock[], parameters: CrcParameters) {
|
||||
const combinedValue = calculateCrcChunks(blocks.map((block) => block.data), parameters)
|
||||
const combinedResult = formatCrc(combinedValue, parameters.width)
|
||||
const blockResults = blocks.map((block) => ({
|
||||
startAddress: block.startAddress,
|
||||
endAddress: block.startAddress + block.data.length - 1,
|
||||
length: block.data.length,
|
||||
crc: blocks.length === 1
|
||||
? combinedResult
|
||||
: formatCrc(calculateCrc(block.data, parameters), parameters.width),
|
||||
}))
|
||||
return { combinedResult, blockResults }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { parseIntelHex, type DataBlock } from '@/lib/intelHex'
|
||||
|
||||
export const MAX_FIRMWARE_FILE_SIZE = 64 * 1024 * 1024
|
||||
|
||||
export type FirmwareFormat = 'Intel HEX' | 'BIN'
|
||||
|
||||
export interface ParsedFirmware {
|
||||
format: FirmwareFormat
|
||||
blocks: DataBlock[]
|
||||
totalBytes: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export function getFirmwareFormat(fileName: string): FirmwareFormat {
|
||||
const extension = fileName.split('.').at(-1)?.toLowerCase()
|
||||
if (extension === 'hex' || extension === 'ihx') return 'Intel HEX'
|
||||
if (extension === 'bin') return 'BIN'
|
||||
throw new Error('请选择 .hex、.ihx 或 .bin 文件')
|
||||
}
|
||||
|
||||
export function parseFirmwareContent(fileName: string, content: string | Uint8Array): ParsedFirmware {
|
||||
const format = getFirmwareFormat(fileName)
|
||||
if (format === 'Intel HEX') {
|
||||
if (typeof content !== 'string') throw new Error('Intel HEX 文件内容必须是文本')
|
||||
return { format, ...parseIntelHex(content) }
|
||||
}
|
||||
if (typeof content === 'string') throw new Error('BIN 文件内容必须是二进制数据')
|
||||
if (content.length === 0) throw new Error('文件为空')
|
||||
return {
|
||||
format,
|
||||
blocks: [{ startAddress: 0, data: content }],
|
||||
totalBytes: content.length,
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface IcoFrame {
|
||||
size: number
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export function normalizeIcoSizes(sizes: number[]): number[] {
|
||||
if (sizes.length === 0) throw new Error('至少需要选择一个图标尺寸')
|
||||
if (sizes.length > 255) throw new Error('图标尺寸数量过多')
|
||||
for (const size of sizes) {
|
||||
if (!Number.isInteger(size) || size < 1 || size > 256) throw new Error(`ICO 尺寸无效:${size}`)
|
||||
}
|
||||
return [...new Set(sizes)].sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
export function packIco(frames: IcoFrame[]): Uint8Array {
|
||||
if (frames.length === 0) throw new Error('至少需要选择一个图标尺寸')
|
||||
if (frames.length > 255) throw new Error('图标尺寸数量过多')
|
||||
|
||||
for (const frame of frames) {
|
||||
if (!Number.isInteger(frame.size) || frame.size < 1 || frame.size > 256) {
|
||||
throw new Error(`ICO 尺寸无效:${frame.size}`)
|
||||
}
|
||||
if (frame.data.length === 0) throw new Error(`尺寸 ${frame.size} 的图像数据为空`)
|
||||
}
|
||||
|
||||
const directorySize = 6 + frames.length * 16
|
||||
const dataSize = frames.reduce((sum, frame) => sum + frame.data.length, 0)
|
||||
const output = new Uint8Array(directorySize + dataSize)
|
||||
const view = new DataView(output.buffer)
|
||||
|
||||
view.setUint16(0, 0, true)
|
||||
view.setUint16(2, 1, true)
|
||||
view.setUint16(4, frames.length, true)
|
||||
|
||||
let imageOffset = directorySize
|
||||
frames.forEach((frame, index) => {
|
||||
const entryOffset = 6 + index * 16
|
||||
view.setUint8(entryOffset, frame.size === 256 ? 0 : frame.size)
|
||||
view.setUint8(entryOffset + 1, frame.size === 256 ? 0 : frame.size)
|
||||
view.setUint8(entryOffset + 2, 0)
|
||||
view.setUint8(entryOffset + 3, 0)
|
||||
view.setUint16(entryOffset + 4, 1, true)
|
||||
view.setUint16(entryOffset + 6, 32, true)
|
||||
view.setUint32(entryOffset + 8, frame.data.length, true)
|
||||
view.setUint32(entryOffset + 12, imageOffset, true)
|
||||
output.set(frame.data, imageOffset)
|
||||
imageOffset += frame.data.length
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
function canvasToPng(canvas: HTMLCanvasElement): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('PNG 编码失败'))
|
||||
return
|
||||
}
|
||||
resolve(new Uint8Array(await blob.arrayBuffer()))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
export async function imageToIco(image: CanvasImageSource, sizes: number[]): Promise<Uint8Array> {
|
||||
const uniqueSizes = normalizeIcoSizes(sizes)
|
||||
const frames = await Promise.all(uniqueSizes.map(async (size) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('浏览器无法创建图像画布')
|
||||
context.imageSmoothingEnabled = true
|
||||
context.imageSmoothingQuality = 'high'
|
||||
context.clearRect(0, 0, size, size)
|
||||
context.drawImage(image, 0, 0, size, size)
|
||||
return { size, data: await canvasToPng(canvas) }
|
||||
}))
|
||||
return packIco(frames)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
export interface DataBlock {
|
||||
startAddress: number
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
export interface ParsedIntelHex {
|
||||
blocks: DataBlock[]
|
||||
totalBytes: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface IntelHexLimits {
|
||||
maxDataRecords?: number
|
||||
maxBlocks?: number
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_INTEL_HEX_DATA_RECORDS = 500_000
|
||||
export const DEFAULT_MAX_INTEL_HEX_BLOCKS = 1_024
|
||||
|
||||
interface DataRecord {
|
||||
startAddress: number
|
||||
data: Uint8Array
|
||||
line: number
|
||||
}
|
||||
|
||||
function parseRecord(line: string, lineNumber: number): Uint8Array {
|
||||
if (!line.startsWith(':')) throw new Error(`第 ${lineNumber} 行缺少 Intel HEX 起始符“:”`)
|
||||
const hex = line.slice(1)
|
||||
if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) throw new Error(`第 ${lineNumber} 行包含无效 Hex`)
|
||||
if (hex.length < 10) throw new Error(`第 ${lineNumber} 行记录长度不足`)
|
||||
const declaredByteCount = Number.parseInt(hex.slice(0, 2), 16)
|
||||
if (hex.length !== (declaredByteCount + 5) * 2) throw new Error(`第 ${lineNumber} 行数据长度与记录声明不一致`)
|
||||
const bytes = Uint8Array.from({ length: hex.length / 2 }, (_, index) => Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16))
|
||||
const checksum = bytes.reduce((sum, byte) => (sum + byte) & 0xff, 0)
|
||||
if (checksum !== 0) throw new Error(`第 ${lineNumber} 行校验和错误`)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function validateLimit(value: number, label: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) throw new Error(`${label} 必须是正整数`)
|
||||
return value
|
||||
}
|
||||
|
||||
function mergeRecords(records: DataRecord[], maxBlocks: number): DataBlock[] {
|
||||
const sorted = [...records].sort((left, right) => left.startAddress - right.startAddress || left.line - right.line)
|
||||
const groups: Array<{ startAddress: number; endAddress: number; firstRecord: number; lastRecord: number }> = []
|
||||
|
||||
for (let index = 0; index < sorted.length; index += 1) {
|
||||
const record = sorted[index]
|
||||
const recordEnd = record.startAddress + record.data.length
|
||||
const current = groups.at(-1)
|
||||
if (!current || record.startAddress > current.endAddress) {
|
||||
if (groups.length >= maxBlocks) throw new Error(`Intel HEX 数据块不能超过 ${maxBlocks.toLocaleString()} 个`)
|
||||
groups.push({ startAddress: record.startAddress, endAddress: recordEnd, firstRecord: index, lastRecord: index + 1 })
|
||||
continue
|
||||
}
|
||||
current.endAddress = Math.max(current.endAddress, recordEnd)
|
||||
current.lastRecord = index + 1
|
||||
}
|
||||
|
||||
return groups.map((group) => {
|
||||
const data = new Uint8Array(group.endAddress - group.startAddress)
|
||||
let writtenEnd = 0
|
||||
for (let recordIndex = group.firstRecord; recordIndex < group.lastRecord; recordIndex += 1) {
|
||||
const record = sorted[recordIndex]
|
||||
const offset = record.startAddress - group.startAddress
|
||||
const overlapLength = Math.min(record.data.length, Math.max(0, writtenEnd - offset))
|
||||
for (let index = 0; index < overlapLength; index += 1) {
|
||||
if (data[offset + index] !== record.data[index]) {
|
||||
throw new Error(`第 ${record.line} 行与已有地址 0x${(record.startAddress + index).toString(16).toUpperCase()} 的数据冲突`)
|
||||
}
|
||||
}
|
||||
data.set(record.data, offset)
|
||||
writtenEnd = Math.max(writtenEnd, offset + record.data.length)
|
||||
}
|
||||
return { startAddress: group.startAddress, data }
|
||||
})
|
||||
}
|
||||
|
||||
export function parseIntelHex(value: string, limits: IntelHexLimits = {}): ParsedIntelHex {
|
||||
const maxDataRecords = validateLimit(limits.maxDataRecords ?? DEFAULT_MAX_INTEL_HEX_DATA_RECORDS, 'Intel HEX 数据记录上限')
|
||||
const maxBlocks = validateLimit(limits.maxBlocks ?? DEFAULT_MAX_INTEL_HEX_BLOCKS, 'Intel HEX 数据块上限')
|
||||
const lines = value.replace(/^\uFEFF/, '').split(/\r?\n/)
|
||||
const records: DataRecord[] = []
|
||||
const warnings: string[] = []
|
||||
let baseAddress = 0
|
||||
let eofSeen = false
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index].trim()
|
||||
if (!line) continue
|
||||
const lineNumber = index + 1
|
||||
if (eofSeen) throw new Error(`第 ${lineNumber} 行位于 EOF 记录之后`)
|
||||
const bytes = parseRecord(line, lineNumber)
|
||||
const byteCount = bytes[0]
|
||||
const recordAddress = (bytes[1] << 8) | bytes[2]
|
||||
const recordType = bytes[3]
|
||||
const data = bytes.slice(4, 4 + byteCount)
|
||||
|
||||
if (recordType === 0x00) {
|
||||
const startAddress = baseAddress + recordAddress
|
||||
if (startAddress + data.length > 0x100000000) throw new Error(`第 ${lineNumber} 行地址超出 32 位范围`)
|
||||
if (data.length) {
|
||||
if (records.length >= maxDataRecords) throw new Error(`Intel HEX 数据记录不能超过 ${maxDataRecords.toLocaleString()} 条`)
|
||||
records.push({ startAddress, data, line: lineNumber })
|
||||
}
|
||||
} else if (recordType === 0x01) {
|
||||
if (byteCount !== 0 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行 EOF 记录格式无效`)
|
||||
eofSeen = true
|
||||
} else if (recordType === 0x02) {
|
||||
if (byteCount !== 2 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行扩展段地址记录格式无效`)
|
||||
baseAddress = ((data[0] << 8) | data[1]) << 4
|
||||
} else if (recordType === 0x04) {
|
||||
if (byteCount !== 2 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行扩展线性地址记录格式无效`)
|
||||
baseAddress = ((data[0] << 8) | data[1]) * 0x10000
|
||||
} else if (recordType === 0x03 || recordType === 0x05) {
|
||||
if (byteCount !== 4 || recordAddress !== 0) throw new Error(`第 ${lineNumber} 行启动地址记录格式无效`)
|
||||
} else {
|
||||
throw new Error(`第 ${lineNumber} 行包含不支持的记录类型 0x${recordType.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!records.length) throw new Error('Intel HEX 文件不包含有效数据记录')
|
||||
if (!eofSeen) warnings.push('文件缺少 EOF 记录,已按现有数据计算')
|
||||
const blocks = mergeRecords(records, maxBlocks)
|
||||
return { blocks, totalBytes: blocks.reduce((total, block) => total + block.data.length, 0), warnings }
|
||||
}
|
||||
|
||||
export function concatenateBlocks(blocks: DataBlock[]): Uint8Array {
|
||||
const length = blocks.reduce((total, block) => total + block.data.length, 0)
|
||||
const combined = new Uint8Array(length)
|
||||
let offset = 0
|
||||
for (const block of blocks) {
|
||||
combined.set(block.data, offset)
|
||||
offset += block.data.length
|
||||
}
|
||||
return combined
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { NavigationConfig } from '@/types'
|
||||
|
||||
export function cloneNavigation(value: NavigationConfig): NavigationConfig {
|
||||
return {
|
||||
version: value.version,
|
||||
categories: value.categories.map((category) => ({ id: category.id, tools: [...category.tools] })),
|
||||
}
|
||||
}
|
||||
|
||||
function clampInsertionIndex(index: number, length: number): number {
|
||||
if (!Number.isFinite(index)) return length
|
||||
return Math.min(Math.max(0, Math.trunc(index)), length)
|
||||
}
|
||||
|
||||
export function reorderCategory(value: NavigationConfig, sourceIndex: number, targetIndex: number): NavigationConfig {
|
||||
const next = cloneNavigation(value)
|
||||
if (!Number.isInteger(sourceIndex) || sourceIndex < 0 || sourceIndex >= next.categories.length) return next
|
||||
const [category] = next.categories.splice(sourceIndex, 1)
|
||||
next.categories.splice(clampInsertionIndex(targetIndex, next.categories.length), 0, category)
|
||||
return next
|
||||
}
|
||||
|
||||
export function moveCategoryBy(value: NavigationConfig, sourceIndex: number, delta: number): NavigationConfig {
|
||||
const targetIndex = sourceIndex + delta
|
||||
if (!Number.isInteger(delta) || targetIndex < 0 || targetIndex >= value.categories.length) return cloneNavigation(value)
|
||||
return reorderCategory(value, sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
export interface ToolPosition {
|
||||
categoryIndex: number
|
||||
toolIndex: number
|
||||
}
|
||||
|
||||
export function relocateTool(value: NavigationConfig, source: ToolPosition, target: ToolPosition): NavigationConfig {
|
||||
const next = cloneNavigation(value)
|
||||
const sourceCategory = next.categories[source.categoryIndex]
|
||||
const targetCategory = next.categories[target.categoryIndex]
|
||||
if (!sourceCategory || !targetCategory || !Number.isInteger(source.toolIndex) || source.toolIndex < 0 || source.toolIndex >= sourceCategory.tools.length) return next
|
||||
|
||||
const [toolId] = sourceCategory.tools.splice(source.toolIndex, 1)
|
||||
targetCategory.tools.splice(clampInsertionIndex(target.toolIndex, targetCategory.tools.length), 0, toolId)
|
||||
return next
|
||||
}
|
||||
|
||||
export function moveToolBy(value: NavigationConfig, categoryIndex: number, toolIndex: number, delta: number): NavigationConfig {
|
||||
const tools = value.categories[categoryIndex]?.tools
|
||||
const targetIndex = toolIndex + delta
|
||||
if (!tools || !Number.isInteger(delta) || targetIndex < 0 || targetIndex >= tools.length) return cloneNavigation(value)
|
||||
return relocateTool(value, { categoryIndex, toolIndex }, { categoryIndex, toolIndex: targetIndex })
|
||||
}
|
||||
|
||||
export function moveToolToCategory(value: NavigationConfig, toolId: string, sourceCategoryIndex: number, targetCategoryId: string): NavigationConfig {
|
||||
const sourceCategory = value.categories[sourceCategoryIndex]
|
||||
const targetCategoryIndex = value.categories.findIndex((category) => category.id === targetCategoryId)
|
||||
const sourceToolIndex = sourceCategory?.tools.indexOf(toolId) ?? -1
|
||||
if (sourceToolIndex < 0 || targetCategoryIndex < 0 || sourceCategory.id === targetCategoryId) return cloneNavigation(value)
|
||||
return relocateTool(
|
||||
value,
|
||||
{ categoryIndex: sourceCategoryIndex, toolIndex: sourceToolIndex },
|
||||
{ categoryIndex: targetCategoryIndex, toolIndex: value.categories[targetCategoryIndex].tools.length },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export const PNG_SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
export const PNG_HEADER_LENGTH = 33
|
||||
export const MAX_PNG_FILE_SIZE = 32 * 1024 * 1024
|
||||
export const MAX_PNG_DIMENSION = 16_384
|
||||
export const MAX_PNG_PIXELS = 32_000_000
|
||||
|
||||
export interface PngMetadata {
|
||||
width: number
|
||||
height: number
|
||||
pixels: number
|
||||
}
|
||||
|
||||
export function parsePngHeader(bytes: Uint8Array, fileSize = bytes.length): PngMetadata {
|
||||
if (!Number.isSafeInteger(fileSize) || fileSize < PNG_HEADER_LENGTH) throw new Error('PNG 文件内容不完整')
|
||||
if (fileSize > MAX_PNG_FILE_SIZE) throw new Error('PNG 文件不能超过 32 MiB')
|
||||
if (bytes.length < PNG_HEADER_LENGTH) throw new Error('PNG 文件头不完整')
|
||||
if (!PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) throw new Error('请选择有效的 PNG 图片')
|
||||
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
const ihdrLength = view.getUint32(8, false)
|
||||
const ihdrType = String.fromCharCode(...bytes.slice(12, 16))
|
||||
if (ihdrLength !== 13 || ihdrType !== 'IHDR') throw new Error('PNG 文件缺少有效的 IHDR 数据块')
|
||||
|
||||
const width = view.getUint32(16, false)
|
||||
const height = view.getUint32(20, false)
|
||||
if (width === 0 || height === 0) throw new Error('PNG 图片尺寸无效')
|
||||
if (width > MAX_PNG_DIMENSION || height > MAX_PNG_DIMENSION) {
|
||||
throw new Error(`PNG 图片宽高不能超过 ${MAX_PNG_DIMENSION.toLocaleString()} 像素`)
|
||||
}
|
||||
const pixels = width * height
|
||||
if (pixels > MAX_PNG_PIXELS) throw new Error(`PNG 图片总像素不能超过 ${MAX_PNG_PIXELS.toLocaleString()}`)
|
||||
return { width, height, pixels }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface RandomSource {
|
||||
getRandomValues<T extends ArrayBufferView>(array: T): T
|
||||
}
|
||||
|
||||
export function secureRandomBytes(length: number, source: RandomSource = globalThis.crypto): Uint8Array {
|
||||
if (!Number.isInteger(length) || length < 1 || length > 65_536) throw new Error('随机数长度必须是 1 至 65536 字节的整数')
|
||||
if (!source?.getRandomValues) throw new Error('当前浏览器不支持安全随机数生成')
|
||||
const output = new Uint8Array(length)
|
||||
const chunkSize = 65_536
|
||||
for (let offset = 0; offset < output.length; offset += chunkSize) {
|
||||
source.getRandomValues(output.subarray(offset, Math.min(offset + chunkSize, output.length)))
|
||||
}
|
||||
return output
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import forge from 'node-forge'
|
||||
|
||||
export interface GeneratedRsaKeyPair {
|
||||
publicKeyPem: string
|
||||
privateKeyPem: string
|
||||
modulusHex: string
|
||||
exponentHex: string
|
||||
source: 'Web Crypto' | 'node-forge'
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
const chunkSize = 0x8000
|
||||
let binary = ''
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
export function formatPem(data: ArrayBuffer | Uint8Array, label: string): string {
|
||||
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
|
||||
const base64 = bytesToBase64(bytes)
|
||||
const lines = base64.match(/.{1,64}/g) ?? []
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
export function base64UrlToHex(value: string): string {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
return Array.from(binary, (character) => character.charCodeAt(0).toString(16).padStart(2, '0')).join('').toUpperCase()
|
||||
}
|
||||
|
||||
function normalizePem(value: string): string {
|
||||
return `${value.replace(/\r\n/g, '\n').trimEnd()}\n`
|
||||
}
|
||||
|
||||
async function generateWithWebCrypto(modulusLength: number): Promise<GeneratedRsaKeyPair> {
|
||||
const keyPair = await globalThis.crypto.subtle.generateKey({
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength,
|
||||
publicExponent: Uint8Array.from([0x01, 0x00, 0x01]),
|
||||
hash: 'SHA-256',
|
||||
}, true, ['sign', 'verify']) as CryptoKeyPair
|
||||
const [publicDer, privateDer, publicJwk] = await Promise.all([
|
||||
globalThis.crypto.subtle.exportKey('spki', keyPair.publicKey),
|
||||
globalThis.crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
|
||||
globalThis.crypto.subtle.exportKey('jwk', keyPair.publicKey),
|
||||
])
|
||||
if (!publicJwk.n || !publicJwk.e) throw new Error('浏览器未返回 RSA 公钥参数')
|
||||
return {
|
||||
publicKeyPem: formatPem(publicDer, 'PUBLIC KEY'),
|
||||
privateKeyPem: formatPem(privateDer, 'PRIVATE KEY'),
|
||||
modulusHex: base64UrlToHex(publicJwk.n),
|
||||
exponentHex: base64UrlToHex(publicJwk.e),
|
||||
source: 'Web Crypto',
|
||||
}
|
||||
}
|
||||
|
||||
function nativeRandomSeed(length: number): string {
|
||||
if (!globalThis.crypto?.getRandomValues) throw new Error('当前浏览器不支持安全随机数生成')
|
||||
const bytes = new Uint8Array(length)
|
||||
globalThis.crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
|
||||
}
|
||||
|
||||
function generateWithForge(modulusLength: number): Promise<GeneratedRsaKeyPair> {
|
||||
const prng = forge.random.createInstance()
|
||||
prng.seedFileSync = (needed) => nativeRandomSeed(needed)
|
||||
prng.seedFile = (needed, callback) => callback(null, nativeRandomSeed(needed))
|
||||
return new Promise((resolve, reject) => {
|
||||
forge.pki.rsa.generateKeyPair({ bits: modulusLength, e: 0x10001, workers: 0, prng }, (error, keyPair) => {
|
||||
if (error) { reject(error); return }
|
||||
try {
|
||||
const privateKeyInfo = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(keyPair.privateKey))
|
||||
const modulus = keyPair.publicKey.n.toString(16).toUpperCase()
|
||||
const exponent = keyPair.publicKey.e.toString(16).toUpperCase()
|
||||
resolve({
|
||||
publicKeyPem: normalizePem(forge.pki.publicKeyToPem(keyPair.publicKey, 64)),
|
||||
privateKeyPem: normalizePem(forge.pki.privateKeyInfoToPem(privateKeyInfo, 64)),
|
||||
modulusHex: modulus.length % 2 ? `0${modulus}` : modulus,
|
||||
exponentHex: exponent.length % 2 ? `0${exponent}` : exponent,
|
||||
source: 'node-forge',
|
||||
})
|
||||
} catch (reason) {
|
||||
reject(reason)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateRsaKeyPair(modulusLength: number): Promise<GeneratedRsaKeyPair> {
|
||||
if (![2048, 3072, 4096].includes(modulusLength)) throw new Error('RSA 密钥长度必须是 2048、3072 或 4096 位')
|
||||
if (globalThis.crypto?.subtle) {
|
||||
try {
|
||||
return await generateWithWebCrypto(modulusLength)
|
||||
} catch {
|
||||
return generateWithForge(modulusLength)
|
||||
}
|
||||
}
|
||||
return generateWithForge(modulusLength)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
export type SyntaxLanguage = 'json' | 'xml'
|
||||
|
||||
export type SyntaxTokenType =
|
||||
| 'plain'
|
||||
| 'punctuation'
|
||||
| 'key'
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'literal'
|
||||
| 'tag'
|
||||
| 'attribute'
|
||||
| 'value'
|
||||
| 'comment'
|
||||
| 'meta'
|
||||
|
||||
export interface SyntaxToken {
|
||||
text: string
|
||||
type: SyntaxTokenType
|
||||
}
|
||||
|
||||
export const MAX_HIGHLIGHTED_SYNTAX_LENGTH = 100_000
|
||||
export const MAX_HIGHLIGHTED_SYNTAX_TOKENS = 10_000
|
||||
|
||||
function append(tokens: SyntaxToken[], text: string, type: SyntaxTokenType) {
|
||||
if (!text) return
|
||||
const previous = tokens.at(-1)
|
||||
if (previous?.type === type) previous.text += text
|
||||
else tokens.push({ text, type })
|
||||
}
|
||||
|
||||
export function tokenizeJson(value: string): SyntaxToken[] {
|
||||
const tokens: SyntaxToken[] = []
|
||||
const pattern = /"(?:\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b|[{}[\],:]/g
|
||||
let cursor = 0
|
||||
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
const index = match.index ?? 0
|
||||
append(tokens, value.slice(cursor, index), 'plain')
|
||||
const text = match[0]
|
||||
let type: SyntaxTokenType = 'punctuation'
|
||||
if (text.startsWith('"')) {
|
||||
type = value.slice(index + text.length).match(/^\s*:/) ? 'key' : 'string'
|
||||
} else if (/^-?\d/.test(text)) {
|
||||
type = 'number'
|
||||
} else if (/^(true|false|null)$/.test(text)) {
|
||||
type = 'literal'
|
||||
}
|
||||
append(tokens, text, type)
|
||||
cursor = index + text.length
|
||||
}
|
||||
append(tokens, value.slice(cursor), 'plain')
|
||||
return tokens
|
||||
}
|
||||
|
||||
function findMarkupEnd(value: string, start: number): number {
|
||||
let quote = ''
|
||||
let bracketDepth = 0
|
||||
for (let index = start + 1; index < value.length; index += 1) {
|
||||
const character = value[index]
|
||||
if (quote) {
|
||||
if (character === quote) quote = ''
|
||||
continue
|
||||
}
|
||||
if (character === '"' || character === "'") quote = character
|
||||
else if (character === '[') bracketDepth += 1
|
||||
else if (character === ']') bracketDepth = Math.max(0, bracketDepth - 1)
|
||||
else if (character === '>' && bracketDepth === 0) return index + 1
|
||||
}
|
||||
return value.length
|
||||
}
|
||||
|
||||
function tokenizeTag(value: string, tokens: SyntaxToken[]) {
|
||||
let cursor = 0
|
||||
const closing = value.startsWith('</')
|
||||
const openingLength = closing ? 2 : 1
|
||||
append(tokens, value.slice(0, openingLength), 'punctuation')
|
||||
cursor = openingLength
|
||||
|
||||
const name = value.slice(cursor).match(/^[A-Za-z_][\w:.-]*/)?.[0]
|
||||
if (name) {
|
||||
append(tokens, name, 'tag')
|
||||
cursor += name.length
|
||||
}
|
||||
|
||||
while (cursor < value.length) {
|
||||
const remaining = value.slice(cursor)
|
||||
const whitespace = remaining.match(/^\s+/)?.[0]
|
||||
if (whitespace) {
|
||||
append(tokens, whitespace, 'plain')
|
||||
cursor += whitespace.length
|
||||
continue
|
||||
}
|
||||
if (remaining.startsWith('/>')) {
|
||||
append(tokens, '/>', 'punctuation')
|
||||
cursor += 2
|
||||
continue
|
||||
}
|
||||
if (remaining.startsWith('>')) {
|
||||
append(tokens, '>', 'punctuation')
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
if (remaining.startsWith('=')) {
|
||||
append(tokens, '=', 'punctuation')
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
const quoted = remaining.match(/^(?:"[^"]*"|'[^']*')/)?.[0]
|
||||
if (quoted) {
|
||||
append(tokens, quoted, 'value')
|
||||
cursor += quoted.length
|
||||
continue
|
||||
}
|
||||
const attribute = remaining.match(/^[A-Za-z_][\w:.-]*/)?.[0]
|
||||
if (attribute) {
|
||||
append(tokens, attribute, closing ? 'tag' : 'attribute')
|
||||
cursor += attribute.length
|
||||
continue
|
||||
}
|
||||
append(tokens, remaining[0], 'plain')
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
|
||||
export function tokenizeXml(value: string): SyntaxToken[] {
|
||||
const tokens: SyntaxToken[] = []
|
||||
let cursor = 0
|
||||
while (cursor < value.length) {
|
||||
const markupStart = value.indexOf('<', cursor)
|
||||
if (markupStart < 0) {
|
||||
append(tokens, value.slice(cursor), 'plain')
|
||||
break
|
||||
}
|
||||
append(tokens, value.slice(cursor, markupStart), 'plain')
|
||||
|
||||
if (value.startsWith('<!--', markupStart)) {
|
||||
const end = value.indexOf('-->', markupStart + 4)
|
||||
const next = end < 0 ? value.length : end + 3
|
||||
append(tokens, value.slice(markupStart, next), 'comment')
|
||||
cursor = next
|
||||
continue
|
||||
}
|
||||
if (value.startsWith('<![CDATA[', markupStart)) {
|
||||
const end = value.indexOf(']]>', markupStart + 9)
|
||||
const next = end < 0 ? value.length : end + 3
|
||||
append(tokens, value.slice(markupStart, next), 'meta')
|
||||
cursor = next
|
||||
continue
|
||||
}
|
||||
if (value.startsWith('<?', markupStart)) {
|
||||
const end = value.indexOf('?>', markupStart + 2)
|
||||
const next = end < 0 ? value.length : end + 2
|
||||
append(tokens, value.slice(markupStart, next), 'meta')
|
||||
cursor = next
|
||||
continue
|
||||
}
|
||||
|
||||
const next = findMarkupEnd(value, markupStart)
|
||||
const markup = value.slice(markupStart, next)
|
||||
if (/^<!/i.test(markup)) append(tokens, markup, 'meta')
|
||||
else tokenizeTag(markup, tokens)
|
||||
cursor = next
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
export function tokenizeSyntax(value: string, language: SyntaxLanguage): SyntaxToken[] {
|
||||
return language === 'json' ? tokenizeJson(value) : tokenizeXml(value)
|
||||
}
|
||||
|
||||
export function tokenizeSyntaxForDisplay(value: string, language: SyntaxLanguage): SyntaxToken[] | undefined {
|
||||
if (value.length > MAX_HIGHLIGHTED_SYNTAX_LENGTH) return undefined
|
||||
const tokens = tokenizeSyntax(value, language)
|
||||
return tokens.length <= MAX_HIGHLIGHTED_SYNTAX_TOKENS ? tokens : undefined
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { toolById, toolComponents, tools } from '@/tools/registry'
|
||||
|
||||
const toolRoutes: RouteRecordRaw[] = tools.map((tool) => ({
|
||||
path: `/tools/${tool.id}`,
|
||||
name: tool.id,
|
||||
component: toolComponents[tool.id],
|
||||
meta: {
|
||||
title: tool.title,
|
||||
categoryId: tool.category,
|
||||
toolId: tool.id,
|
||||
},
|
||||
}))
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/tools/ascii-hex' },
|
||||
...toolRoutes,
|
||||
{
|
||||
path: '/setting',
|
||||
name: 'setting',
|
||||
component: () => import('@/views/SettingView.vue'),
|
||||
meta: { title: '工具管理' },
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/tools/ascii-hex' },
|
||||
],
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.toolId && !toolById.has(String(to.meta.toolId))) return '/tools/ascii-hex'
|
||||
return true
|
||||
})
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--canvas: #ffffff;
|
||||
--sidebar: #f5f6f7;
|
||||
--panel: #f8f9fa;
|
||||
--surface: #ffffff;
|
||||
--text: #15191c;
|
||||
--muted: #68727a;
|
||||
--faint: #929ba1;
|
||||
--border: #e1e5e8;
|
||||
--border-strong: #cbd2d7;
|
||||
--primary: #1d2428;
|
||||
--primary-hover: #30383d;
|
||||
--primary-text: #ffffff;
|
||||
--accent: #0f766e;
|
||||
--accent-hover: #0b5f59;
|
||||
--accent-soft: #e7f3f1;
|
||||
--success: #2f7d57;
|
||||
--success-soft: #edf7f1;
|
||||
--warning: #a75e14;
|
||||
--warning-soft: #fff7e8;
|
||||
--danger: #bd3d38;
|
||||
--danger-soft: #fff0ef;
|
||||
--info-soft: #eef4f7;
|
||||
--syntax-key: #0969da;
|
||||
--syntax-string: #087a32;
|
||||
--syntax-number: #a04400;
|
||||
--syntax-literal: #8250df;
|
||||
--syntax-tag: #0550ae;
|
||||
--syntax-attribute: #9a4b00;
|
||||
--syntax-value: #087a32;
|
||||
--syntax-comment: #768078;
|
||||
--syntax-meta: #8250df;
|
||||
color: var(--text);
|
||||
background: var(--canvas);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--canvas: #111315;
|
||||
--sidebar: #171a1d;
|
||||
--panel: #1b1f23;
|
||||
--surface: #202429;
|
||||
--text: #edf0f2;
|
||||
--muted: #9da7ad;
|
||||
--faint: #707a80;
|
||||
--border: #30363b;
|
||||
--border-strong: #424a50;
|
||||
--primary: #e9edef;
|
||||
--primary-hover: #ffffff;
|
||||
--primary-text: #141719;
|
||||
--accent: #4db6aa;
|
||||
--accent-hover: #70c6bd;
|
||||
--accent-soft: #173c38;
|
||||
--success: #64bc88;
|
||||
--success-soft: #173326;
|
||||
--warning: #e0a15d;
|
||||
--warning-soft: #382918;
|
||||
--danger: #ed7770;
|
||||
--danger-soft: #3b201f;
|
||||
--info-soft: #18272e;
|
||||
--syntax-key: #79c0ff;
|
||||
--syntax-string: #7ee787;
|
||||
--syntax-number: #ffa657;
|
||||
--syntax-literal: #d2a8ff;
|
||||
--syntax-tag: #7ee787;
|
||||
--syntax-attribute: #79c0ff;
|
||||
--syntax-value: #ffa657;
|
||||
--syntax-comment: #8b949e;
|
||||
--syntax-meta: #d2a8ff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
body { background: var(--canvas); color: var(--text); }
|
||||
button, input, select, textarea { font: inherit; letter-spacing: 0; }
|
||||
button, a, input, select, textarea { outline-color: var(--accent); }
|
||||
button { color: inherit; }
|
||||
|
||||
.app-layout { height: 100dvh; display: flex; overflow: hidden; background: var(--canvas); }
|
||||
.app-sidebar { width: 236px; flex: 0 0 auto; display: flex; flex-direction: column; background: var(--sidebar); border-right: 1px solid var(--border); z-index: 30; }
|
||||
.brand-row { min-height: 66px; padding: 0 18px; display: flex; align-items: center; gap: 11px; border-bottom: 1px solid var(--border); }
|
||||
.brand-mark { width: 30px; height: 30px; border-radius: 7px; display: grid; place-items: center; color: var(--primary-text); background: var(--primary); }
|
||||
.brand-row strong { display: block; font-size: 14px; font-weight: 600; }
|
||||
.brand-row small { display: block; margin-top: 1px; color: var(--faint); font-size: 11px; }
|
||||
.sidebar-search { height: 36px; margin: 17px 14px 10px; padding: 0 10px; display: flex; align-items: center; gap: 8px; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.sidebar-search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.sidebar-search input { width: 100%; min-width: 0; color: var(--text); background: transparent; border: 0; outline: 0; font-size: 12px; }
|
||||
.sidebar-search input::placeholder { color: var(--faint); }
|
||||
.tool-navigation { padding: 0 9px 18px; overflow-y: auto; }
|
||||
.tool-navigation section { margin: 0; }
|
||||
.tool-navigation h2 { margin: 0; padding: 13px 10px 6px; color: var(--faint); font-size: 10px; font-weight: 600; }
|
||||
.tool-navigation a { position: relative; min-height: 35px; padding: 0 10px; display: flex; align-items: center; gap: 9px; color: var(--muted); text-decoration: none; border: 1px solid transparent; border-radius: 6px; }
|
||||
.tool-navigation a:hover { color: var(--text); background: var(--surface); }
|
||||
.tool-navigation a.is-active { color: var(--text); background: var(--surface); border-color: var(--border); box-shadow: 0 1px 2px rgba(20, 24, 28, .04); }
|
||||
.tool-navigation a.is-active::before { position: absolute; left: -1px; width: 3px; height: 18px; content: ''; background: var(--accent); border-radius: 0 2px 2px 0; }
|
||||
.tool-navigation a span { min-width: 0; overflow-wrap: anywhere; font-size: 13px; }
|
||||
.tool-navigation a small { margin-left: auto; color: var(--faint); font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.empty-search { padding: 20px 10px; color: var(--muted); font-size: 12px; }
|
||||
.sidebar-local { min-height: 48px; margin-top: auto; padding: 0 18px; display: flex; align-items: center; gap: 7px; color: var(--muted); border-top: 1px solid var(--border); font-size: 11px; }
|
||||
|
||||
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--canvas); }
|
||||
.app-main > .tool-page, .app-main > .setting-page { flex: 1 1 auto; min-height: 0; }
|
||||
.topbar { min-height: 66px; flex: 0 0 66px; padding: 0 28px; display: flex; align-items: center; gap: 9px; color: var(--muted); border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.topbar strong { color: var(--text); font-weight: 600; }
|
||||
.topbar-spacer { flex: 1; }
|
||||
.icon-button { width: 32px; height: 32px; padding: 0; display: grid; place-items: center; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 6px; cursor: pointer; }
|
||||
.icon-button:hover { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.mobile-menu { display: none; }
|
||||
.menu-scrim { display: none; }
|
||||
|
||||
.tool-page, .setting-page { width: 100%; max-width: 1120px; padding: 32px 32px 40px; overflow-y: auto; }
|
||||
.tool-heading, .setting-heading { margin-bottom: 25px; display: flex; align-items: flex-start; gap: 20px; }
|
||||
.tool-category, .setting-category { margin: 0 0 5px; color: var(--accent); font-size: 11px; font-weight: 600; }
|
||||
.tool-heading h1, .setting-heading h1 { margin: 0; color: var(--text); font-size: 24px; line-height: 1.2; font-weight: 600; }
|
||||
.tool-description, .setting-description { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.local-state { margin-left: auto; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; white-space: nowrap; }
|
||||
.local-state svg { color: var(--success); }
|
||||
|
||||
.mode-tabs { width: fit-content; min-height: 36px; padding: 3px; display: flex; align-items: center; gap: 2px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; }
|
||||
.mode-tab { min-height: 28px; padding: 0 10px; color: var(--muted); background: transparent; border: 0; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
||||
.mode-tab:hover { color: var(--text); }
|
||||
.mode-tab.is-active { color: var(--text); background: var(--surface); box-shadow: 0 0 0 1px var(--border); }
|
||||
.tool-controls { margin-bottom: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.control-spacer, .action-spacer { flex: 1; }
|
||||
.compact-field { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
.compact-field select, .select-input, .text-input { min-height: 34px; color: var(--text); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px; }
|
||||
.compact-field select, .select-input { padding: 0 30px 0 10px; }
|
||||
.text-input { width: 100%; padding: 0 10px; }
|
||||
.text-input:focus, .select-input:focus, .compact-field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.check-field { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); cursor: pointer; font-size: 12px; }
|
||||
.check-field input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
|
||||
|
||||
.editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) 38px minmax(0, 1fr); gap: 13px; align-items: center; }
|
||||
.editor-panel { min-width: 0; display: block; overflow: hidden; background: var(--surface); border: 1px solid var(--border-strong); border-radius: 7px; }
|
||||
.editor-panel:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.editor-header { min-height: 38px; padding: 0 12px; display: flex; align-items: center; gap: 8px; color: var(--muted); background: var(--panel); border-bottom: 1px solid var(--border); font-size: 11px; }
|
||||
.editor-header strong { color: var(--text); font-weight: 600; }
|
||||
.editor-meta { margin-left: auto; color: var(--faint); }
|
||||
.editor-panel textarea { width: 100%; min-height: 184px; padding: 13px; display: block; resize: vertical; color: var(--text); background: var(--surface); border: 0; outline: 0; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.6; }
|
||||
.editor-panel textarea::placeholder, .key-input::placeholder { color: var(--faint); }
|
||||
.syntax-viewer { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border-strong); border-radius: 7px; }
|
||||
.syntax-viewer pre { min-height: 290px; max-height: 520px; margin: 0; padding: 13px; overflow: auto; color: var(--text); background: var(--surface); font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.6; white-space: pre; tab-size: 2; }
|
||||
.syntax-viewer code { font: inherit; }
|
||||
.syntax-key { color: var(--syntax-key); }
|
||||
.syntax-string { color: var(--syntax-string); }
|
||||
.syntax-number { color: var(--syntax-number); }
|
||||
.syntax-literal { color: var(--syntax-literal); font-weight: 600; }
|
||||
.syntax-tag { color: var(--syntax-tag); }
|
||||
.syntax-attribute { color: var(--syntax-attribute); }
|
||||
.syntax-value { color: var(--syntax-value); }
|
||||
.syntax-comment { color: var(--syntax-comment); font-style: italic; }
|
||||
.syntax-meta { color: var(--syntax-meta); }
|
||||
.swap-button { width: 32px; height: 32px; padding: 0; display: grid; place-items: center; justify-self: center; color: var(--muted); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 50%; cursor: pointer; }
|
||||
.swap-button:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.swap-button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
|
||||
.action-row { min-height: 36px; margin-top: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.action-button { min-height: 34px; padding: 0 12px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--text); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; }
|
||||
.action-button:hover { border-color: var(--text); }
|
||||
.action-button.primary { color: var(--primary-text); background: var(--primary); border-color: var(--primary); }
|
||||
.action-button.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
.action-button.ghost { color: var(--muted); background: transparent; border-color: transparent; }
|
||||
.action-button.ghost:hover { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.action-button.danger { color: var(--danger); }
|
||||
.action-button:disabled, .action-button.disabled { cursor: not-allowed; opacity: .5; }
|
||||
.privacy-note { min-height: 42px; margin-top: 18px; padding: 9px 13px; display: flex; align-items: center; gap: 9px; color: var(--muted); background: var(--info-soft); border-radius: 6px; font-size: 11px; }
|
||||
.privacy-note strong { color: var(--text); }
|
||||
.status-message { min-height: 38px; margin-top: 12px; padding: 9px 11px; display: flex; align-items: flex-start; gap: 8px; border-radius: 6px; font-size: 12px; }
|
||||
.status-message.error { color: var(--danger); background: var(--danger-soft); }
|
||||
.status-message.success { color: var(--success); background: var(--success-soft); }
|
||||
.status-message.warning { color: var(--warning); background: var(--warning-soft); }
|
||||
.app-footer { flex: 0 0 auto; min-height: 38px; padding: 10px 28px; color: var(--faint); border-top: 1px solid var(--border); text-align: center; font-size: 11px; line-height: 1.4; }
|
||||
|
||||
.field-stack { margin-bottom: 16px; display: grid; gap: 7px; }
|
||||
.field-stack > label, .field-label { color: var(--muted); font-size: 12px; font-weight: 600; }
|
||||
.key-input { width: 100%; min-height: 64px; padding: 10px 12px; resize: vertical; color: var(--text); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 7px; outline: 0; font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.key-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.field-help { color: var(--faint); font-size: 11px; }
|
||||
.byte-count { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.byte-count.is-valid { color: var(--success); }
|
||||
.byte-count.is-invalid { color: var(--danger); }
|
||||
|
||||
.single-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
|
||||
.single-editor-layout .editor-panel textarea { min-height: 290px; }
|
||||
|
||||
.upload-zone { min-height: 170px; padding: 24px; display: grid; place-items: center; text-align: center; color: var(--muted); background: var(--panel); border: 1px dashed var(--border-strong); border-radius: 7px; cursor: pointer; }
|
||||
.upload-zone:hover, .upload-zone.is-dragging { color: var(--text); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.upload-zone input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.sr-only-file { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
|
||||
.upload-zone strong { display: block; margin-top: 8px; color: var(--text); font-size: 13px; }
|
||||
.upload-zone span { display: block; margin-top: 3px; font-size: 11px; }
|
||||
.image-workspace { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(0, 1.2fr); gap: 22px; align-items: start; }
|
||||
.image-preview { aspect-ratio: 1; display: grid; place-items: center; overflow: hidden; background-color: var(--panel); background-image: linear-gradient(45deg, var(--border) 25%, transparent 25%), linear-gradient(-45deg, var(--border) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--border) 75%), linear-gradient(-45deg, transparent 75%, var(--border) 75%); background-size: 18px 18px; background-position: 0 0, 0 9px, 9px -9px, -9px 0; border: 1px solid var(--border); border-radius: 7px; }
|
||||
.image-preview img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.image-options h2 { margin: 0 0 10px; font-size: 13px; font-weight: 600; }
|
||||
.size-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
||||
.size-option { min-height: 42px; padding: 0 10px; display: flex; align-items: center; gap: 8px; color: var(--muted); background: var(--surface); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
|
||||
.size-option:has(input:checked) { color: var(--text); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.size-option input { margin: 0; accent-color: var(--accent); }
|
||||
.file-meta { margin-top: 12px; color: var(--muted); font-size: 11px; }
|
||||
.compact-upload { min-height: 138px; }
|
||||
.firmware-summary { min-height: 58px; margin-bottom: 16px; padding: 10px 12px; display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; }
|
||||
.firmware-summary > div { min-width: 0; margin-right: auto; }
|
||||
.firmware-summary strong, .firmware-summary span { display: block; overflow-wrap: anywhere; }
|
||||
.firmware-summary strong { font-size: 12px; font-weight: 600; }
|
||||
.firmware-summary span { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
.crc-config { display: grid; grid-template-columns: minmax(190px, 1.35fr) repeat(4, minmax(110px, 1fr)) auto auto; gap: 10px; align-items: end; }
|
||||
.parameter-field { min-width: 0; display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 600; }
|
||||
.parameter-field .select-input, .parameter-field .text-input { width: 100%; font-size: 12px; font-weight: 400; }
|
||||
.crc-check { min-height: 34px; white-space: nowrap; }
|
||||
.crc-results { margin-top: 16px; }
|
||||
.crc-total { min-height: 58px; padding: 10px 13px; display: flex; align-items: center; gap: 14px; color: var(--muted); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); border-radius: 7px; font-size: 11px; }
|
||||
.crc-total strong { margin-left: auto; color: var(--accent); font-family: "SFMono-Regular", Consolas, monospace; font-size: 20px; font-weight: 700; }
|
||||
.result-table-wrap { margin-top: 10px; overflow-x: auto; border: 1px solid var(--border); border-radius: 7px; }
|
||||
.result-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 11px; }
|
||||
.result-table th, .result-table td { height: 38px; padding: 0 11px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.result-table th { color: var(--faint); background: var(--panel); font-weight: 600; }
|
||||
.result-table td { color: var(--muted); }
|
||||
.result-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.crc-value { color: var(--text) !important; font-family: "SFMono-Regular", Consolas, monospace; font-weight: 600; }
|
||||
.fixed-parameter { min-height: 34px; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; }
|
||||
.fixed-parameter strong { color: var(--text); font-family: "SFMono-Regular", Consolas, monospace; font-weight: 600; }
|
||||
.rsa-generate-row { margin-top: 0; margin-bottom: 18px; }
|
||||
.rsa-output-tabs { margin: 0 0 10px; }
|
||||
.rsa-parameter-format { margin: 0 0 10px; }
|
||||
.rsa-parameter-grid { display: grid; gap: 14px; }
|
||||
.rsa-copy-row { min-height: 30px; margin-top: 4px; justify-content: flex-end; }
|
||||
.random-controls { margin-bottom: 14px; display: grid; grid-template-columns: minmax(120px, 180px) minmax(310px, max-content) auto; gap: 12px; align-items: end; }
|
||||
.random-format-field .mode-tabs { min-width: 310px; }
|
||||
.random-check { min-height: 34px; white-space: nowrap; }
|
||||
|
||||
.setting-page { max-width: 980px; }
|
||||
.setting-heading-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||
.setting-list { display: grid; gap: 22px; }
|
||||
.setting-loading { min-height: 180px; display: grid; place-items: center; color: var(--muted); font-size: 13px; }
|
||||
.setting-category-block { min-width: 0; }
|
||||
.setting-category-header { min-height: 38px; display: flex; align-items: center; gap: 9px; color: var(--muted); border-bottom: 1px solid var(--border); }
|
||||
.setting-category-header h2 { margin: 0; color: var(--text); font-size: 12px; font-weight: 600; }
|
||||
.setting-category-header small { color: var(--faint); }
|
||||
.setting-category-header .row-actions { margin-left: auto; }
|
||||
.setting-tool-list { padding-top: 8px; display: grid; gap: 7px; }
|
||||
.setting-tool-row { min-height: 52px; padding: 0 11px; display: flex; align-items: center; gap: 10px; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 7px; }
|
||||
.setting-tool-row.is-dragging { opacity: .45; }
|
||||
.drag-handle { color: var(--faint); cursor: grab; }
|
||||
.tool-row-icon { width: 28px; height: 28px; display: grid; place-items: center; color: var(--accent); background: var(--accent-soft); border-radius: 6px; }
|
||||
.setting-tool-row strong { min-width: 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.setting-tool-row .select-input { width: 130px; margin-left: auto; font-size: 11px; }
|
||||
.row-actions { display: flex; gap: 3px; }
|
||||
.row-action { width: 30px; height: 30px; padding: 0; display: grid; place-items: center; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 5px; cursor: pointer; }
|
||||
.row-action:hover:not(:disabled) { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.row-action:disabled { opacity: .3; cursor: default; }
|
||||
.save-state { margin-top: 14px; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.app-sidebar { position: fixed; inset: 0 auto 0 0; transform: translateX(-100%); transition: transform .18s ease; box-shadow: 14px 0 32px rgba(20, 24, 28, .14); }
|
||||
.menu-open .app-sidebar { transform: translateX(0); }
|
||||
.menu-scrim { position: fixed; inset: 0; z-index: 20; display: block; background: rgba(10, 14, 17, .38); border: 0; }
|
||||
.mobile-menu { display: grid; }
|
||||
.topbar { padding: 0 16px; }
|
||||
.tool-page, .setting-page { max-width: none; padding: 26px 18px 34px; }
|
||||
.editor-grid { grid-template-columns: 1fr; }
|
||||
.swap-button { transform: rotate(90deg); }
|
||||
.single-editor-layout, .image-workspace { grid-template-columns: 1fr; }
|
||||
.image-preview { max-width: 320px; width: 100%; margin: 0 auto; }
|
||||
.crc-config { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.preset-field { grid-column: 1 / -1; }
|
||||
.random-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.random-format-field { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.tool-heading, .setting-heading { display: block; }
|
||||
.local-state, .setting-heading-actions { margin: 10px 0 0; }
|
||||
.tool-heading h1, .setting-heading h1 { font-size: 22px; }
|
||||
.tool-page, .setting-page { padding: 22px 14px 30px; }
|
||||
.topbar { min-height: 60px; flex-basis: 60px; }
|
||||
.topbar > span:first-of-type, .topbar > svg { display: none; }
|
||||
.mode-tabs { width: 100%; }
|
||||
.mode-tab { flex: 1; }
|
||||
.random-format-field .mode-tabs { min-width: 0; }
|
||||
.compact-field { width: 100%; justify-content: space-between; }
|
||||
.compact-field select { flex: 1; }
|
||||
.editor-panel textarea { min-height: 162px; }
|
||||
.single-editor-layout .editor-panel textarea { min-height: 220px; }
|
||||
.syntax-viewer pre { min-height: 220px; }
|
||||
.action-spacer, .control-spacer { display: none; }
|
||||
.action-button.ghost { padding-left: 8px; padding-right: 8px; }
|
||||
.size-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.setting-tool-row { padding: 9px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.setting-tool-row .select-input { width: calc(100% - 76px); margin-left: 38px; order: 5; }
|
||||
.setting-tool-row .row-actions { margin-left: auto; }
|
||||
.firmware-summary { align-items: flex-start; flex-wrap: wrap; }
|
||||
.firmware-summary > div { width: 100%; }
|
||||
.crc-total { align-items: flex-start; flex-direction: column; }
|
||||
.crc-total strong { margin-left: 0; font-size: 18px; overflow-wrap: anywhere; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, Play, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { bytesToHex, parseHex, textToBytes } from '@/lib/bytes'
|
||||
import { aes128Cmac } from '@/lib/cmac'
|
||||
|
||||
type MessageMode = 'hex' | 'text'
|
||||
const keyInput = ref('')
|
||||
const messageMode = ref<MessageMode>('hex')
|
||||
const message = ref('')
|
||||
const output = ref('')
|
||||
const uppercase = ref(true)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const messageModes = [{ value: 'hex' as const, label: 'Hex 消息' }, { value: 'text' as const, label: 'UTF-8 文本' }]
|
||||
|
||||
const keyState = computed(() => {
|
||||
if (!keyInput.value.trim()) return { bytes: 0, valid: false }
|
||||
try {
|
||||
const bytes = parseHex(keyInput.value).length
|
||||
return { bytes, valid: bytes === 16 }
|
||||
}
|
||||
catch { return { bytes: null, valid: false } }
|
||||
})
|
||||
|
||||
function clearResult() {
|
||||
output.value = ''
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch([keyInput, message, messageMode, uppercase], clearResult)
|
||||
|
||||
function calculate() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
const key = parseHex(keyInput.value, 16)
|
||||
const data = messageMode.value === 'hex' ? parseHex(message.value) : textToBytes(message.value)
|
||||
output.value = bytesToHex(aes128Cmac(key, data), '', uppercase.value)
|
||||
success.value = 'CMAC 计算完成'
|
||||
} catch (reason) {
|
||||
output.value = ''
|
||||
error.value = reason instanceof Error ? reason.message : 'CMAC 计算失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try { await copyText(output.value); success.value = '结果已复制'; error.value = '' }
|
||||
catch { error.value = '复制失败' }
|
||||
}
|
||||
function clearAll() { keyInput.value = ''; message.value = ''; clearResult() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="AES-128-CMAC" description="使用 128 位密钥计算消息认证码,结果按 RFC 4493 规则生成。">
|
||||
<div class="field-stack">
|
||||
<label for="cmac-key">128 位密钥</label>
|
||||
<textarea id="cmac-key" v-model="keyInput" class="key-input" rows="2" spellcheck="false" placeholder="例如:0x00 0x11 0x22 … 或 00112233445566778899AABBCCDDEEFF" />
|
||||
<div class="field-help">
|
||||
支持连续 Hex、空格、换行、英文/中文逗号及每字节可选的 0x 前缀。
|
||||
<span class="byte-count" :class="keyState.valid ? 'is-valid' : 'is-invalid'">{{ keyState.bytes === null ? '格式无效' : `${keyState.bytes}/16 字节` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="messageMode" :options="messageModes" label="消息格式" />
|
||||
<span class="control-spacer" />
|
||||
<label class="check-field"><input v-model="uppercase" type="checkbox" />大写结果</label>
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="message" label="消息" :format="messageMode === 'hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="`${message.length} 字符`" placeholder="输入待认证消息;空消息也可计算" />
|
||||
<span />
|
||||
<TextEditor v-model="output" label="CMAC 结果" format="128 位 Hex" :meta="output ? '16 字节' : ''" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="calculate"><Play :size="15" />计算 CMAC</button>
|
||||
<button class="action-button" type="button" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制结果</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, ArrowLeftRight, CheckCircle2, Clipboard, Copy, Play, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText, pasteText } from '@/lib/browser'
|
||||
import { bytesToHex, bytesToText, parseHex, textToBytes } from '@/lib/bytes'
|
||||
|
||||
type Mode = 'text-to-hex' | 'hex-to-text'
|
||||
const mode = ref<Mode>('text-to-hex')
|
||||
const input = ref('Hello, Web Tools')
|
||||
const output = ref('')
|
||||
const delimiter = ref<' ' | '' | ','>(' ')
|
||||
const uppercase = ref(true)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const modes = [
|
||||
{ value: 'text-to-hex' as const, label: '文本 → Hex' },
|
||||
{ value: 'hex-to-text' as const, label: 'Hex → 文本' },
|
||||
]
|
||||
const inputMeta = computed(() => `${input.value.length} 字符`)
|
||||
const outputMeta = computed(() => `${output.value.length} 字符`)
|
||||
|
||||
function clearResult() {
|
||||
output.value = ''
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch([input, mode, delimiter, uppercase], clearResult)
|
||||
|
||||
function convert() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
output.value = mode.value === 'text-to-hex'
|
||||
? bytesToHex(textToBytes(input.value), delimiter.value, uppercase.value)
|
||||
: bytesToText(parseHex(input.value))
|
||||
success.value = '转换完成'
|
||||
} catch (reason) {
|
||||
output.value = ''
|
||||
error.value = reason instanceof Error ? reason.message : '转换失败'
|
||||
}
|
||||
}
|
||||
|
||||
function swap() {
|
||||
input.value = output.value
|
||||
output.value = ''
|
||||
mode.value = mode.value === 'text-to-hex' ? 'hex-to-text' : 'text-to-hex'
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try {
|
||||
await copyText(output.value)
|
||||
success.value = '结果已复制'
|
||||
error.value = ''
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '复制失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function pasteInput() {
|
||||
try {
|
||||
input.value = await pasteText()
|
||||
error.value = ''
|
||||
} catch {
|
||||
error.value = '无法读取剪贴板,请检查浏览器权限'
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
input.value = ''
|
||||
clearResult()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="编码转换" title="ASCII / Hex" description="在 UTF-8 文本与十六进制字节之间转换。">
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="mode" :options="modes" label="转换方向" />
|
||||
<span class="control-spacer" />
|
||||
<label v-if="mode === 'text-to-hex'" class="compact-field">分隔符
|
||||
<select v-model="delimiter">
|
||||
<option value=" ">空格</option>
|
||||
<option value="">无</option>
|
||||
<option value=",">逗号</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="mode === 'text-to-hex'" class="check-field"><input v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="input" label="输入" :format="mode === 'text-to-hex' ? 'UTF-8 文本' : 'Hex 字节'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<button class="swap-button" type="button" aria-label="交换输入输出" @click="swap"><ArrowLeftRight :size="16" /></button>
|
||||
<TextEditor v-model="output" label="结果" :format="mode === 'text-to-hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="outputMeta" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="convert"><Play :size="15" />转换</button>
|
||||
<button class="action-button" type="button" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" @click="pasteInput"><Clipboard :size="15" />粘贴</button>
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, ArrowLeftRight, CheckCircle2, Clipboard, Copy, Play, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText, pasteText } from '@/lib/browser'
|
||||
import { bytesToText, decodeBase64, encodeBase64, formatBytes, textToBytes } from '@/lib/bytes'
|
||||
|
||||
type Mode = 'encode' | 'decode'
|
||||
type DecodeOutputMode = 'text' | 'hex' | 'byte-array'
|
||||
const mode = ref<Mode>('encode')
|
||||
const decodeOutputMode = ref<DecodeOutputMode>('text')
|
||||
const input = ref('Hello, Web Tools')
|
||||
const output = ref('')
|
||||
const decodedBytes = shallowRef<Uint8Array>()
|
||||
const urlSafe = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const modes = [{ value: 'encode' as const, label: '编码' }, { value: 'decode' as const, label: '解码' }]
|
||||
const decodeOutputModes = [
|
||||
{ value: 'text' as const, label: 'UTF-8 文本' },
|
||||
{ value: 'hex' as const, label: '连续 Hex' },
|
||||
{ value: 'byte-array' as const, label: '字节数组' },
|
||||
]
|
||||
const inputMeta = computed(() => `${input.value.length} 字符`)
|
||||
const outputMeta = computed(() => decodedBytes.value && mode.value === 'decode'
|
||||
? `${decodedBytes.value.length} 字节 · ${output.value.length} 字符`
|
||||
: `${output.value.length} 字符`)
|
||||
const outputFormat = computed(() => {
|
||||
if (mode.value === 'encode') return 'Base64'
|
||||
if (decodeOutputMode.value === 'text') return 'UTF-8 文本'
|
||||
return decodeOutputMode.value === 'hex' ? '连续 Hex' : '字节数组'
|
||||
})
|
||||
|
||||
function renderDecodedOutput() {
|
||||
if (!decodedBytes.value) return
|
||||
error.value = ''
|
||||
try {
|
||||
if (decodeOutputMode.value === 'text') output.value = bytesToText(decodedBytes.value)
|
||||
else output.value = formatBytes(decodedBytes.value, {
|
||||
format: decodeOutputMode.value === 'hex' ? 'continuous-hex' : 'byte-array',
|
||||
})
|
||||
} catch (reason) {
|
||||
output.value = ''
|
||||
error.value = reason instanceof Error ? reason.message : '输出格式转换失败'
|
||||
}
|
||||
}
|
||||
|
||||
function clearConversion() {
|
||||
output.value = ''
|
||||
decodedBytes.value = undefined
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch(decodeOutputMode, renderDecodedOutput)
|
||||
watch([input, mode, urlSafe], clearConversion)
|
||||
|
||||
function convert() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
if (mode.value === 'encode') {
|
||||
decodedBytes.value = undefined
|
||||
output.value = encodeBase64(textToBytes(input.value), urlSafe.value)
|
||||
} else {
|
||||
decodedBytes.value = decodeBase64(input.value, urlSafe.value)
|
||||
renderDecodedOutput()
|
||||
if (error.value) return
|
||||
}
|
||||
success.value = '转换完成'
|
||||
} catch (reason) {
|
||||
output.value = ''
|
||||
decodedBytes.value = undefined
|
||||
error.value = reason instanceof Error ? reason.message : '转换失败'
|
||||
}
|
||||
}
|
||||
|
||||
function swap() {
|
||||
if (mode.value === 'decode' && decodeOutputMode.value !== 'text') return
|
||||
input.value = output.value
|
||||
output.value = ''
|
||||
decodedBytes.value = undefined
|
||||
mode.value = mode.value === 'encode' ? 'decode' : 'encode'
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try { await copyText(output.value); success.value = '结果已复制'; error.value = '' }
|
||||
catch { error.value = '复制失败' }
|
||||
}
|
||||
async function pasteInput() {
|
||||
try { input.value = await pasteText(); error.value = '' }
|
||||
catch { error.value = '无法读取剪贴板,请检查浏览器权限' }
|
||||
}
|
||||
function clearAll() { input.value = ''; clearConversion() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="编码转换" title="Base64" description="编码、解码并校验 Base64 内容。">
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="mode" :options="modes" label="转换模式" />
|
||||
<span class="control-spacer" />
|
||||
<label class="check-field"><input v-model="urlSafe" type="checkbox" />URL 安全模式</label>
|
||||
</div>
|
||||
<div v-if="mode === 'decode'" class="tool-controls base64-output-controls">
|
||||
<ModeTabs v-model="decodeOutputMode" :options="decodeOutputModes" label="解码输出格式" />
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="input" label="输入" :format="mode === 'encode' ? 'UTF-8 文本' : 'Base64'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<button class="swap-button" type="button" aria-label="交换输入输出" :disabled="mode === 'decode' && decodeOutputMode !== 'text'" :title="mode === 'decode' && decodeOutputMode !== 'text' ? '仅 UTF-8 文本结果可交换' : ''" @click="swap"><ArrowLeftRight :size="16" /></button>
|
||||
<TextEditor v-model="output" label="结果" :format="outputFormat" :meta="outputMeta" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="convert"><Play :size="15" />转换</button>
|
||||
<button class="action-button" type="button" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" @click="pasteInput"><Clipboard :size="15" />粘贴</button>
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, Calculator, CheckCircle2, Copy, FileUp, Trash2, Upload } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { CRC_PRESETS, type CrcParameters } from '@/lib/crc'
|
||||
import type { CrcBlockResult, CrcWorkerRequest, CrcWorkerResponse } from '@/lib/crcWorker'
|
||||
import { MAX_FIRMWARE_FILE_SIZE, type FirmwareFormat } from '@/lib/firmware'
|
||||
|
||||
interface FirmwareData {
|
||||
fileName: string
|
||||
fileSize: number
|
||||
format: FirmwareFormat
|
||||
blockCount: number
|
||||
totalBytes: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
const defaultPreset = CRC_PRESETS[0]
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const firmware = shallowRef<FirmwareData>()
|
||||
const dragging = ref(false)
|
||||
const selectedPreset = ref(defaultPreset.id)
|
||||
const width = ref(defaultPreset.width)
|
||||
const polynomial = ref(defaultPreset.polynomial.toString(16).toUpperCase())
|
||||
const initialValue = ref(defaultPreset.initialValue.toString(16).toUpperCase())
|
||||
const xorOut = ref(defaultPreset.xorOut.toString(16).toUpperCase())
|
||||
const inputReflected = ref(defaultPreset.inputReflected)
|
||||
const outputReflected = ref(defaultPreset.outputReflected)
|
||||
const combinedResult = ref('')
|
||||
const blockResults = ref<CrcBlockResult[]>([])
|
||||
const parsing = ref(false)
|
||||
const calculating = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
let worker: Worker | undefined
|
||||
let requestSequence = 0
|
||||
let activeParseRequest: number | undefined
|
||||
let activeCalculationRequest: number | undefined
|
||||
|
||||
const activeParameterName = computed(() => CRC_PRESETS.find((preset) => preset.id === selectedPreset.value)?.name ?? '自定义参数')
|
||||
|
||||
function formatAddress(address: number): string {
|
||||
return `0x${address.toString(16).toUpperCase().padStart(8, '0')}`
|
||||
}
|
||||
|
||||
function applyPreset() {
|
||||
const preset = CRC_PRESETS.find((item) => item.id === selectedPreset.value)
|
||||
if (!preset) return
|
||||
width.value = preset.width
|
||||
polynomial.value = preset.polynomial.toString(16).toUpperCase()
|
||||
initialValue.value = preset.initialValue.toString(16).toUpperCase()
|
||||
xorOut.value = preset.xorOut.toString(16).toUpperCase()
|
||||
inputReflected.value = preset.inputReflected
|
||||
outputReflected.value = preset.outputReflected
|
||||
invalidateResults()
|
||||
}
|
||||
|
||||
function markCustom() {
|
||||
selectedPreset.value = 'custom'
|
||||
invalidateResults()
|
||||
}
|
||||
|
||||
function parseParameter(value: string, label: string): bigint {
|
||||
const normalized = value.trim().replace(/^0x/i, '')
|
||||
if (!normalized || !/^[0-9a-f]+$/i.test(normalized)) throw new Error(`${label} 必须是十六进制整数`)
|
||||
return BigInt(`0x${normalized}`)
|
||||
}
|
||||
|
||||
function getParameters(): CrcParameters {
|
||||
return {
|
||||
width: width.value,
|
||||
polynomial: parseParameter(polynomial.value, 'Poly'),
|
||||
initialValue: parseParameter(initialValue.value, 'Init'),
|
||||
inputReflected: inputReflected.value,
|
||||
outputReflected: outputReflected.value,
|
||||
xorOut: parseParameter(xorOut.value, 'XorOut'),
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
combinedResult.value = ''
|
||||
blockResults.value = []
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
function invalidateResults() {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
clearResults()
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function destroyWorker() {
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
activeParseRequest = undefined
|
||||
activeCalculationRequest = undefined
|
||||
parsing.value = false
|
||||
calculating.value = false
|
||||
}
|
||||
|
||||
function createWorker(): Worker {
|
||||
const nextWorker = new Worker(new URL('../workers/crc.worker.ts', import.meta.url), { type: 'module' })
|
||||
nextWorker.onmessage = (event: MessageEvent<CrcWorkerResponse>) => {
|
||||
if (nextWorker !== worker) return
|
||||
const response = event.data
|
||||
if (response.type === 'parsed') {
|
||||
if (response.id !== activeParseRequest) return
|
||||
activeParseRequest = undefined
|
||||
parsing.value = false
|
||||
firmware.value = {
|
||||
fileName: pendingFileName,
|
||||
fileSize: pendingFileSize,
|
||||
format: response.format,
|
||||
totalBytes: response.totalBytes,
|
||||
blockCount: response.blockCount,
|
||||
warnings: response.warnings,
|
||||
}
|
||||
success.value = `已解析 ${response.blockCount} 个数据块`
|
||||
return
|
||||
}
|
||||
if (response.type === 'calculated') {
|
||||
if (response.id !== activeCalculationRequest) return
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
combinedResult.value = response.combinedResult
|
||||
blockResults.value = response.blockResults
|
||||
success.value = `${activeCalculationName} 计算完成`
|
||||
return
|
||||
}
|
||||
if (response.operation === 'parse' && response.id === activeParseRequest) {
|
||||
activeParseRequest = undefined
|
||||
parsing.value = false
|
||||
firmware.value = undefined
|
||||
error.value = response.error
|
||||
} else if (response.operation === 'calculate' && response.id === activeCalculationRequest) {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
error.value = response.error
|
||||
}
|
||||
}
|
||||
nextWorker.onerror = () => {
|
||||
if (nextWorker !== worker) return
|
||||
destroyWorker()
|
||||
firmware.value = undefined
|
||||
clearResults()
|
||||
error.value = 'CRC 后台线程异常终止,请重新选择文件'
|
||||
}
|
||||
worker = nextWorker
|
||||
return nextWorker
|
||||
}
|
||||
|
||||
let pendingFileName = ''
|
||||
let pendingFileSize = 0
|
||||
let activeCalculationName = ''
|
||||
|
||||
function selectFile(selected?: File) {
|
||||
if (!selected) return
|
||||
destroyWorker()
|
||||
firmware.value = undefined
|
||||
clearResults()
|
||||
error.value = ''
|
||||
if (selected.size === 0) { error.value = '文件为空'; return }
|
||||
if (selected.size > MAX_FIRMWARE_FILE_SIZE) { error.value = '文件不能超过 64 MiB'; return }
|
||||
pendingFileName = selected.name
|
||||
pendingFileSize = selected.size
|
||||
const id = ++requestSequence
|
||||
activeParseRequest = id
|
||||
parsing.value = true
|
||||
const request: CrcWorkerRequest = { type: 'parse', id, file: selected }
|
||||
createWorker().postMessage(request)
|
||||
}
|
||||
|
||||
function onFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const selected = input.files?.[0]
|
||||
input.value = ''
|
||||
selectFile(selected)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
void selectFile(event.dataTransfer?.files[0])
|
||||
}
|
||||
|
||||
function calculate() {
|
||||
invalidateResults()
|
||||
if (!firmware.value) { error.value = '请先选择 BIN 或 Intel HEX 文件'; return }
|
||||
try {
|
||||
const parameters = getParameters()
|
||||
if (!worker) throw new Error('文件数据已失效,请重新选择文件')
|
||||
const id = ++requestSequence
|
||||
activeCalculationRequest = id
|
||||
activeCalculationName = activeParameterName.value
|
||||
calculating.value = true
|
||||
const request: CrcWorkerRequest = { type: 'calculate', id, parameters }
|
||||
worker.postMessage(request)
|
||||
} catch (reason) {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
error.value = reason instanceof Error ? reason.message : 'CRC 计算失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try { await copyText(combinedResult.value); success.value = '总 CRC 已复制'; error.value = '' }
|
||||
catch { error.value = '复制失败' }
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
destroyWorker()
|
||||
invalidateResults()
|
||||
firmware.value = undefined
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(destroyWorker)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="校验计算" title="CRC" description="解析 BIN 或 Intel HEX 有效数据,并使用预设或自定义参数计算 CRC。">
|
||||
<label v-if="!firmware" class="upload-zone compact-upload" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<input ref="fileInput" type="file" accept=".hex,.ihx,.bin,application/octet-stream" :disabled="parsing" @change="onFileInput" />
|
||||
<span><FileUp :size="28" aria-hidden="true" /><strong>{{ parsing ? '正在解析文件…' : '选择或拖入 HEX / BIN 文件' }}</strong><span>Intel HEX 会校验记录并识别多个数据块</span></span>
|
||||
</label>
|
||||
<div v-else class="firmware-summary">
|
||||
<div><strong>{{ firmware.fileName }}</strong><span>{{ firmware.format }} · {{ firmware.totalBytes.toLocaleString() }} 有效字节 · {{ firmware.blockCount }} 个 block</span></div>
|
||||
<label class="action-button"><Upload :size="15" />更换文件<input class="sr-only-file" type="file" accept=".hex,.ihx,.bin,application/octet-stream" @change="onFileInput" /></label>
|
||||
<button class="action-button" type="button" @click="clearFile"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
|
||||
<div class="crc-config">
|
||||
<label class="parameter-field preset-field"><span>参数预设</span>
|
||||
<select v-model="selectedPreset" class="select-input" :disabled="calculating" @change="applyPreset">
|
||||
<option v-for="preset in CRC_PRESETS" :key="preset.id" :value="preset.id">{{ preset.name }}</option>
|
||||
<option value="custom">自定义参数</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="parameter-field"><span>Width</span>
|
||||
<select v-model.number="width" class="select-input" :disabled="calculating" @change="markCustom"><option :value="8">8</option><option :value="16">16</option><option :value="32">32</option><option :value="64">64</option></select>
|
||||
</label>
|
||||
<label class="parameter-field"><span>Poly (Hex)</span><input v-model="polynomial" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>Init (Hex)</span><input v-model="initialValue" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>XorOut (Hex)</span><input v-model="xorOut" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="check-field crc-check"><input v-model="inputReflected" type="checkbox" :disabled="calculating" @change="markCustom" />RefIn</label>
|
||||
<label class="check-field crc-check"><input v-model="outputReflected" type="checkbox" :disabled="calculating" @change="markCustom" />RefOut</label>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" :disabled="!firmware || parsing || calculating" @click="calculate"><Calculator :size="15" />{{ calculating ? '正在计算…' : '计算 CRC' }}</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!combinedResult" @click="copyResult"><Copy :size="15" />复制总 CRC</button>
|
||||
</div>
|
||||
|
||||
<div v-if="combinedResult" class="crc-results">
|
||||
<div class="crc-total"><span>总有效数据 · 按地址升序拼接,不填充空洞</span><strong>{{ combinedResult }}</strong></div>
|
||||
<div class="result-table-wrap">
|
||||
<table class="result-table">
|
||||
<thead><tr><th>Block</th><th>起始地址</th><th>结束地址</th><th>字节数</th><th>CRC</th></tr></thead>
|
||||
<tbody><tr v-for="(block, index) in blockResults" :key="block.startAddress"><td>#{{ index + 1 }}</td><td>{{ formatAddress(block.startAddress) }}</td><td>{{ formatAddress(block.endAddress) }}</td><td>{{ block.length.toLocaleString() }}</td><td class="crc-value">{{ block.crc }}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="firmware?.warnings.length" class="status-message warning"><AlertCircle :size="16" />{{ firmware.warnings.join(';') }}</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, Minimize2, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import SyntaxViewer from '@/components/SyntaxViewer.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
|
||||
const input = ref('{"name":"Web Tools","local":true,"tools":["Hex","CMAC","Base64"]}')
|
||||
const output = ref('')
|
||||
const indent = ref('2')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
function clearResult() {
|
||||
output.value = ''
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch([input, indent], clearResult)
|
||||
|
||||
function parseInput() {
|
||||
if (!input.value.trim()) throw new Error('请输入 JSON 内容')
|
||||
return JSON.parse(input.value) as unknown
|
||||
}
|
||||
function format() {
|
||||
try { output.value = JSON.stringify(parseInput(), null, indent.value === 'tab' ? '\t' : Number(indent.value)); error.value = ''; success.value = 'JSON 格式正确' }
|
||||
catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'JSON 无效' }
|
||||
}
|
||||
function minify() {
|
||||
try { output.value = JSON.stringify(parseInput()); error.value = ''; success.value = 'JSON 已压缩' }
|
||||
catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'JSON 无效' }
|
||||
}
|
||||
function validate() {
|
||||
try { parseInput(); error.value = ''; success.value = 'JSON 格式正确' }
|
||||
catch (reason) { success.value = ''; error.value = reason instanceof Error ? reason.message : 'JSON 无效' }
|
||||
}
|
||||
async function copyResult() { try { await copyText(output.value); success.value = '结果已复制' } catch { error.value = '复制失败' } }
|
||||
function clearAll() { input.value = ''; clearResult() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="格式化" title="JSON" description="格式化、压缩并校验 JSON 字符串。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">缩进
|
||||
<select v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option><option value="tab">Tab</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="single-editor-layout">
|
||||
<TextEditor v-model="input" label="输入" format="JSON" :meta="`${input.length} 字符`" placeholder="粘贴 JSON 字符串" :rows="14" />
|
||||
<SyntaxViewer :value="output" label="结果" language="json" format="JSON" :meta="`${output.length} 字符`" />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="format"><Sparkles :size="15" />格式化</button>
|
||||
<button class="action-button" type="button" @click="minify"><Minimize2 :size="15" />压缩</button>
|
||||
<button class="action-button" type="button" @click="validate"><CheckCircle2 :size="15" />校验</button>
|
||||
<button class="action-button" type="button" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Download, ImagePlus, Trash2, Upload } from '@lucide/vue'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { downloadBytes } from '@/lib/browser'
|
||||
import { imageToIco } from '@/lib/ico'
|
||||
import { MAX_PNG_FILE_SIZE, parsePngHeader, PNG_HEADER_LENGTH, type PngMetadata } from '@/lib/png'
|
||||
|
||||
const availableSizes = [16, 24, 32, 48, 64, 128, 256]
|
||||
const selectedSizes = ref([16, 32, 48, 64, 128, 256])
|
||||
const previewImage = ref<HTMLImageElement>()
|
||||
const file = ref<File>()
|
||||
const metadata = ref<PngMetadata>()
|
||||
const previewUrl = ref('')
|
||||
const dimensions = ref('')
|
||||
const imageReady = ref(false)
|
||||
const dragging = ref(false)
|
||||
const converting = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
let stateVersion = 0
|
||||
|
||||
function cleanupUrl() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
async function selectFile(selected?: File) {
|
||||
if (!selected) return
|
||||
const selectionVersion = ++stateVersion
|
||||
cleanupUrl()
|
||||
file.value = undefined
|
||||
metadata.value = undefined
|
||||
previewImage.value = undefined
|
||||
dimensions.value = ''
|
||||
imageReady.value = false
|
||||
converting.value = false
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
if (selected.size > MAX_PNG_FILE_SIZE) throw new Error('PNG 文件不能超过 32 MiB')
|
||||
const header = new Uint8Array(await selected.slice(0, PNG_HEADER_LENGTH).arrayBuffer())
|
||||
if (selectionVersion !== stateVersion) return
|
||||
metadata.value = parsePngHeader(header, selected.size)
|
||||
file.value = selected
|
||||
dimensions.value = `${metadata.value.width} × ${metadata.value.height}px`
|
||||
previewUrl.value = URL.createObjectURL(selected)
|
||||
} catch (reason) {
|
||||
if (selectionVersion !== stateVersion) return
|
||||
error.value = reason instanceof Error ? reason.message : '无法读取 PNG 图片'
|
||||
}
|
||||
}
|
||||
|
||||
function onFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const selected = input.files?.[0]
|
||||
input.value = ''
|
||||
void selectFile(selected)
|
||||
}
|
||||
|
||||
function imageLoaded(event: Event) {
|
||||
const image = event.target as HTMLImageElement
|
||||
if (image.src !== previewUrl.value || !metadata.value) return
|
||||
if (image.naturalWidth !== metadata.value.width || image.naturalHeight !== metadata.value.height) {
|
||||
failCurrentImage('PNG 文件头与实际图片尺寸不一致')
|
||||
return
|
||||
}
|
||||
previewImage.value = image
|
||||
imageReady.value = true
|
||||
dimensions.value = `${image.naturalWidth} × ${image.naturalHeight}px`
|
||||
}
|
||||
|
||||
function failCurrentImage(message: string) {
|
||||
stateVersion += 1
|
||||
cleanupUrl()
|
||||
file.value = undefined
|
||||
metadata.value = undefined
|
||||
previewImage.value = undefined
|
||||
dimensions.value = ''
|
||||
imageReady.value = false
|
||||
converting.value = false
|
||||
success.value = ''
|
||||
error.value = message
|
||||
}
|
||||
|
||||
function imageFailed(event: Event) {
|
||||
const image = event.target as HTMLImageElement
|
||||
if (image.src === previewUrl.value) failCurrentImage('无法读取 PNG 图片')
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
void selectFile(event.dataTransfer?.files[0])
|
||||
}
|
||||
|
||||
function toggleSize(size: number) {
|
||||
if (converting.value) return
|
||||
selectedSizes.value = selectedSizes.value.includes(size)
|
||||
? selectedSizes.value.filter((current) => current !== size)
|
||||
: [...selectedSizes.value, size].sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
async function convert() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
if (!previewImage.value || !file.value || !imageReady.value) { error.value = '请先选择并等待 PNG 图片读取完成'; return }
|
||||
if (selectedSizes.value.length === 0) { error.value = '至少选择一个 ICO 尺寸'; return }
|
||||
const conversionVersion = stateVersion
|
||||
const sourceImage = previewImage.value
|
||||
const sourceFile = file.value
|
||||
const sizes = [...selectedSizes.value]
|
||||
converting.value = true
|
||||
try {
|
||||
const ico = await imageToIco(sourceImage, sizes)
|
||||
if (conversionVersion !== stateVersion) return
|
||||
downloadBytes(ico, `${sourceFile.name.replace(/\.png$/i, '') || 'icon'}.ico`, 'image/x-icon')
|
||||
success.value = `已生成包含 ${sizes.length} 个尺寸的 ICO`
|
||||
} catch (reason) {
|
||||
if (conversionVersion !== stateVersion) return
|
||||
error.value = reason instanceof Error ? reason.message : 'ICO 生成失败'
|
||||
} finally {
|
||||
if (conversionVersion === stateVersion) converting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
stateVersion += 1
|
||||
cleanupUrl()
|
||||
file.value = undefined
|
||||
metadata.value = undefined
|
||||
previewImage.value = undefined
|
||||
dimensions.value = ''
|
||||
imageReady.value = false
|
||||
dragging.value = false
|
||||
converting.value = false
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stateVersion += 1
|
||||
cleanupUrl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="图像处理" title="PNG → ICO" description="在浏览器本地缩放 PNG,并生成包含多个尺寸的 ICO 文件。">
|
||||
<label v-if="!file" class="upload-zone" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<input type="file" accept="image/png,.png" @change="onFileInput" />
|
||||
<span><ImagePlus :size="28" aria-hidden="true" /><strong>选择或拖入 PNG 图片</strong><span>透明背景会保留,文件不会上传</span></span>
|
||||
</label>
|
||||
<div v-else class="image-workspace">
|
||||
<div class="image-preview"><img :key="previewUrl" :src="previewUrl" alt="待转换 PNG 预览" @load="imageLoaded" @error="imageFailed" /></div>
|
||||
<div class="image-options">
|
||||
<h2>ICO 尺寸</h2>
|
||||
<div class="size-grid">
|
||||
<label v-for="size in availableSizes" :key="size" class="size-option">
|
||||
<input type="checkbox" :checked="selectedSizes.includes(size)" :disabled="converting" @change="toggleSize(size)" />{{ size }} × {{ size }}
|
||||
</label>
|
||||
</div>
|
||||
<p class="file-meta">{{ file.name }} · {{ dimensions }} · {{ (file.size / 1024).toFixed(1) }} KB</p>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" :disabled="converting || !imageReady" @click="convert"><Download :size="15" />{{ converting ? '正在生成…' : imageReady ? '生成并下载 ICO' : '正在读取图片…' }}</button>
|
||||
<label class="action-button" :class="{ disabled: converting }" :aria-disabled="converting"><Upload :size="15" />更换图片<input class="sr-only-file" type="file" accept="image/png,.png" :disabled="converting" @change="onFileInput" /></label>
|
||||
<button class="action-button" type="button" :disabled="converting" @click="clearFile"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, Dices, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { formatBytes, type ByteDisplayFormat } from '@/lib/bytes'
|
||||
import { secureRandomBytes } from '@/lib/random'
|
||||
|
||||
const length = ref(8)
|
||||
const outputFormat = ref<ByteDisplayFormat>('continuous-hex')
|
||||
const uppercase = ref(true)
|
||||
const bytes = shallowRef<Uint8Array>()
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const outputFormats = [
|
||||
{ value: 'continuous-hex' as const, label: '连续 Hex' },
|
||||
{ value: 'spaced-hex' as const, label: '空格 Hex' },
|
||||
{ value: 'byte-array' as const, label: '字节数组' },
|
||||
]
|
||||
const output = computed(() => bytes.value ? formatBytes(bytes.value, {
|
||||
format: outputFormat.value,
|
||||
uppercase: uppercase.value,
|
||||
}) : '')
|
||||
const formatLabel = computed(() => outputFormats.find((item) => item.value === outputFormat.value)?.label ?? 'Hex')
|
||||
|
||||
function generate() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
bytes.value = secureRandomBytes(length.value)
|
||||
success.value = `已使用安全随机源生成 ${length.value * 8} 位随机数据`
|
||||
} catch (reason) {
|
||||
bytes.value = undefined
|
||||
error.value = reason instanceof Error ? reason.message : '随机数生成失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try { await copyText(output.value); success.value = '随机数已复制'; error.value = '' }
|
||||
catch { error.value = '复制失败' }
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
bytes.value = undefined
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch(length, clearAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="安全随机数" description="使用浏览器密码学随机源生成指定字节长度的随机数据。">
|
||||
<div class="random-controls">
|
||||
<label class="parameter-field"><span>字节长度</span><input v-model.number="length" class="text-input" type="number" min="1" max="65536" step="1" /></label>
|
||||
<div class="parameter-field random-format-field"><span>输出格式</span><ModeTabs v-model="outputFormat" :options="outputFormats" label="输出格式" /></div>
|
||||
<label class="check-field random-check"><input v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
</div>
|
||||
<TextEditor :model-value="output" label="随机数" :format="formatLabel" :meta="`${bytes?.length ?? 0} 字节 · ${output.length} 字符`" readonly :rows="11" />
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="generate"><Dices :size="15" />生成随机数</button>
|
||||
<button class="action-button" type="button" :disabled="!output" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制结果</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, KeyRound, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { formatBytes, parseHex } from '@/lib/bytes'
|
||||
import { generateRsaKeyPair, type GeneratedRsaKeyPair } from '@/lib/rsa'
|
||||
|
||||
type OutputMode = 'public' | 'private' | 'parameters'
|
||||
type ParameterFormat = 'hex' | 'byte-array'
|
||||
const modulusLength = ref(2048)
|
||||
const outputMode = ref<OutputMode>('public')
|
||||
const parameterFormat = ref<ParameterFormat>('hex')
|
||||
const result = shallowRef<GeneratedRsaKeyPair>()
|
||||
const generating = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const outputModes = [
|
||||
{ value: 'public' as const, label: '公钥 PEM' },
|
||||
{ value: 'private' as const, label: '私钥 PEM' },
|
||||
{ value: 'parameters' as const, label: 'N / E 参数' },
|
||||
]
|
||||
const parameterFormats = [
|
||||
{ value: 'hex' as const, label: '连续 Hex' },
|
||||
{ value: 'byte-array' as const, label: '字节数组' },
|
||||
]
|
||||
|
||||
const pemOutput = computed(() => {
|
||||
if (!result.value) return ''
|
||||
if (outputMode.value === 'public') return result.value.publicKeyPem
|
||||
if (outputMode.value === 'private') return result.value.privateKeyPem
|
||||
return ''
|
||||
})
|
||||
|
||||
const outputFormat = computed(() => outputMode.value === 'public' ? 'SPKI' : 'PKCS#8')
|
||||
const parameterFormatLabel = computed(() => parameterFormat.value === 'hex' ? '连续 Hex' : '字节数组')
|
||||
const modulusOutput = computed(() => formatParameter(result.value?.modulusHex ?? ''))
|
||||
const exponentOutput = computed(() => formatParameter(result.value?.exponentHex ?? ''))
|
||||
const modulusBytes = computed(() => result.value ? parseHex(result.value.modulusHex).length : 0)
|
||||
const exponentBytes = computed(() => result.value ? parseHex(result.value.exponentHex).length : 0)
|
||||
|
||||
function formatParameter(hex: string): string {
|
||||
if (!hex) return ''
|
||||
return formatBytes(parseHex(hex), {
|
||||
format: parameterFormat.value === 'hex' ? 'continuous-hex' : 'byte-array',
|
||||
})
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
generating.value = true
|
||||
try {
|
||||
result.value = await generateRsaKeyPair(modulusLength.value)
|
||||
outputMode.value = 'public'
|
||||
success.value = `RSA 密钥对已生成 · ${result.value.source}`
|
||||
} catch (reason) {
|
||||
result.value = undefined
|
||||
error.value = reason instanceof Error ? reason.message : 'RSA 密钥生成失败'
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyValue(value: string, label: string) {
|
||||
try { await copyText(value); success.value = `${label}已复制`; error.value = '' }
|
||||
catch { error.value = '复制失败' }
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
result.value = undefined
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
|
||||
watch(modulusLength, clearAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="RSA 密钥对" description="使用安全随机源生成 RSA 密钥对,并导出标准 PEM 或公钥 N/E 参数。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">密钥长度
|
||||
<select v-model.number="modulusLength" :disabled="generating"><option :value="2048">2048 位</option><option :value="3072">3072 位</option><option :value="4096">4096 位</option></select>
|
||||
</label>
|
||||
<span class="fixed-parameter">Public exponent <strong>65537 · 0x010001</strong></span>
|
||||
</div>
|
||||
<div class="action-row rsa-generate-row">
|
||||
<button class="action-button primary" type="button" :disabled="generating" @click="generate"><KeyRound :size="15" />{{ generating ? '正在生成…' : '生成密钥对' }}</button>
|
||||
<button class="action-button" type="button" :disabled="!result || generating" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
<template v-if="result">
|
||||
<div class="tool-controls rsa-output-tabs">
|
||||
<ModeTabs v-model="outputMode" :options="outputModes" label="输出格式" />
|
||||
<span class="control-spacer" />
|
||||
<button v-if="outputMode !== 'parameters'" class="action-button ghost" type="button" @click="copyValue(pemOutput, '当前结果')"><Copy :size="15" />复制当前结果</button>
|
||||
</div>
|
||||
<template v-if="outputMode === 'parameters'">
|
||||
<div class="tool-controls rsa-parameter-format">
|
||||
<ModeTabs v-model="parameterFormat" :options="parameterFormats" label="参数格式" />
|
||||
</div>
|
||||
<div class="rsa-parameter-grid">
|
||||
<section>
|
||||
<TextEditor :model-value="modulusOutput" label="模数 N" :format="parameterFormatLabel" :meta="`${modulusBytes} 字节 · ${modulusOutput.length} 字符`" readonly :rows="14" />
|
||||
<div class="action-row rsa-copy-row"><button class="action-button ghost" type="button" @click="copyValue(modulusOutput, 'N 参数')"><Copy :size="15" />复制 N</button></div>
|
||||
</section>
|
||||
<section>
|
||||
<TextEditor :model-value="exponentOutput" label="指数 E" :format="parameterFormatLabel" :meta="`${exponentBytes} 字节 · ${exponentOutput.length} 字符`" readonly :rows="4" />
|
||||
<div class="action-row rsa-copy-row"><button class="action-button ghost" type="button" @click="copyValue(exponentOutput, 'E 参数')"><Copy :size="15" />复制 E</button></div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<TextEditor v-else :model-value="pemOutput" label="生成结果" :format="outputFormat" :meta="`${pemOutput.length} 字符`" readonly :rows="16" />
|
||||
</template>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, Minimize2, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import xmlFormatter from 'xml-formatter'
|
||||
import SyntaxViewer from '@/components/SyntaxViewer.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
|
||||
const input = ref('<tools><tool id="hex">ASCII / Hex</tool><tool id="cmac">AES-128-CMAC</tool></tools>')
|
||||
const output = ref('')
|
||||
const indent = ref('2')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
function clearResult() {
|
||||
output.value = ''
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch([input, indent], clearResult)
|
||||
|
||||
function validateXml() {
|
||||
if (!input.value.trim()) throw new Error('请输入 XML 内容')
|
||||
const document = new DOMParser().parseFromString(input.value, 'application/xml')
|
||||
const parserError = document.querySelector('parsererror')
|
||||
if (parserError) throw new Error(parserError.textContent?.split('\n')[0] || 'XML 格式无效')
|
||||
}
|
||||
function format() {
|
||||
try {
|
||||
validateXml()
|
||||
output.value = xmlFormatter(input.value, { indentation: ' '.repeat(Number(indent.value)), lineSeparator: '\n', strictMode: true })
|
||||
error.value = ''; success.value = 'XML 格式正确'
|
||||
} catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
function minify() {
|
||||
try { validateXml(); output.value = xmlFormatter.minify(input.value, { strictMode: true }); error.value = ''; success.value = 'XML 已压缩' }
|
||||
catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
function validate() {
|
||||
try { validateXml(); error.value = ''; success.value = 'XML 格式正确' }
|
||||
catch (reason) { success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
async function copyResult() { try { await copyText(output.value); success.value = '结果已复制' } catch { error.value = '复制失败' } }
|
||||
function clearAll() { input.value = ''; clearResult() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="格式化" title="XML" description="格式化、压缩并校验 XML 字符串。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">缩进
|
||||
<select v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="single-editor-layout">
|
||||
<TextEditor v-model="input" label="输入" format="XML" :meta="`${input.length} 字符`" placeholder="粘贴 XML 字符串" :rows="14" />
|
||||
<SyntaxViewer :value="output" label="结果" language="xml" format="XML" :meta="`${output.length} 字符`" />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="format"><Sparkles :size="15" />格式化</button>
|
||||
<button class="action-button" type="button" @click="minify"><Minimize2 :size="15" />压缩</button>
|
||||
<button class="action-button" type="button" @click="validate"><CheckCircle2 :size="15" />校验</button>
|
||||
<button class="action-button" type="button" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!output" @click="copyResult"><Copy :size="15" />复制</button>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
Binary,
|
||||
Braces,
|
||||
Code2,
|
||||
Dices,
|
||||
FileCode2,
|
||||
Image,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
} from '@lucide/vue'
|
||||
import { defineAsyncComponent, type Component } from 'vue'
|
||||
import catalogData from '@shared/tool-catalog.json'
|
||||
import type { CatalogCategory, CatalogTool } from '@/types'
|
||||
|
||||
export const iconComponents: Record<string, Component> = {
|
||||
BadgeCheck,
|
||||
Binary,
|
||||
Braces,
|
||||
Code2,
|
||||
Dices,
|
||||
FileCode2,
|
||||
Image,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
}
|
||||
|
||||
export const categories = catalogData.categories as CatalogCategory[]
|
||||
export const tools = (catalogData.tools as CatalogTool[]).map((tool) => {
|
||||
const iconComponent = iconComponents[tool.icon]
|
||||
if (!iconComponent) throw new Error(`工具 ${tool.id} 使用了未注册图标 ${tool.icon}`)
|
||||
return { ...tool, iconComponent }
|
||||
})
|
||||
|
||||
export const categoryById = new Map(categories.map((category) => [category.id, category]))
|
||||
export const toolById = new Map(tools.map((tool) => [tool.id, tool]))
|
||||
|
||||
export const toolComponents: Record<string, Component> = {
|
||||
'ascii-hex': defineAsyncComponent(() => import('./AsciiHexTool.vue')),
|
||||
base64: defineAsyncComponent(() => import('./Base64Tool.vue')),
|
||||
'aes-cmac': defineAsyncComponent(() => import('./AesCmacTool.vue')),
|
||||
'rsa-key-pair': defineAsyncComponent(() => import('./RsaKeyPairTool.vue')),
|
||||
random: defineAsyncComponent(() => import('./RandomTool.vue')),
|
||||
crc: defineAsyncComponent(() => import('./CrcTool.vue')),
|
||||
json: defineAsyncComponent(() => import('./JsonTool.vue')),
|
||||
xml: defineAsyncComponent(() => import('./XmlTool.vue')),
|
||||
'png-ico': defineAsyncComponent(() => import('./PngIcoTool.vue')),
|
||||
}
|
||||
|
||||
export function assertToolRegistryConsistency() {
|
||||
const categoryIds = new Set<string>()
|
||||
for (const category of categories) {
|
||||
if (categoryIds.has(category.id)) throw new Error(`工具分类重复:${category.id}`)
|
||||
categoryIds.add(category.id)
|
||||
}
|
||||
|
||||
const toolIds = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
if (toolIds.has(tool.id)) throw new Error(`工具 ID 重复:${tool.id}`)
|
||||
if (!categoryIds.has(tool.category)) throw new Error(`工具 ${tool.id} 使用了不存在的分类 ${tool.category}`)
|
||||
toolIds.add(tool.id)
|
||||
}
|
||||
|
||||
const componentIds = Object.keys(toolComponents)
|
||||
const missingComponents = [...toolIds].filter((id) => !(id in toolComponents))
|
||||
const unknownComponents = componentIds.filter((id) => !toolIds.has(id))
|
||||
if (missingComponents.length || unknownComponents.length) {
|
||||
throw new Error(`工具目录与懒加载组件不一致:缺少 [${missingComponents.join(', ')}],多余 [${unknownComponents.join(', ')}]`)
|
||||
}
|
||||
}
|
||||
|
||||
assertToolRegistryConsistency()
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export interface CatalogCategory {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface CatalogTool {
|
||||
id: string
|
||||
title: string
|
||||
category: string
|
||||
description: string
|
||||
icon: string
|
||||
iconComponent?: Component
|
||||
}
|
||||
|
||||
export interface NavigationCategory {
|
||||
id: string
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
export interface NavigationConfig {
|
||||
version: 1
|
||||
categories: NavigationCategory[]
|
||||
}
|
||||
|
||||
export interface ModeOption<T extends string = string> {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, ArrowDown, ArrowUp, CheckCircle2, GripVertical, RotateCcw, Save } from '@lucide/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { categories, categoryById, toolById } from '@/tools/registry'
|
||||
import { useNavigation } from '@/composables/useNavigation'
|
||||
import {
|
||||
cloneNavigation,
|
||||
moveCategoryBy,
|
||||
moveToolBy,
|
||||
moveToolToCategory,
|
||||
relocateTool,
|
||||
reorderCategory,
|
||||
} from '@/lib/navigationOrder'
|
||||
import type { NavigationConfig } from '@/types'
|
||||
|
||||
const { navigation, loadError, loadNavigation, resetLocalNavigation, saveNavigation } = useNavigation()
|
||||
const draft = ref<NavigationConfig>(resetLocalNavigation())
|
||||
const original = ref('')
|
||||
const ready = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const draggedTool = ref<{ categoryIndex: number; toolIndex: number }>()
|
||||
const draggedCategory = ref<number>()
|
||||
|
||||
const dirty = computed(() => ready.value && JSON.stringify(draft.value) !== original.value)
|
||||
|
||||
function cloneCurrent() {
|
||||
draft.value = cloneNavigation(navigation.value as NavigationConfig)
|
||||
original.value = JSON.stringify(draft.value)
|
||||
}
|
||||
|
||||
function moveCategory(index: number, delta: number) {
|
||||
draft.value = moveCategoryBy(draft.value, index, delta)
|
||||
}
|
||||
|
||||
function moveTool(categoryIndex: number, toolIndex: number, delta: number) {
|
||||
draft.value = moveToolBy(draft.value, categoryIndex, toolIndex, delta)
|
||||
}
|
||||
|
||||
function changeToolCategory(toolId: string, sourceCategoryIndex: number, targetCategoryId: string) {
|
||||
draft.value = moveToolToCategory(draft.value, toolId, sourceCategoryIndex, targetCategoryId)
|
||||
}
|
||||
|
||||
function startToolDrag(categoryIndex: number, toolIndex: number) {
|
||||
draggedCategory.value = undefined
|
||||
draggedTool.value = { categoryIndex, toolIndex }
|
||||
}
|
||||
|
||||
function startCategoryDrag(categoryIndex: number) {
|
||||
draggedTool.value = undefined
|
||||
draggedCategory.value = categoryIndex
|
||||
}
|
||||
|
||||
function dropTool(targetCategoryIndex: number, targetToolIndex: number) {
|
||||
const source = draggedTool.value
|
||||
draggedTool.value = undefined
|
||||
if (!source) return
|
||||
draft.value = relocateTool(draft.value, source, { categoryIndex: targetCategoryIndex, toolIndex: targetToolIndex })
|
||||
}
|
||||
|
||||
function dropCategory(targetIndex: number) {
|
||||
const sourceIndex = draggedCategory.value
|
||||
draggedCategory.value = undefined
|
||||
if (sourceIndex === undefined || sourceIndex === targetIndex) return
|
||||
draft.value = reorderCategory(draft.value, sourceIndex, targetIndex)
|
||||
}
|
||||
|
||||
function restoreDefaults() {
|
||||
if (!ready.value) return
|
||||
draft.value = resetLocalNavigation()
|
||||
error.value = ''
|
||||
success.value = '已恢复默认顺序,保存后生效'
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!ready.value || !dirty.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
const result = await saveNavigation(cloneNavigation(draft.value))
|
||||
draft.value = cloneNavigation(result)
|
||||
original.value = JSON.stringify(result)
|
||||
success.value = '导航顺序已保存'
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadNavigation()
|
||||
cloneCurrent()
|
||||
if (loadError.value) error.value = loadError.value
|
||||
ready.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="setting-page">
|
||||
<header class="setting-heading">
|
||||
<div>
|
||||
<p class="setting-category">工具管理</p>
|
||||
<h1>导航顺序</h1>
|
||||
<p class="setting-description">拖动分类或工具调整顺序,也可以用方向按钮精确移动。</p>
|
||||
</div>
|
||||
<div class="setting-heading-actions">
|
||||
<button class="action-button" type="button" :disabled="!ready || saving" @click="restoreDefaults"><RotateCcw :size="15" />恢复默认</button>
|
||||
<button class="action-button primary" type="button" :disabled="!ready || saving || !dirty" @click="save"><Save :size="15" />{{ saving ? '保存中…' : '保存顺序' }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!ready" class="setting-loading" role="status">正在加载导航配置…</div>
|
||||
<div v-else class="setting-list">
|
||||
<section
|
||||
v-for="(navigationCategory, categoryIndex) in draft.categories"
|
||||
:key="navigationCategory.id"
|
||||
class="setting-category-block"
|
||||
@dragover.prevent
|
||||
@drop="draggedTool ? dropTool(categoryIndex, navigationCategory.tools.length) : dropCategory(categoryIndex)"
|
||||
>
|
||||
<header class="setting-category-header" draggable="true" @dragstart="startCategoryDrag(categoryIndex)" @dragend="draggedCategory = undefined">
|
||||
<GripVertical class="drag-handle" :size="16" aria-hidden="true" />
|
||||
<h2>{{ categoryById.get(navigationCategory.id)?.title }}</h2>
|
||||
<small>{{ navigationCategory.tools.length }} 个工具</small>
|
||||
<span class="row-actions">
|
||||
<button class="row-action" type="button" aria-label="分类上移" :disabled="categoryIndex === 0" @click="moveCategory(categoryIndex, -1)"><ArrowUp :size="15" /></button>
|
||||
<button class="row-action" type="button" aria-label="分类下移" :disabled="categoryIndex === draft.categories.length - 1" @click="moveCategory(categoryIndex, 1)"><ArrowDown :size="15" /></button>
|
||||
</span>
|
||||
</header>
|
||||
<div class="setting-tool-list">
|
||||
<div
|
||||
v-for="(toolId, toolIndex) in navigationCategory.tools"
|
||||
:key="toolId"
|
||||
class="setting-tool-row"
|
||||
:class="{ 'is-dragging': draggedTool?.categoryIndex === categoryIndex && draggedTool?.toolIndex === toolIndex }"
|
||||
draggable="true"
|
||||
@dragstart="startToolDrag(categoryIndex, toolIndex)"
|
||||
@dragend="draggedTool = undefined"
|
||||
@dragover.prevent
|
||||
@drop.stop="dropTool(categoryIndex, toolIndex)"
|
||||
>
|
||||
<GripVertical class="drag-handle" :size="16" aria-hidden="true" />
|
||||
<span class="tool-row-icon"><component :is="toolById.get(toolId)?.iconComponent" :size="15" /></span>
|
||||
<strong>{{ toolById.get(toolId)?.title }}</strong>
|
||||
<select class="select-input" :value="navigationCategory.id" aria-label="所属分类" @change="changeToolCategory(toolId, categoryIndex, ($event.target as HTMLSelectElement).value)">
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">{{ category.title }}</option>
|
||||
</select>
|
||||
<span class="row-actions">
|
||||
<button class="row-action" type="button" aria-label="工具上移" :disabled="toolIndex === 0" @click="moveTool(categoryIndex, toolIndex, -1)"><ArrowUp :size="15" /></button>
|
||||
<button class="row-action" type="button" aria-label="工具下移" :disabled="toolIndex === navigationCategory.tools.length - 1" @click="moveTool(categoryIndex, toolIndex, 1)"><ArrowDown :size="15" /></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="error" class="status-message error save-state"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success save-state"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { calculateCrcReport, type CrcWorkerRequest, type CrcWorkerResponse } from '@/lib/crcWorker'
|
||||
import { getFirmwareFormat, MAX_FIRMWARE_FILE_SIZE, parseFirmwareContent } from '@/lib/firmware'
|
||||
import type { DataBlock } from '@/lib/intelHex'
|
||||
|
||||
const workerScope = globalThis as unknown as {
|
||||
onmessage: ((event: MessageEvent<CrcWorkerRequest>) => void) | null
|
||||
postMessage(message: CrcWorkerResponse): void
|
||||
}
|
||||
|
||||
let firmwareBlocks: DataBlock[] | undefined
|
||||
|
||||
workerScope.onmessage = async (event) => {
|
||||
const request = event.data
|
||||
try {
|
||||
if (request.type === 'parse') {
|
||||
if (request.file.size === 0) throw new Error('文件为空')
|
||||
if (request.file.size > MAX_FIRMWARE_FILE_SIZE) throw new Error('文件不能超过 64 MiB')
|
||||
const format = getFirmwareFormat(request.file.name)
|
||||
const content = format === 'Intel HEX'
|
||||
? await request.file.text()
|
||||
: new Uint8Array(await request.file.arrayBuffer())
|
||||
const parsed = parseFirmwareContent(request.file.name, content)
|
||||
firmwareBlocks = parsed.blocks
|
||||
workerScope.postMessage({
|
||||
type: 'parsed',
|
||||
id: request.id,
|
||||
format: parsed.format,
|
||||
totalBytes: parsed.totalBytes,
|
||||
blockCount: parsed.blocks.length,
|
||||
warnings: parsed.warnings,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!firmwareBlocks) throw new Error('请先选择 BIN 或 Intel HEX 文件')
|
||||
workerScope.postMessage({ type: 'calculated', id: request.id, ...calculateCrcReport(firmwareBlocks, request.parameters) })
|
||||
} catch (reason) {
|
||||
workerScope.postMessage({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
operation: request.type,
|
||||
error: reason instanceof Error ? reason.message : 'CRC 计算失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user