调整,音乐模块

This commit is contained in:
zhongluofeng
2026-09-12 11:05:26 +08:00
parent 27ad5d89a5
commit d702ed0d31
71 changed files with 13647 additions and 387 deletions
+166
View File
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ArrowDownUp, Upload } from '@lucide/vue'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import Segmented from '../components/Segmented.vue'
import ResultArea from '../components/ResultArea.vue'
type Tab = 'convert' | 'file'
const tab = ref<Tab>('convert')
const mode = ref<'encode' | 'decode'>('encode')
const input = ref('')
const error = ref('')
function encode(s: string): string {
const bytes = new TextEncoder().encode(s)
let bin = ''
for (const b of bytes) bin += String.fromCharCode(b)
return btoa(bin)
}
function decode(s: string): string {
const cleaned = s.replace(/[\r\n\s]/g, '')
const bin = atob(cleaned)
const bytes = Uint8Array.from(bin, ch => ch.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
const output = computed(() => {
error.value = ''
const v = input.value
if (!v) return ''
try {
return mode.value === 'encode' ? encode(v) : decode(v)
} catch (e) {
error.value = '解码失败:' + String(e)
return ''
}
})
const swap = () => {
if (output.value) {
input.value = output.value
mode.value = mode.value === 'encode' ? 'decode' : 'encode'
}
}
// ===== 文件转 Base64 =====
const file = ref<File | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const fileBase64 = ref('')
const fileError = ref('')
const fileLoading = ref(false)
const FILE_SIZE_LIMIT = 64 * 1024 * 1024 // 64MB 保护上限(Base64 展示本身就很占内存)
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const mime = computed(() => file.value?.type || 'application/octet-stream')
async function onFileChange(e: Event) {
const el = e.target as HTMLInputElement
const f = el.files?.[0] ?? null
fileError.value = ''
fileBase64.value = ''
file.value = f
if (!f) return
if (f.size > FILE_SIZE_LIMIT) {
fileError.value = `文件过大(${formatSize(f.size)}),请使用 64MB 以内的文件`
return
}
fileLoading.value = true
try {
const buf = await f.arrayBuffer()
const bytes = new Uint8Array(buf)
let bin = ''
const CHUNK = 0x8000
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK))
}
fileBase64.value = btoa(bin)
} catch (e) {
fileError.value = '读取失败:' + String(e)
} finally {
fileLoading.value = false
}
}
function clearFile() {
file.value = null
fileBase64.value = ''
fileError.value = ''
if (fileInput.value) fileInput.value.value = ''
}
const dataUrl = computed(() =>
fileBase64.value ? `data:${mime.value};base64,${fileBase64.value}` : ''
)
</script>
<template>
<div class="flex flex-col gap-4">
<Segmented
v-model="tab"
:options="[
{ value: 'convert', label: '文本互转' },
{ value: 'file', label: '文件转 Base64' }
]"
/>
<!-- 文本互转 -->
<template v-if="tab === 'convert'">
<div class="flex items-center justify-between flex-wrap gap-2">
<Segmented
v-model="mode"
label="操作"
:options="[
{ value: 'encode', label: '编码' },
{ value: 'decode', label: '解码' }
]"
/>
<Button size="sm" variant="outline" class="h-8 text-sm gap-1" :disabled="!output" @click="swap">
<ArrowDownUp class="size-3.5" />
结果回填
</Button>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">{{ mode === 'encode' ? '原文' : 'Base64 字符串' }}</Label>
<Textarea v-model="input" placeholder="在此输入..." class="min-h-[120px] font-mono text-xs resize-y" />
</div>
<ResultArea :text="output" placeholder="结果" />
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
</template>
<!-- 文件转 Base64 -->
<template v-else>
<div class="flex items-center gap-2 flex-wrap">
<Button size="sm" variant="outline" class="h-8 text-sm gap-1.5" @click="fileInput?.click()">
<Upload class="size-3.5" />
选择文件
</Button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<template v-if="file">
<span class="text-xs font-mono text-muted-foreground">{{ file.name }}{{ formatSize(file.size) }}{{ mime }}</span>
<Button size="sm" variant="ghost" class="h-7 text-xs" @click="clearFile">移除</Button>
</template>
</div>
<p v-if="fileError" class="text-xs text-destructive">{{ fileError }}</p>
<template v-if="fileBase64">
<ResultArea :text="fileBase64" label="Base64" placeholder="Base64" minHeight="100px" />
<ResultArea :text="dataUrl" label="Data URL" placeholder="Data URL" minHeight="100px" />
<p class="text-xs text-muted-foreground">Base64 体积约为原文件的 4/3{{ formatSize(fileBase64.length) }}</p>
</template>
<p v-else-if="!fileLoading" class="text-xs text-muted-foreground">选择文件后自动生成 Base64 Data URL</p>
<p v-else class="text-xs text-muted-foreground">读取中...</p>
</template>
</div>
</template>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import Segmented from '../components/Segmented.vue'
import ResultArea from '../components/ResultArea.vue'
type CaseMode =
| 'camel' | 'pascal' | 'snake' | 'kebab'
| 'upper' | 'lower' | 'title'
| 'spaceToDash' | 'trimLines' | 'collapseSpace'
const modes: { value: CaseMode; label: string }[] = [
{ value: 'camel', label: 'camelCase' },
{ value: 'pascal', label: 'PascalCase' },
{ value: 'snake', label: 'snake_case' },
{ value: 'kebab', label: 'kebab-case' },
{ value: 'upper', label: '全大写' },
{ value: 'lower', label: '全小写' },
{ value: 'title', label: '标题式' },
{ value: 'spaceToDash', label: '空格转下划线' },
{ value: 'collapseSpace', label: '合并空行空白' },
{ value: 'trimLines', label: '每行去首尾空格' }
]
const mode = ref<CaseMode>('camel')
const input = ref('')
function toWords(s: string): string[] {
// 拆分 camelCase / snake_case / kebab-case / 空格,得到词
return s
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_\-\s]+/g, ' ')
.trim()
.split(' ')
.filter(Boolean)
}
const output = computed(() => {
const v = input.value
if (!v) return ''
switch (mode.value) {
case 'upper':
return v.toUpperCase()
case 'lower':
return v.toLowerCase()
case 'title':
return v.replace(/\b\w/g, ch => ch.toUpperCase())
case 'spaceToDash':
return v.replace(/\s+/g, '_')
case 'collapseSpace':
return v.split(/\n+/).map(l => l.trim()).filter(Boolean).join('\n')
case 'trimLines':
return v.split('\n').map(l => l.trim()).join('\n')
case 'camel': // fallthrough
case 'pascal':
case 'snake':
case 'kebab':
break
}
const words = toWords(v).filter(Boolean)
if (words.length === 0) return ''
if (mode.value === 'camel') {
return words[0].toLowerCase() + words.slice(1).map(w => cap(w)).join('')
}
if (mode.value === 'pascal') {
return words.map(cap).join('')
}
const sep = mode.value === 'snake' ? '_' : '-'
return words.map(w => w.toLowerCase()).join(sep)
})
function cap(w: string): string {
return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
}
</script>
<template>
<div class="flex flex-col gap-4">
<Segmented v-model="mode" :options="modes" />
<div class="flex flex-col gap-1.5">
<Label class="text-xs">原文</Label>
<Textarea v-model="input" placeholder="在此输入..." class="min-h-[140px] font-mono text-xs resize-y" />
</div>
<ResultArea :text="output" placeholder="结果" />
</div>
</template>
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
const input = ref('')
const showFrequency = ref(true)
function utf8Bytes(s: string): number {
return new TextEncoder().encode(s).length
}
/** 统计词:中文按字、英文按单词 */
function countWords(s: string): number {
const cjk = (s.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) ?? []).length
const latin = (s.match(/[a-zA-Z0-9]+(?:[-'][a-zA-Z0-9]+)*/g) ?? []).length
return cjk + latin
}
interface Stats {
chars: number
charsNoSpace: number
bytes: number
words: number
lines: number
nonEmptyLines: number
sentences: number
paragraphs: number
}
const stats = computed<Stats>(() => {
const v = input.value
const lines = v === '' ? 0 : v.split('\n').length
const nonEmpty = v.split('\n').filter(l => l.trim()).length
// 句子:以 。!?.!?;; 结尾的段落片段
const sentences = (v.match(/[^。!?!?\n]+[。!?!?]?/g) ?? []).filter(s => s.trim()).length
const paragraphs = v.split(/\n\s*\n/).filter(p => p.trim()).length
return {
chars: v.length,
charsNoSpace: v.replace(/\s/g, '').length,
bytes: utf8Bytes(v),
words: countWords(v),
lines,
nonEmptyLines: nonEmpty,
sentences,
paragraphs
}
})
const FIELDS: Array<{ key: keyof Stats; label: string }> = [
{ key: 'chars', label: '字符数' },
{ key: 'charsNoSpace', label: '字符数(不含空白)' },
{ key: 'bytes', label: '字节数(UTF-8' },
{ key: 'words', label: '词数(中文按字/英文按词)' },
{ key: 'lines', label: '行数' },
{ key: 'nonEmptyLines', label: '非空行数' },
{ key: 'sentences', label: '句子数' },
{ key: 'paragraphs', label: '段落数(空行分隔)' }
]
/** 高频字符 / 高频词 top 10 */
const topChars = computed(() => {
if (!showFrequency.value || !input.value) return []
const map = new Map<string, number>()
for (const ch of input.value) {
if (/\s/.test(ch)) continue
map.set(ch, (map.get(ch) ?? 0) + 1)
}
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
})
const topWords = computed(() => {
if (!showFrequency.value || !input.value) return []
const tokens = input.value.match(/[\u4e00-\u9fff]|[a-zA-Z0-9]+(?:[-'][a-zA-Z0-9]+)*/g) ?? []
const map = new Map<string, number>()
for (const t of tokens) {
map.set(t, (map.get(t) ?? 0) + 1)
}
return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label class="text-xs">文本</Label>
<label class="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
显示高频统计
<Switch v-model="showFrequency" />
</label>
</div>
<Textarea v-model="input" placeholder="粘贴或输入文本,实时统计..." class="min-h-[160px] font-mono text-xs resize-y" />
</div>
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div v-for="f in FIELDS" :key="f.key" class="flex justify-between px-3 py-1.5">
<span class="text-muted-foreground">{{ f.label }}</span>
<span class="font-mono">{{ stats[f.key].toLocaleString() }}</span>
</div>
</div>
<template v-if="showFrequency && input">
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">高频字符 Top 10</Label>
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div v-for="([ch, n], i) in topChars" :key="i" class="flex justify-between px-3 py-1">
<span class="font-mono w-8 text-center rounded bg-muted">{{ ch }}</span>
<span class="font-mono text-muted-foreground">{{ n }} </span>
</div>
<div v-if="topChars.length === 0" class="px-3 py-2 text-muted-foreground">无内容</div>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">高频词 Top 10</Label>
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div v-for="([w, n], i) in topWords" :key="i" class="flex justify-between px-3 py-1">
<span class="font-mono">{{ w }}</span>
<span class="font-mono text-muted-foreground">{{ n }} </span>
</div>
<div v-if="topWords.length === 0" class="px-3 py-2 text-muted-foreground">无内容</div>
</div>
</div>
</div>
</template>
</div>
</template>
+211
View File
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import ResultArea from '../components/ResultArea.vue'
const input = ref('#3b82f6')
const alpha = ref(1)
interface Rgb {
r: number
g: number
b: number
}
function clampByte(v: number): number {
return Math.min(255, Math.max(0, Math.round(v)))
}
function parseColor(s: string): Rgb | null {
const v = s.trim().toLowerCase()
if (!v) return null
// #rgb / #rgba / #rrggbb / #rrggbbaa
const hex = v.match(/^#?([0-9a-f]{3,8})$/)
if (hex) {
const h = hex[1]
if (h.length === 3 || h.length === 4) {
const [r, g, b] = [h[0], h[1], h[2]].map(c => parseInt(c + c, 16))
return { r, g, b }
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16)
}
}
return null
}
// rgb() / rgba()
const rgb = v.match(/^rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})/)
if (rgb) {
return { r: clampByte(Number(rgb[1])), g: clampByte(Number(rgb[2])), b: clampByte(Number(rgb[3])) }
}
// hsl() / hsla()
const hsl = v.match(/^hsla?\(\s*(\d{1,3}(?:\.\d+)?)\s*[,\s]\s*(\d{1,3})%\s*[,\s]\s*(\d{1,3})%/)
if (hsl) {
return hslToRgb(Number(hsl[1]), Number(hsl[2]) / 100, Number(hsl[3]) / 100)
}
return null
}
function hslToRgb(h: number, s: number, l: number): Rgb {
h = ((h % 360) + 360) % 360
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = l - c / 2
let r = 0, g = 0, b = 0
if (h < 60) { r = c; g = x }
else if (h < 120) { r = x; g = c }
else if (h < 180) { g = c; b = x }
else if (h < 240) { g = x; b = c }
else if (h < 300) { r = x; b = c }
else { r = c; b = x }
return { r: clampByte((r + m) * 255), g: clampByte((g + m) * 255), b: clampByte((b + m) * 255) }
}
function rgbToHsl({ r, g, b }: Rgb): { h: number; s: number; l: number } {
const rn = r / 255, gn = g / 255, bn = b / 255
const max = Math.max(rn, gn, bn)
const min = Math.min(rn, gn, bn)
const l = (max + min) / 2
let h = 0
let s = 0
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60
else if (max === gn) h = ((bn - rn) / d + 2) * 60
else h = ((rn - gn) / d + 4) * 60
}
return { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100) }
}
function rgbToHsv({ r, g, b }: Rgb): { h: number; s: number; v: number } {
const rn = r / 255, gn = g / 255, bn = b / 255
const max = Math.max(rn, gn, bn)
const min = Math.min(rn, gn, bn)
const d = max - min
let h = 0
if (d !== 0) {
if (max === rn) h = (((gn - bn) / d) % 6) * 60
else if (max === gn) h = ((bn - rn) / d + 2) * 60
else h = ((rn - gn) / d + 4) * 60
}
h = Math.round(((h % 360) + 360) % 360)
return { h, s: Math.round((max === 0 ? 0 : d / max) * 100), v: Math.round(max * 100) }
}
function toHex({ r, g, b }: Rgb): string {
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')
}
const rgb = computed<Rgb | null>(() => parseColor(input.value))
const alphaInput = computed(() => {
const a = Math.min(1, Math.max(0, alpha.value))
return Math.round(a * 255)
.toString(16)
.padStart(2, '0')
})
const formats = computed(() => {
const c = rgb.value
if (!c) return null
const hsl = rgbToHsl(c)
const hsv = rgbToHsv(c)
return {
hex: toHex(c),
hexA: `${toHex(c)}${alphaInput.value}`,
rgb: `rgb(${c.r}, ${c.g}, ${c.b})`,
rgba: `rgba(${c.r}, ${c.g}, ${c.b}, ${alpha.value})`,
hsl: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`,
hsla: `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${alpha.value})`,
hsv: `hsv(${hsv.h}, ${hsv.s}%, ${hsv.v}%)`,
cmyk: rgbToCmyk(c)
}
})
function rgbToCmyk({ r, g, b }: Rgb): string {
const rn = r / 255, gn = g / 255, bn = b / 255
const k = 1 - Math.max(rn, gn, bn)
if (k === 1) return 'cmyk(0%, 0%, 0%, 100%)'
const c = (1 - rn - k) / (1 - k)
const m = (1 - gn - k) / (1 - k)
const y = (1 - bn - k) / (1 - k)
const p = (x: number) => Math.round(x * 100)
return `cmyk(${p(c)}%, ${p(m)}%, ${p(y)}%, ${p(k)}%)`
}
// 亮度判断(W3C 公式),用于预览色上的文字颜色
const previewTextLight = computed(() => {
const c = rgb.value
if (!c) return true
return (c.r * 0.299 + c.g * 0.587 + c.b * 0.114) < 140
})
// 明暗梯度
const shades = computed(() => {
const c = rgb.value
if (!c) return []
const hsl = rgbToHsl(c)
return [-60, -40, -20, 0, 20, 40, 60].map(delta => {
const l = Math.min(96, Math.max(4, hsl.l + delta))
const rgb2 = hslToRgb(hsl.h, hsl.s / 100, l / 100)
return { label: delta === 0 ? `${l}%` : `${l}%`, color: toHex(rgb2) }
})
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-end gap-3 flex-wrap">
<div class="flex flex-col gap-1.5 flex-1 min-w-[200px]">
<Label class="text-xs">颜色支持 #hex / rgb() / hsl()</Label>
<Input v-model="input" placeholder="#3b82f6 或 rgb(59,130,246) 或 hsl(217,91%,60%)" class="font-mono text-sm" />
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">透明度 {{ alpha.toFixed(2) }}</Label>
<input v-model.number="alpha" type="range" min="0" max="1" step="0.01" class="w-40 accent-primary" />
</div>
</div>
<template v-if="formats && rgb">
<!-- 预览 -->
<div
class="rounded-md border border-border h-20 flex items-center justify-center font-mono text-sm"
:style="{ backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`, color: previewTextLight ? '#ffffff' : '#000000' }"
>
{{ formats.hex }} {{ alpha < 1 ? `(透明度 ${alpha.toFixed(2)}` : '' }}
</div>
<ResultArea :text="formats.hex" label="HEX" placeholder="HEX" minHeight="60px" />
<ResultArea v-if="alpha < 1" :text="formats.hexA" label="HEX + Alpha" placeholder="HEX" minHeight="60px" />
<ResultArea :text="formats.rgb" label="RGB" placeholder="RGB" minHeight="60px" />
<ResultArea v-if="alpha < 1" :text="formats.rgba" label="RGBA" placeholder="RGBA" minHeight="60px" />
<ResultArea :text="formats.hsl" label="HSL" placeholder="HSL" minHeight="60px" />
<ResultArea v-if="alpha < 1" :text="formats.hsla" label="HSLA" placeholder="HSLA" minHeight="60px" />
<ResultArea :text="formats.hsv" label="HSV" placeholder="HSV" minHeight="60px" />
<ResultArea :text="formats.cmyk" label="CMYK" placeholder="CMYK" minHeight="60px" />
<!-- 明暗梯度 -->
<div class="flex flex-col gap-1.5">
<Label class="text-xs">明暗梯度</Label>
<div class="flex rounded-md overflow-hidden border border-border h-10">
<div
v-for="(s, i) in shades"
:key="i"
class="flex-1 flex items-center justify-center text-[10px] font-mono cursor-pointer"
:style="{ backgroundColor: s.color, color: i < 3 ? '#fff' : '#000' }"
:title="s.color"
@click="input = s.color"
>
{{ s.label }}
</div>
</div>
</div>
</template>
<p v-else-if="input" class="text-xs text-destructive">无法识别的颜色格式支持 #hex / rgb() / rgba() / hsl() / hsla()</p>
</div>
</template>
+228
View File
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import ResultArea from '../components/ResultArea.vue'
const expr = ref('*/5 * * * *')
interface FieldMatcher {
/** 该字段是否为非通配(受限) */
restricted: boolean
match(v: number): boolean
}
function parseField(field: string, min: number, max: number): FieldMatcher {
const values = new Set<number>()
let restricted = false
for (const part of field.split(',')) {
const [rangePart, stepPart] = part.split('/')
const step = stepPart ? Number(stepPart) : 1
if (!Number.isFinite(step) || step < 1) throw new Error(`非法步长:${part}`)
let lo: number
let hi: number
if (rangePart === '*') {
if (!stepPart) {
// 纯通配:匹配任意值(不受限)
return { restricted: false, match: () => true }
}
lo = min
hi = max
} else if (rangePart.includes('-')) {
const [a, b] = rangePart.split('-').map(Number)
if (!Number.isFinite(a) || !Number.isFinite(b) || a < min || b > max || a > b) {
throw new Error(`非法范围:${part}(应在 ${min}-${max} 内)`)
}
lo = a
hi = b
} else {
const n = Number(rangePart)
if (!Number.isFinite(n) || n < min || n > max) {
throw new Error(`非法值:${part}(应在 ${min}-${max} 内)`)
}
lo = n
hi = n
}
restricted = true
for (let v = lo; v <= hi; v += step) values.add(v)
}
if (values.size === 0) throw new Error(`空字段:${field}`)
return { restricted, match: v => values.has(v) }
}
const FIELD_NAMES = ['分钟', '小时', '日', '月', '星期'] as const
// 星期允许 0-7(0 与 7 均为周日)
const FIELD_RANGES: Array<[number, number]> = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]]
const DOW_NAMES = ['日', '一', '二', '三', '四', '五', '六']
/** 支持英文别名(星期/月份)与中文星期 */
function normalizeExpr(s: string): string {
const DOW_MAP: Record<string, string> = {
sun: '0', mon: '1', tue: '2', wed: '3', thu: '4', fri: '5', sat: '6',
'日': '0', '一': '1', '二': '2', '三': '3', '四': '4', '五': '5', '六': '6'
}
const MON_MAP: Record<string, string> = {
jan: '1', feb: '2', mar: '3', apr: '4', may: '5', jun: '6',
jul: '7', aug: '8', sep: '9', oct: '10', nov: '11', dec: '12'
}
let out = s.trim().toLowerCase()
for (const [k, v] of Object.entries(MON_MAP)) {
out = out.split(k).join(v)
}
for (const [k, v] of Object.entries(DOW_MAP)) {
out = out.split(k).join(v)
}
return out
}
interface CronParsed {
minute: FieldMatcher
hour: FieldMatcher
dom: FieldMatcher
month: FieldMatcher
dow: FieldMatcher
domRestricted: boolean
dowRestricted: boolean
}
function parseCron(s: string): CronParsed {
const parts = normalizeExpr(s).split(/\s+/)
if (parts.length !== 5) {
throw new Error(`应为 5 个字段(分 时 日 月 周),当前 ${parts.length}`)
}
const [minute, hour, dom, month, dowRaw] = parts.map((p, i) => parseField(p, FIELD_RANGES[i][0], FIELD_RANGES[i][1]))
// cron 标准允许星期用 0-7,其中 0 与 7 均为周日
const dow: FieldMatcher = {
restricted: dowRaw.restricted,
match: v => dowRaw.match(v) || (v === 0 && dowRaw.match(7))
}
return {
minute, hour, dom, month, dow,
domRestricted: dom.restricted,
dowRestricted: dow.restricted
}
}
/** 标准 cron 语义:日 与 星期 都受限时,任一匹配即可 */
function matches(p: CronParsed, d: Date): boolean {
if (!p.minute.match(d.getMinutes())) return false
if (!p.hour.match(d.getHours())) return false
if (!p.month.match(d.getMonth() + 1)) return false
const domOk = p.dom.match(d.getDate())
const dowOk = p.dow.match(d.getDay())
if (p.domRestricted && p.dowRestricted) return domOk || dowOk
return domOk && dowOk
}
function nextRuns(p: CronParsed, from: Date, n: number): Date[] {
const out: Date[] = []
const t = new Date(from.getTime())
t.setSeconds(0, 0)
t.setMinutes(t.getMinutes() + 1)
// 最多向前扫描 5 年(处理 2 月 29 日等罕见窗口)
const limit = new Date(from.getTime() + 5 * 365.25 * 24 * 3600 * 1000)
while (out.length < n && t < limit) {
if (matches(p, t)) out.push(new Date(t.getTime()))
t.setMinutes(t.getMinutes() + 1)
}
return out
}
const pad = (x: number) => String(x).padStart(2, '0')
function fmt(d: Date): string {
const week = `${DOW_NAMES[d.getDay()]}`
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())} ${week}`
}
const parsed = computed<{ runs: string[]; fields: string[] } | null>(() => {
const s = expr.value.trim()
if (!s) return null
try {
const p = parseCron(s)
const runs = nextRuns(p, new Date(), 6)
const fields = s.split(/\s+/)
return { runs: runs.map(fmt), fields }
} catch {
return null
}
})
const error = computed(() => {
const s = expr.value.trim()
if (!s) return ''
try {
parseCron(s)
return ''
} catch (e) {
return String(e instanceof Error ? e.message : e)
}
})
/** 人类可读的字段说明 */
const fieldDesc = computed<string[]>(() => {
const s = expr.value.trim()
if (!s || error.value) return []
const fields = normalizeExpr(s).split(/\s+/)
return fields.map((f, i) => {
if (f === '*') return `${FIELD_NAMES[i]}:任意`
return `${FIELD_NAMES[i]}${f}`
})
})
const EXAMPLES = [
{ expr: '*/5 * * * *', desc: '每 5 分钟' },
{ expr: '0 * * * *', desc: '每小时整点' },
{ expr: '30 9 * * 1-5', desc: '工作日 9:30' },
{ expr: '0 0 1 * *', desc: '每月 1 日零点' },
{ expr: '0 12 */2 * *', desc: '每 2 天的 12:00' }
]
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">Cron 表达式 支持英文/中文星期与英文月份</Label>
<Input v-model="expr" placeholder="*/5 * * * *" class="font-mono text-sm" :class="{ 'border-destructive': !!error }" />
<div class="flex items-center gap-2 flex-wrap">
<button
v-for="ex in EXAMPLES"
:key="ex.expr"
type="button"
class="text-xs px-2 py-0.5 rounded border border-border text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
@click="expr = ex.expr"
>
{{ ex.desc }}
</button>
</div>
</div>
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
<template v-if="parsed">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">字段拆解</Label>
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div v-for="(f, i) in parsed.fields" :key="i" class="flex gap-3 px-3 py-1.5">
<span class="w-12 shrink-0 text-muted-foreground">{{ FIELD_NAMES[i] }}</span>
<span class="font-mono">{{ f }}</span>
<span class="ml-auto text-muted-foreground">{{ fieldDesc[i]?.split('')[1] ?? '' }}</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">接下来 6 次执行时间</Label>
<div class="rounded-md border border-border divide-y divide-border text-xs font-mono">
<div v-for="(r, i) in parsed.runs" :key="i" class="px-3 py-1.5 flex gap-3">
<span class="text-muted-foreground w-4">+{{ i + 1 }}</span>
<span>{{ r }}</span>
</div>
<div v-if="parsed.runs.length === 0" class="px-3 py-2 text-muted-foreground">5 年内无执行时间表达式可能过严 2 30 </div>
</div>
</div>
<ResultArea :text="parsed.runs.join('\n')" label="执行时间列表(可复制)" placeholder="执行时间" />
</template>
<p v-else-if="!expr" class="text-xs text-muted-foreground">输入 5 段式 cron 表达式自动计算接下来 6 次执行时间</p>
</div>
</template>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import ResultArea from '../components/ResultArea.vue'
const oldText = ref('')
const newText = ref('')
type Op = 'equal' | 'del' | 'add'
interface DiffLine {
op: Op
oldLine: string
line: string
newLine: string
}
// LCS DP 为 O(n·m),行数过大会卡死 UI;超过上限停止计算并提示
const MAX_LINES = 3000
// 先裁剪公共前缀/后缀,LCS 只算中间差异部分(典型场景提速明显)
function diffLines(a: string[], b: string[]): DiffLine[] {
const out: DiffLine[] = []
let start = 0
while (start < a.length && start < b.length && a[start] === b[start]) {
out.push({ op: 'equal', oldLine: a[start], line: a[start], newLine: b[start] })
start++
}
let endA = a.length, endB = b.length
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
endA--
endB--
}
const mid = lcsDiff(a.slice(start, endA), b.slice(start, endB))
// 后缀公共部分:a[endA..] 与 b[endB..] 逐行配对
const suffix: DiffLine[] = []
for (let i = a.length - 1; i >= endA; i--) {
const j = endB + (i - endA)
suffix.unshift({ op: 'equal', oldLine: a[i], line: a[i], newLine: b[j] })
}
return [...out, ...mid, ...suffix]
}
function lcsDiff(a: string[], b: string[]): DiffLine[] {
const n = a.length
const m = b.length
if (n === 0) return b.map(line => ({ op: 'add' as const, oldLine: '', line, newLine: '' }))
if (m === 0) return a.map(line => ({ op: 'del' as const, oldLine: line, line, newLine: '' }))
// LCS DP
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
}
}
const out: DiffLine[] = []
let i = 0
let j = 0
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ op: 'equal', oldLine: a[i], line: a[i], newLine: b[j] })
i++
j++
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ op: 'del', oldLine: a[i], line: a[i], newLine: '' })
i++
} else {
out.push({ op: 'add', oldLine: '', line: b[j], newLine: b[j] })
j++
}
}
while (i < n) {
out.push({ op: 'del', oldLine: a[i], line: a[i], newLine: '' })
i++
}
while (j < m) {
out.push({ op: 'add', oldLine: '', line: b[j], newLine: b[j] })
j++
}
return out
}
const tooLarge = computed(() =>
oldText.value.split('\n').length > MAX_LINES || newText.value.split('\n').length > MAX_LINES
)
const lines = computed(() => {
if (tooLarge.value) return []
return diffLines(oldText.value.split('\n'), newText.value.split('\n'))
})
const stats = computed(() => {
let adds = 0
let dels = 0
for (const l of lines.value) {
if (l.op === 'add') adds++
else if (l.op === 'del') dels++
}
return { adds, dels }
})
const unifiedText = computed(() => {
if (lines.value.length === 0) return ''
return lines.value
.map(l => (l.op === 'add' ? '+' : l.op === 'del' ? '-' : ' ') + l.line)
.join('\n')
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label class="text-xs text-red-500">旧文本删除 {{ stats.dels }} </Label>
<Textarea v-model="oldText" placeholder="旧文本..." class="min-h-[140px] font-mono text-xs resize-y" />
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs text-green-500">新文本新增 {{ stats.adds }} </Label>
<Textarea v-model="newText" placeholder="新文本..." class="min-h-[140px] font-mono text-xs resize-y" />
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">差异预览</Label>
<div class="rounded-md border border-border max-h-64 overflow-auto font-mono text-xs leading-relaxed">
<div v-if="tooLarge" class="p-3 text-destructive">文本超过 {{ MAX_LINES }} 差异计算已停止LCS 算法复杂度 O(n·m)请缩减输入</div>
<div v-else-if="lines.length === 0 || (oldText === '' && newText === '')" class="p-3 text-muted-foreground">输入两边文本查看差异</div>
<div
v-for="(l, i) in lines"
:key="i"
class="flex whitespace-pre px-2 py-0.5"
:class="{
'bg-red-500/10 text-red-600 dark:text-red-400': l.op === 'del',
'bg-green-500/10 text-green-600 dark:text-green-400': l.op === 'add',
'text-muted-foreground': l.op === 'equal'
}"
>
<span class="w-5 shrink-0 select-none">{{ l.op === 'add' ? '+' : l.op === 'del' ? '-' : ' ' }}</span>
<span class="break-all">{{ l.line }}</span>
</div>
</div>
</div>
<ResultArea :text="unifiedText" label="统一格式(可复制)" placeholder="unified diff" />
</div>
</template>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import Segmented from '../components/Segmented.vue'
import ResultArea from '../components/ResultArea.vue'
type Eol = 'CRLF' | 'LF' | 'CR'
const target = ref<Eol>('LF')
const input = ref('')
const detectEol = computed<Eol | 'mixed' | 'none'>(() => {
const v = input.value
if (!v) return 'none'
// 注意:\r\n 中的 \n 也会命中 /\n/,必须用负向后顾排除 CRLF 中的 LF
const hasCrlf = v.includes('\r\n')
const hasLfOnly = /(?<!\r)\n/.test(v)
const hasCrOnly = /\r(?!\n)/.test(v)
if (!hasCrlf && !hasLfOnly && !hasCrOnly) return 'none'
if (hasCrlf && !hasLfOnly && !hasCrOnly) return 'CRLF'
if (!hasCrlf && hasLfOnly && !hasCrOnly) return 'LF'
if (!hasCrlf && !hasLfOnly && hasCrOnly) return 'CR'
return 'mixed'
})
const detectLabel: Record<string, string> = {
CRLF: 'CRLFWindows',
LF: 'LFUnix / macOS',
CR: 'CR(旧 Mac',
mixed: '混合',
none: '未检测到换行符'
}
const output = computed(() => {
const v = input.value
if (!v) return ''
const sep = target.value === 'CRLF' ? '\r\n' : target.value === 'LF' ? '\n' : '\r'
return v.replace(/\r\n|\r|\n/g, sep)
})
</script>
<template>
<div class="flex flex-col gap-4">
<Segmented
v-model="target"
label="目标行尾符"
:options="[
{ value: 'CRLF', label: 'CRLF (\\r\\n)' },
{ value: 'LF', label: 'LF (\\n)' },
{ value: 'CR', label: 'CR (\\r)' }
]"
/>
<p class="text-xs text-muted-foreground">当前检测<span class="font-medium">{{ detectLabel[detectEol] }}</span></p>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">原文</Label>
<Textarea v-model="input" placeholder="在此输入..." class="min-h-[160px] font-mono text-xs resize-y" />
</div>
<ResultArea :text="output" placeholder="结果" />
</div>
</template>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ArrowDownUp } from '@lucide/vue'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import Segmented from '../components/Segmented.vue'
import ResultArea from '../components/ResultArea.vue'
const type = ref<'html' | 'json'>('html')
const mode = ref<'encode' | 'decode'>('encode')
const input = ref('')
function htmlEncode(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function htmlDecode(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&#x27;/g, "'")
.replace(/&nbsp;|&#160;/g, ' ')
}
function jsonEncode(s: string): string {
return JSON.stringify(s).slice(1, -1)
}
function jsonDecode(s: string): string {
// 粘贴的原文可能含真实换行等控制字符,直接拼进 JSON 字符串会解析失败;
// 仅转义控制字符(已转义的反斜杠序列不受影响,其中的控制字符不是裸的)
const escaped = s.replace(/[\u0000-\u001f]/g, c => {
const map: Record<string, string> = { '\n': '\\n', '\r': '\\r', '\t': '\\t', '\b': '\\b', '\f': '\\f' }
return map[c] ?? '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')
})
return JSON.parse('"' + escaped + '"')
}
const output = computed(() => {
const v = input.value
if (!v) return { text: '', error: '' }
try {
if (type.value === 'html') {
return { text: mode.value === 'encode' ? htmlEncode(v) : htmlDecode(v), error: '' }
}
return { text: mode.value === 'encode' ? jsonEncode(v) : jsonDecode(v), error: '' }
} catch (e) {
return { text: '', error: '转换失败:' + String(e) }
}
})
const swap = () => {
if (output.value.text) {
input.value = output.value.text
mode.value = mode.value === 'encode' ? 'decode' : 'encode'
}
}
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between flex-wrap gap-3">
<div class="flex items-center gap-3 flex-wrap">
<Segmented
v-model="type"
label="类型"
:options="[
{ value: 'html', label: 'HTML' },
{ value: 'json', label: 'JSON 字符串' }
]"
/>
<Segmented
v-model="mode"
label="操作"
:options="[
{ value: 'encode', label: '转义' },
{ value: 'decode', label: '反转义' }
]"
/>
</div>
<Button size="sm" variant="outline" class="h-8 text-sm gap-1" :disabled="!output.text" @click="swap">
<ArrowDownUp class="size-3.5" />
结果回填
</Button>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">原文 / 已转义文本</Label>
<Textarea v-model="input" placeholder="在此输入..." class="min-h-[120px] font-mono text-xs resize-y" />
</div>
<ResultArea :text="output.text" placeholder="结果" />
<p v-if="output.error" class="text-xs text-destructive">{{ output.error }}</p>
</div>
</template>
+249
View File
@@ -0,0 +1,249 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Upload } from '@lucide/vue'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import Segmented from '../components/Segmented.vue'
import ResultArea from '../components/ResultArea.vue'
type HashAlgo = 'MD5' | 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'
type Source = 'text' | 'file'
const algos: { value: HashAlgo; label: string }[] = [
{ value: 'MD5', label: 'MD5' },
{ value: 'SHA-1', label: 'SHA-1' },
{ value: 'SHA-256', label: 'SHA-256' },
{ value: 'SHA-384', label: 'SHA-384' },
{ value: 'SHA-512', label: 'SHA-512' }
]
const algo = ref<HashAlgo>('SHA-256')
const source = ref<Source>('text')
const uppercase = ref(false)
const useHmac = ref(false)
const hmacKey = ref('')
const input = ref('')
const output = ref('')
const loading = ref(false)
const error = ref('')
// ===== 文件 =====
const file = ref<File | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const fileError = ref('')
const FILE_SIZE_LIMIT = 512 * 1024 * 1024 // 512MB 保护上限
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
async function onFileChange(e: Event) {
const el = e.target as HTMLInputElement
fileError.value = ''
file.value = el.files?.[0] ?? null
await compute()
}
function clearFile() {
file.value = null
if (fileInput.value) fileInput.value.value = ''
void compute()
}
// ===== MD5WebCrypto 不支持,自行实现)=====
function md5(input: Uint8Array): string {
const S = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21
]
const K = new Uint32Array(64)
for (let i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296)
const len = input.length
const bitLenLo = (len * 8) >>> 0
const bitLenHi = Math.floor(len / 536870912) // len*8 / 2^32
const paddedLen = (((len + 8) >> 6) + 1) * 64
const msg = new Uint8Array(paddedLen)
msg.set(input)
msg[len] = 0x80
const dv = new DataView(msg.buffer)
dv.setUint32(paddedLen - 8, bitLenLo, true)
dv.setUint32(paddedLen - 4, bitLenHi, true)
let a0 = 0x67452301
let b0 = 0xefcdab89
let c0 = 0x98badcfe
let d0 = 0x10325476
const M = new Uint32Array(16)
for (let off = 0; off < paddedLen; off += 64) {
for (let i = 0; i < 16; i++) M[i] = dv.getUint32(off + i * 4, true)
let A = a0, B = b0, C = c0, D = d0
for (let i = 0; i < 64; i++) {
let F: number
let g: number
if (i < 16) { F = (B & C) | (~B & D); g = i }
else if (i < 32) { F = (D & B) | (~D & C); g = (5 * i + 1) % 16 }
else if (i < 48) { F = B ^ C ^ D; g = (3 * i + 5) % 16 }
else { F = C ^ (B | ~D); g = (7 * i) % 16 }
F = (F + A + K[i] + M[g]) >>> 0
A = D
D = C
C = B
B = (B + ((F << S[i]) | (F >>> (32 - S[i])))) >>> 0
}
a0 = (a0 + A) >>> 0
b0 = (b0 + B) >>> 0
c0 = (c0 + C) >>> 0
d0 = (d0 + D) >>> 0
}
const out = new Uint8Array(16)
const odv = new DataView(out.buffer)
odv.setUint32(0, a0, true)
odv.setUint32(4, b0, true)
odv.setUint32(8, c0, true)
odv.setUint32(12, d0, true)
return Array.from(out, b => b.toString(16).padStart(2, '0')).join('')
}
function toHex(buf: ArrayBuffer): string {
return Array.from(new Uint8Array(buf), b => b.toString(16).padStart(2, '0')).join('')
}
async function compute() {
error.value = ''
fileError.value = ''
output.value = ''
if (source.value === 'text') {
const text = input.value
if (!text) return
loading.value = true
try {
const data = new TextEncoder().encode(text)
output.value = await digest(algo.value, data)
} catch (e) {
error.value = '计算失败:' + String(e)
} finally {
loading.value = false
}
} else {
const f = file.value
if (!f) return
if (f.size > FILE_SIZE_LIMIT) {
fileError.value = `文件过大(${formatSize(f.size)}),请使用 512MB 以内的文件`
return
}
loading.value = true
try {
const data = new Uint8Array(await f.arrayBuffer())
output.value = await digest(algo.value, data)
} catch (e) {
error.value = '计算失败:' + String(e)
} finally {
loading.value = false
}
}
}
async function digest(algorithm: HashAlgo, data: Uint8Array): Promise<string> {
let hex: string
if (algorithm === 'MD5') {
hex = md5(data)
} else {
hex = toHex(await crypto.subtle.digest(algorithm, data))
}
if (useHmac.value) {
if (algorithm === 'MD5') {
throw new Error('HMAC 不支持 MD5,请选择 SHA 系列算法')
}
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw', enc.encode(hmacKey.value),
{ name: 'HMAC', hash: { name: algorithm } },
false, ['sign']
)
hex = toHex(await crypto.subtle.sign('HMAC', key, data))
}
return hex
}
const displayOutput = computed(() =>
uppercase.value ? output.value.toUpperCase() : output.value
)
// 输入、算法、选项、文件变化时自动重算
watch([input, algo, source, uppercase, useHmac, hmacKey], () => {
void compute()
})
// 首次挂载如有初始值则计算(通常为空)
void compute()
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3 flex-wrap">
<Segmented
v-model="source"
:options="[
{ value: 'text', label: '文本' },
{ value: 'file', label: '文件' }
]"
/>
<Segmented v-model="algo" label="算法" :options="algos" />
<label class="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
大写
<Switch v-model="uppercase" />
</label>
</div>
<div v-if="algo === 'MD5'" class="text-xs text-muted-foreground">
MD5 SHA-1 已不推荐用于安全场景仅用于兼容旧系统或校验比对
</div>
<!-- HMAC -->
<div class="flex flex-col gap-2 rounded-md border border-border p-3">
<label class="flex items-center gap-2 text-xs cursor-pointer w-fit">
<Switch v-model="useHmac" />
<span class="font-medium">HMAC密钥签名</span>
</label>
<div v-if="useHmac" class="flex flex-col gap-1.5">
<Label class="text-xs">密钥</Label>
<Input v-model="hmacKey" placeholder="HMAC 密钥(仅支持 SHA 系列)" class="font-mono text-sm" />
</div>
</div>
<!-- 文本输入 -->
<div v-if="source === 'text'" class="flex flex-col gap-1.5">
<Label class="text-xs">原文文本</Label>
<Textarea v-model="input" placeholder="在此输入文本..." class="min-h-[120px] font-mono text-xs resize-y" />
</div>
<!-- 文件输入 -->
<div v-else class="flex flex-col gap-2">
<Label class="text-xs">选择文件</Label>
<div class="flex items-center gap-2 flex-wrap">
<Button size="sm" variant="outline" class="h-8 text-sm gap-1.5" @click="fileInput?.click()">
<Upload class="size-3.5" />
选择文件
</Button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<template v-if="file">
<span class="text-xs font-mono text-muted-foreground">{{ file.name }}{{ formatSize(file.size) }}</span>
<Button size="sm" variant="ghost" class="h-7 text-xs" @click="clearFile">移除</Button>
</template>
</div>
<p v-if="fileError" class="text-xs text-destructive">{{ fileError }}</p>
</div>
<ResultArea :text="loading ? '计算中...' : displayOutput" :label="useHmac ? 'HMAC 摘要(十六进制)' : '摘要(十六进制)'" placeholder="摘要" />
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
</div>
</template>
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import Segmented from '../components/Segmented.vue'
type Tab = 'status' | 'mime'
const tab = ref<Tab>('status')
const query = ref('')
interface StatusDef {
code: number
name: string
desc: string
}
const STATUSES: StatusDef[] = [
// 1xx
{ code: 100, name: 'Continue', desc: '客户端应继续请求,常用于 Expect: 100-continue 大文件上传前探测' },
{ code: 101, name: 'Switching Protocols', desc: '服务器同意切换协议(如升级 WebSocket' },
{ code: 102, name: 'Processing', desc: '服务器已收到请求,仍在处理(WebDAV)' },
{ code: 103, name: 'Early Hints', desc: '预加载提示,主响应前先返回 Link 头' },
// 2xx
{ code: 200, name: 'OK', desc: '请求成功' },
{ code: 201, name: 'Created', desc: '请求成功并创建了新资源(POST 之后常见)' },
{ code: 202, name: 'Accepted', desc: '请求已受理,但尚未处理完成(异步任务)' },
{ code: 204, name: 'No Content', desc: '成功但无返回体(DELETE / PUT 常见)' },
{ code: 206, name: 'Partial Content', desc: '范围请求成功(断点续传 / 视频拖动)' },
// 3xx
{ code: 301, name: 'Moved Permanently', desc: '永久重定向,搜索引擎更新链接(GET 保持、POST 可能转 GET' },
{ code: 302, name: 'Found', desc: '临时重定向,浏览器可能将 POST 改为 GET' },
{ code: 303, name: 'See Other', desc: '临时重定向,强制使用 GET 访问新地址' },
{ code: 304, name: 'Not Modified', desc: '缓存有效(协商缓存命中,无返回体)' },
{ code: 307, name: 'Temporary Redirect', desc: '临时重定向,严格保持原请求方法与体' },
{ code: 308, name: 'Permanent Redirect', desc: '永久重定向,严格保持原请求方法与体' },
// 4xx
{ code: 400, name: 'Bad Request', desc: '请求语法错误 / 参数校验失败' },
{ code: 401, name: 'Unauthorized', desc: '未认证(缺少或无效的凭证,应带 WWW-Authenticate 头)' },
{ code: 402, name: 'Payment Required', desc: '要求付费(保留状态码,实际很少使用)' },
{ code: 403, name: 'Forbidden', desc: '已认证但无权限访问该资源' },
{ code: 404, name: 'Not Found', desc: '资源不存在' },
{ code: 405, name: 'Method Not Allowed', desc: '方法不被允许(应返回 Allow 头)' },
{ code: 406, name: 'Not Acceptable', desc: '请求的 Accept 头无法满足内容协商' },
{ code: 408, name: 'Request Timeout', desc: '客户端请求超时' },
{ code: 409, name: 'Conflict', desc: '请求与当前资源状态冲突(并发编辑 / 版本冲突)' },
{ code: 410, name: 'Gone', desc: '资源已永久消失(区别于 404' },
{ code: 412, name: 'Precondition Failed', desc: '前置条件失败(If-Match / If-None-Match 校验不过)' },
{ code: 413, name: 'Content Too Large', desc: '请求体超过服务器限制' },
{ code: 415, name: 'Unsupported Media Type', desc: 'Content-Type 不支持' },
{ code: 418, name: "I'm a teapot", desc: '愚人节彩蛋:我是茶壶' },
{ code: 422, name: 'Unprocessable Content', desc: '语义正确但校验失败(表单校验常用)' },
{ code: 425, name: 'Too Early', desc: '过早重放(防重放攻击)' },
{ code: 428, name: 'Precondition Required', desc: '要求带条件请求头(防丢失更新)' },
{ code: 429, name: 'Too Many Requests', desc: '请求频率超限(限流,应带 Retry-After 头)' },
{ code: 431, name: 'Request Header Fields Too Large', desc: '请求头过大(Cookie 太多常见)' },
{ code: 451, name: 'Unavailable For Legal Reasons', desc: '因法律原因不可提供(审查)' },
// 5xx
{ code: 500, name: 'Internal Server Error', desc: '服务器内部错误(后端异常兜底)' },
{ code: 501, name: 'Not Implemented', desc: '服务器不支持该功能' },
{ code: 502, name: 'Bad Gateway', desc: '网关收到上游无效响应(后端挂了 / 崩溃)' },
{ code: 503, name: 'Service Unavailable', desc: '服务不可用(过载 / 维护中,可带 Retry-After' },
{ code: 504, name: 'Gateway Timeout', desc: '网关等待上游超时(后端太慢)' },
{ code: 505, name: 'HTTP Version Not Supported', desc: 'HTTP 版本不支持' },
{ code: 507, name: 'Insufficient Storage', desc: '存储不足(WebDAV' },
{ code: 508, name: 'Loop Detected', desc: '检测到无限循环(WebDAV' },
{ code: 511, name: 'Network Authentication Required', desc: '需要网络认证(公共 Wi-Fi 门户)' }
]
interface MimeDef {
mime: string
ext: string
desc: string
}
const MIMES: MimeDef[] = [
{ mime: 'text/html', ext: '.html .htm', desc: 'HTML 文档' },
{ mime: 'text/plain', ext: '.txt', desc: '纯文本' },
{ mime: 'text/css', ext: '.css', desc: '样式表' },
{ mime: 'text/javascript', ext: '.js .mjs', desc: 'JavaScript(旧写法 application/javascript' },
{ mime: 'application/json', ext: '.json', desc: 'JSON 数据(API 最常用)' },
{ mime: 'application/xml', ext: '.xml', desc: 'XML 数据' },
{ mime: 'application/yaml', ext: '.yaml .yml', desc: 'YAML 配置' },
{ mime: 'application/toml', ext: '.toml', desc: 'TOML 配置' },
{ mime: 'text/csv', ext: '.csv', desc: '逗号分隔表格' },
{ mime: 'text/markdown', ext: '.md', desc: 'Markdown 文档' },
{ mime: 'image/jpeg', ext: '.jpg .jpeg', desc: 'JPEG 图片(有损压缩)' },
{ mime: 'image/png', ext: '.png', desc: 'PNG 图片(无损,支持透明)' },
{ mime: 'image/gif', ext: '.gif', desc: 'GIF 动图' },
{ mime: 'image/webp', ext: '.webp', desc: 'WebP 图片(现代格式,体积小)' },
{ mime: 'image/svg+xml', ext: '.svg', desc: 'SVG 矢量图' },
{ mime: 'image/avif', ext: '.avif', desc: 'AVIF 图片(新一代压缩)' },
{ mime: 'image/x-icon', ext: '.ico', desc: '网站图标' },
{ mime: 'audio/mpeg', ext: '.mp3', desc: 'MP3 音频' },
{ mime: 'audio/ogg', ext: '.ogg', desc: 'OGG 音频' },
{ mime: 'audio/wav', ext: '.wav', desc: 'WAV 无损音频' },
{ mime: 'video/mp4', ext: '.mp4', desc: 'MP4 视频' },
{ mime: 'video/webm', ext: '.webm', desc: 'WebM 视频' },
{ mime: 'video/x-matroska', ext: '.mkv', desc: 'MKV 视频' },
{ mime: 'application/pdf', ext: '.pdf', desc: 'PDF 文档' },
{ mime: 'application/zip', ext: '.zip', desc: 'ZIP 压缩包' },
{ mime: 'application/x-7z-compressed', ext: '.7z', desc: '7z 压缩包' },
{ mime: 'application/x-rar-compressed', ext: '.rar', desc: 'RAR 压缩包' },
{ mime: 'application/gzip', ext: '.gz', desc: 'GZip 压缩' },
{ mime: 'application/x-tar', ext: '.tar', desc: 'TAR 归档' },
{ mime: 'application/octet-stream', ext: '(默认)', desc: '未知二进制(浏览器会下载)' },
{ mime: 'application/wasm', ext: '.wasm', desc: 'WebAssembly 模块' },
{ mime: 'font/woff', ext: '.woff', desc: 'Web 字体(压缩)' },
{ mime: 'font/woff2', ext: '.woff2', desc: 'Web 字体(现代格式)' },
{ mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ext: '.docx', desc: 'Word 文档' },
{ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ext: '.xlsx', desc: 'Excel 表格' },
{ mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ext: '.pptx', desc: 'PPT 演示文稿' },
{ mime: 'application/msword', ext: '.doc', desc: 'Word 旧格式' },
{ mime: 'application/vnd.ms-excel', ext: '.xls', desc: 'Excel 旧格式' },
{ mime: 'multipart/form-data', ext: '(表单)', desc: '文件上传表单(带 boundary' },
{ mime: 'application/x-www-form-urlencoded', ext: '(表单)', desc: 'URL 编码表单(默认)' },
{ mime: 'application/grpc', ext: 'RPC', desc: 'gRPC 请求(配合 proto' }
]
const STATUS_CLASS: Record<string, string> = {
'1xx': 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30',
'2xx': 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
'3xx': 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
'4xx': 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30',
'5xx': 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30'
}
const statusGroups = computed(() => {
const q = query.value.trim().toLowerCase()
const filtered = q
? STATUSES.filter(s => String(s.code).includes(q) || s.name.toLowerCase().includes(q) || s.desc.toLowerCase().includes(q))
: STATUSES
const groups: Array<{ label: string; class: string; items: StatusDef[] }> = []
for (const [label, cls] of Object.entries(STATUS_CLASS)) {
const items = filtered.filter(s => String(s.code).startsWith(label[0]))
if (items.length > 0) groups.push({ label: `${label} ${label === '1xx' ? '信息' : label === '2xx' ? '成功' : label === '3xx' ? '重定向' : label === '4xx' ? '客户端错误' : '服务器错误'}`, class: cls, items })
}
return groups
})
const filteredMimes = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return MIMES
return MIMES.filter(m => m.mime.includes(q) || m.ext.includes(q) || m.desc.toLowerCase().includes(q))
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3 flex-wrap">
<Segmented
v-model="tab"
:options="[
{ value: 'status', label: 'HTTP 状态码' },
{ value: 'mime', label: 'MIME 类型' }
]"
/>
<div class="relative flex-1 min-w-[160px] max-w-xs">
<Input v-model="query" placeholder="搜索…" class="h-8 text-sm" />
</div>
<span class="text-xs text-muted-foreground ml-auto">
{{ tab === 'status' ? `${STATUSES.length} 个状态码` : `${MIMES.length} 个常用类型` }}
</span>
</div>
<!-- 状态码 -->
<template v-if="tab === 'status'">
<div v-for="group in statusGroups" :key="group.label" class="flex flex-col gap-2">
<span class="text-xs font-medium text-muted-foreground">{{ group.label }}</span>
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div v-for="s in group.items" :key="s.code" class="flex items-start gap-3 px-3 py-2">
<Badge variant="outline" :class="['py-0 shrink-0 font-mono', group.class]">{{ s.code }}</Badge>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="font-medium">{{ s.name }}</span>
<span class="text-muted-foreground leading-snug">{{ s.desc }}</span>
</div>
</div>
</div>
</div>
<p v-if="statusGroups.length === 0" class="text-xs text-muted-foreground">没有匹配的状态码</p>
</template>
<!-- MIME -->
<template v-else>
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div v-for="m in filteredMimes" :key="m.mime" class="flex items-start gap-3 px-3 py-2">
<span class="font-mono text-primary break-all w-64 shrink-0">{{ m.mime }}</span>
<span class="font-mono text-muted-foreground w-20 shrink-0">{{ m.ext }}</span>
<span class="text-muted-foreground min-w-0">{{ m.desc }}</span>
</div>
<div v-if="filteredMimes.length === 0" class="px-3 py-2 text-muted-foreground">没有匹配的 MIME 类型</div>
</div>
</template>
</div>
</template>
+189
View File
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Segmented from '../components/Segmented.vue'
import ResultArea from '../components/ResultArea.vue'
type Tab = 'cidr' | 'convert'
const tab = ref<Tab>('cidr')
// ===== CIDR 计算 =====
const cidrInput = ref('192.168.1.0/24')
function parseIPv4(s: string): bigint | null {
const parts = s.trim().split('.')
if (parts.length !== 4) return null
let n = 0n
for (const p of parts) {
if (!/^\d{1,3}$/.test(p)) return null
const v = Number(p)
if (v > 255) return null
n = (n << 8n) | BigInt(v)
}
return n
}
function ipToString(n: bigint): string {
return [24n, 16n, 8n, 0n].map(shift => String((n >> shift) & 0xffn)).join('.')
}
const cidr = computed(() => {
const s = cidrInput.value.trim()
if (!s) return null
const [ipPart, maskPart] = s.split('/')
const ip = parseIPv4(ipPart)
if (ip === null) return { error: 'IPv4 地址格式不正确' }
let prefix: number
if (maskPart === undefined) {
prefix = 24
} else {
prefix = Number(maskPart)
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) {
return { error: '前缀长度应在 0-32 之间' }
}
}
const mask = prefix === 0 ? 0n : (0xffffffffn << BigInt(32 - prefix)) & 0xffffffffn
const network = ip & mask
const broadcast = network | (~mask & 0xffffffffn)
const total = 1n << BigInt(32 - prefix)
// 主机数:/31 无主机位(点对点),/32 单主机
const hosts = prefix >= 31 ? total : total - 2n
const firstHost = prefix >= 31 ? network : network + 1n
const lastHost = prefix >= 31 ? broadcast : broadcast - 1n
const wildcard = ~mask & 0xffffffffn
// 私有地址判断
const privateNote =
(ip >> 24n) === 10n ? 'A 类私有(10.0.0.0/8'
: (ip >> 20n) === 0xac1n ? 'B 类私有(172.16.0.0/12'
: (ip >> 16n) === 0xc0a8n ? 'C 类私有(192.168.0.0/16'
: (ip >> 28n) === 14n ? '环回/保留(240.0.0.0/4'
: ip === 0n ? '未指定地址'
: (ip >> 24n) === 127n ? '环回地址(127.0.0.0/8'
: (ip >> 24n) === 169n && (ip >> 16n) === 0xa9fen ? '链路本地(169.254.0.0/16'
: '公网地址'
return {
error: '',
network: ipToString(network),
broadcast: ipToString(broadcast),
mask: ipToString(mask),
wildcard: ipToString(wildcard),
firstHost: ipToString(firstHost),
lastHost: ipToString(lastHost),
hosts: hosts.toLocaleString(),
total: total.toLocaleString(),
prefix,
isAligned: (ip & mask) === network,
ipIsNetwork: ip === network,
privateNote,
binaryMask: ipToString(mask).split('.').map(p => Number(p).toString(2).padStart(8, '0')).join('.')
}
})
// ===== IP ↔ 整数互转 =====
const convertInput = ref('192.168.1.1')
const converted = computed(() => {
const s = convertInput.value.trim()
if (!s) return null
const ip = parseIPv4(s)
if (ip !== null) {
return {
dec: ip.toString(),
hex: '0x' + ip.toString(16).padStart(8, '0').toUpperCase(),
oct: ip.toString(8),
bin: ip.toString(2).padStart(32, '0').replace(/(.{8})(?=.)/g, '$1 '),
binaryMask: ''
}
}
// 尝试十进制整数
if (/^\d+$/.test(s)) {
const n = BigInt(s)
if (n <= 0xffffffffn) {
return { dec: s, hex: '0x' + n.toString(16).padStart(8, '0').toUpperCase(), oct: n.toString(8), bin: n.toString(2).padStart(32, '0').replace(/(.{8})(?=.)/g, '$1 '), binaryMask: '' }
}
}
return null
})
</script>
<template>
<div class="flex flex-col gap-4">
<Segmented
v-model="tab"
:options="[
{ value: 'cidr', label: 'CIDR 子网计算' },
{ value: 'convert', label: 'IP ↔ 整数' }
]"
/>
<!-- CIDR -->
<template v-if="tab === 'cidr'">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">IPv4 地址 / 前缀 192.168.1.100/26</Label>
<Input v-model="cidrInput" placeholder="192.168.1.0/24" class="font-mono text-sm" />
</div>
<template v-if="cidr">
<p v-if="cidr.error" class="text-xs text-destructive">{{ cidr.error }}</p>
<template v-else>
<p v-if="!cidr.ipIsNetwork" class="text-xs text-amber-600 dark:text-amber-400">
注意输入地址不是该子网的网络地址已按网络 {{ cidr.network }}/{{ cidr.prefix }} 计算
</p>
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div class="flex gap-3 px-3 py-1.5">
<span class="w-24 shrink-0 text-muted-foreground">网络地址</span>
<span class="font-mono">{{ cidr.network }}/{{ cidr.prefix }}</span>
</div>
<div class="flex gap-3 px-3 py-1.5">
<span class="w-24 shrink-0 text-muted-foreground">子网掩码</span>
<span class="font-mono">{{ cidr.mask }}</span>
</div>
<div class="flex gap-3 px-3 py-1.5">
<span class="w-24 shrink-0 text-muted-foreground">掩码二进制</span>
<span class="font-mono break-all">{{ cidr.binaryMask }}</span>
</div>
<div class="flex gap-3 px-3 py-1.5">
<span class="w-24 shrink-0 text-muted-foreground">反掩码</span>
<span class="font-mono">{{ cidr.wildcard }}</span>
</div>
<div class="flex gap-3 px-3 py-1.5">
<span class="w-24 shrink-0 text-muted-foreground">广播地址</span>
<span class="font-mono">{{ cidr.broadcast }}</span>
</div>
<div class="flex gap-3 px-3 py-1.5">
<span class="w-24 shrink-0 text-muted-foreground">可用主机范围</span>
<span class="font-mono">{{ cidr.firstHost }} ~ {{ cidr.lastHost }}</span>
</div>
<div class="flex gap-3 px-3 py-1.5">
<span class="w-24 shrink-0 text-muted-foreground">可用主机数</span>
<span class="font-mono">{{ cidr.hosts }}地址总数 {{ cidr.total }}</span>
</div>
<div class="flex gap-3 px-3 py-1.5">
<span class="w-24 shrink-0 text-muted-foreground">地址类型</span>
<span>{{ cidr.privateNote }}</span>
</div>
</div>
</template>
</template>
<p v-else class="text-xs text-muted-foreground">输入 IPv4 地址与 CIDR 前缀自动计算子网信息</p>
</template>
<!-- IP 整数 -->
<template v-else>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">IP 地址或无符号整数</Label>
<Input v-model="convertInput" placeholder="192.168.1.1 或 3232235777" class="font-mono text-sm" />
</div>
<template v-if="converted">
<ResultArea :text="converted.dec" label="十进制整数" placeholder="十进制" minHeight="60px" />
<ResultArea :text="converted.hex" label="十六进制" placeholder="十六进制" minHeight="60px" />
<ResultArea :text="converted.oct" label="八进制" placeholder="八进制" minHeight="60px" />
<ResultArea :text="converted.bin" label="二进制" placeholder="二进制" minHeight="60px" />
</template>
<p v-else-if="convertInput" class="text-xs text-destructive">无法识别的输入支持 IPv4 地址或 0-4294967295 整数</p>
</template>
</div>
</template>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import Segmented from '../components/Segmented.vue'
import ResultArea from '../components/ResultArea.vue'
const mode = ref<'format' | 'minify'>('format')
const indent = ref('2')
const sortKeys = ref(false)
const input = ref('')
const result = computed<{ text: string; error: string }>(() => {
const v = input.value.trim()
if (!v) return { text: '', error: '' }
try {
const parsed = JSON.parse(v)
if (mode.value === 'minify') {
return { text: JSON.stringify(parsed), error: '' }
}
return { text: JSON.stringify(parsed, normalizeReplacer(sortKeys.value), Number(indent.value)), error: '' }
} catch (e) {
return { text: '', error: 'JSON 解析失败:' + String(e) }
}
})
const output = computed(() => result.value.text)
const error = computed(() => result.value.error)
const isValid = computed<boolean | null>(() => {
if (!input.value.trim()) return null
try {
JSON.parse(input.value)
return true
} catch {
return false
}
})
/** 深浅不一:sortKeys 时按 key 排序输出 */
function normalizeReplacer(sort: boolean): (this: unknown, key: string, value: unknown) => unknown {
if (!sort) return undefined as never
return function (this: unknown, _key: string, value: unknown) {
if (Array.isArray(value)) return value
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>
const sorted: Record<string, unknown> = {}
Object.keys(obj)
.sort()
.forEach(k => { sorted[k] = obj[k] })
return sorted
}
return value
}
}
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between flex-wrap gap-3">
<div class="flex items-center gap-3 flex-wrap">
<Segmented
v-model="mode"
:options="[
{ value: 'format', label: '美化' },
{ value: 'minify', label: '压缩' }
]"
/>
<Segmented
v-model="indent"
label="缩进"
:options="[
{ value: '2', label: '2' },
{ value: '4', label: '4' },
{ value: '8', label: '8' }
]"
/>
<label class="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input v-model="sortKeys" type="checkbox" class="accent-primary size-3.5" />
键排序
</label>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">原始 JSON</Label>
<Textarea v-model="input" placeholder='输入 JSON,如 {"a":1,"b":[true,null]}' class="min-h-[160px] font-mono text-xs resize-y" />
</div>
<div v-if="isValid !== false" class="flex items-center gap-2 text-xs">
<span v-if="isValid === true" class="text-green-600 dark:text-green-400"> 合法 JSON</span>
</div>
<ResultArea :text="output" placeholder="结果" />
<p v-if="error" class="text-xs text-destructive break-all">{{ error }}</p>
</div>
</template>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import ResultArea from '../components/ResultArea.vue'
const token = ref('')
const error = ref('')
function base64UrlDecode(s: string): string {
const cleaned = s.replace(/-/g, '+').replace(/_/g, '/')
const pad = cleaned.length % 4
const normalized = pad ? cleaned + '='.repeat(4 - pad) : cleaned
const bin = atob(normalized)
const bytes = Uint8Array.from(bin, ch => ch.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
interface TimeClaim {
name: string
value: string
local: string
status: 'ok' | 'expired' | 'not-yet' | 'unknown'
}
const parsed = computed<{
header: string
payload: string
signature: string
timeClaims: TimeClaim[]
} | null>(() => {
error.value = ''
const t = token.value.trim()
if (!t) return null
const parts = t.split('.')
if (parts.length < 2) {
error.value = 'JWT 格式不正确(应为 header.payload.signature'
return null
}
try {
const header = formatJson(base64UrlDecode(parts[0]))
const payload = formatJson(base64UrlDecode(parts[1]))
const signature = parts[2] ?? ''
return { header, payload, signature, timeClaims: parseTimeClaims(payload) }
} catch (e) {
error.value = '解码失败:' + String(e)
return null
}
})
function formatJson(s: string): string {
let pretty = s
try {
pretty = JSON.stringify(JSON.parse(s), null, 2)
} catch {
/* 非 JSON(如已损毁),原样展示 */
}
return pretty
}
/** 解读 iat / nbf / exp 等时间声明(秒级时间戳) */
function parseTimeClaims(payload: string): TimeClaim[] {
let obj: unknown
try {
obj = JSON.parse(payload)
} catch {
return []
}
if (!obj || typeof obj !== 'object') return []
const p = obj as Record<string, unknown>
const claims: TimeClaim[] = []
const now = Math.floor(Date.now() / 1000)
const NAMES: Record<string, string> = { iat: 'iat(签发时间)', nbf: 'nbf(生效时间)', exp: 'exp(过期时间)' }
const pad = (x: number) => String(x).padStart(2, '0')
for (const key of ['iat', 'nbf', 'exp']) {
const v = p[key]
if (typeof v !== 'number' || !Number.isFinite(v)) continue
const d = new Date(v * 1000)
if (Number.isNaN(d.getTime())) continue
const local = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
let status: TimeClaim['status'] = 'ok'
if (key === 'exp' && v < now) status = 'expired'
else if (key === 'nbf' && v > now) status = 'not-yet'
else if (key === 'iat' && v > now + 60) status = 'unknown'
claims.push({ name: NAMES[key], value: String(v), local, status })
}
return claims
}
const STATUS_BADGE: Record<TimeClaim['status'], { label: string; class: string } | null> = {
ok: null,
expired: { label: '已过期', class: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30' },
'not-yet': { label: '尚未生效', class: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30' },
unknown: { label: '签发时间在未来(时钟偏差?)', class: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30' }
}
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">JWT Token</Label>
<Textarea
v-model="token"
placeholder="eyJhbGciOi...header.payload.signature"
class="min-h-[70px] font-mono text-xs resize-y break-all"
/>
</div>
<template v-if="parsed">
<ResultArea :text="parsed.header" label="Header" placeholder="Header" />
<ResultArea :text="parsed.payload" label="Payload(载荷)" placeholder="Payload" />
<ResultArea :text="parsed.signature" label="Signature(签名)" placeholder="Signature" />
<div v-if="parsed.timeClaims.length > 0" class="flex flex-col gap-1.5">
<Label class="text-xs">时间声明解读</Label>
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div v-for="c in parsed.timeClaims" :key="c.name" class="flex items-center gap-3 px-3 py-2 flex-wrap">
<span class="font-mono text-muted-foreground">{{ c.name }}</span>
<span class="font-mono">{{ c.local }}</span>
<Badge v-if="STATUS_BADGE[c.status]" variant="outline" :class="['py-0 text-[10px] ml-auto', STATUS_BADGE[c.status]!.class]">
{{ STATUS_BADGE[c.status]!.label }}
</Badge>
</div>
</div>
</div>
</template>
<p v-else-if="!token" class="text-xs text-muted-foreground">在左侧粘贴 JWT下方将自动解析 Header Payload</p>
<p v-else class="text-xs text-destructive">{{ error }}</p>
</div>
</template>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Copy, RefreshCw } from '@lucide/vue'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
const CHARSETS = {
lower: 'abcdefghijklmnopqrstuvwxyz',
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
digits: '0123456789',
symbols: '!@#$%^&*_-+=?',
// 易混淆字符:l/1/I、O/0、等等
ambiguous: 'Il1O0o`\'"|'
}
const length = ref('16')
const count = ref('5')
const useLower = ref(true)
const useUpper = ref(true)
const useDigits = ref(true)
const useSymbols = ref(false)
const excludeAmbiguous = ref(true)
const passwords = ref<string[]>([])
const error = ref('')
const lengthNum = computed(() => {
const n = Math.floor(Number(length.value))
if (!Number.isFinite(n)) return 16
return Math.min(Math.max(n || 0, 4), 128)
})
const countNum = computed(() => {
const n = Math.floor(Number(count.value))
if (!Number.isFinite(n)) return 5
return Math.min(Math.max(n || 0, 1), 100)
})
const charset = computed(() => {
let s = ''
if (useLower.value) s += CHARSETS.lower
if (useUpper.value) s += CHARSETS.upper
if (useDigits.value) s += CHARSETS.digits
if (useSymbols.value) s += CHARSETS.symbols
if (excludeAmbiguous.value) {
for (const c of CHARSETS.ambiguous) s = s.split(c).join('')
}
return s
})
/** 用 crypto.getRandomValues 生成无偏随机整数 [0, max) */
function randomInt(max: number): number {
// 拒绝采样消除模偏差
const limit = Math.floor(0x100000000 / max) * max
const buf = new Uint32Array(1)
let v: number
do {
crypto.getRandomValues(buf)
v = buf[0]
} while (v >= limit)
return v % max
}
/** 熵估算:log2(charsetSize^length) = length * log2(size) */
const entropy = computed(() => {
const size = charset.value.length
if (size === 0) return 0
return Math.round(lengthNum.value * Math.log2(size))
})
const strength = computed(() => {
const e = entropy.value
if (e >= 128) return { label: '极强(128+ bit', class: 'text-green-600 dark:text-green-400' }
if (e >= 80) return { label: '强(80-127 bit', class: 'text-green-600 dark:text-green-400' }
if (e >= 60) return { label: '中等(60-79 bit', class: 'text-amber-600 dark:text-amber-400' }
return { label: '弱(<60 bit', class: 'text-red-600 dark:text-red-400' }
})
function generate() {
error.value = ''
if (charset.value.length === 0) {
error.value = '请至少选择一种字符集'
passwords.value = []
return
}
const list: string[] = []
for (let i = 0; i < countNum.value; i++) {
let pw = ''
for (let j = 0; j < lengthNum.value; j++) {
pw += charset.value[randomInt(charset.value.length)]
}
list.push(pw)
}
passwords.value = list
}
const allText = computed(() => passwords.value.join('\n'))
function copyAll() {
void navigator.clipboard.writeText(allText.value)
}
onMounted(generate)
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-6 flex-wrap">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">长度4-128</Label>
<Input v-model="length" type="number" min="4" max="128" class="w-24 font-mono text-sm" />
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">数量1-100</Label>
<Input v-model="count" type="number" min="1" max="100" class="w-24 font-mono text-sm" />
</div>
<Button size="sm" class="gap-1 self-end" @click="generate">
<RefreshCw class="size-3.5" />
重新生成
</Button>
</div>
<div class="flex items-center gap-4 flex-wrap">
<label class="flex items-center gap-2 text-xs cursor-pointer">
<Switch v-model="useLower" /> 小写 a-z
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<Switch v-model="useUpper" /> 大写 A-Z
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<Switch v-model="useDigits" /> 数字 0-9
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<Switch v-model="useSymbols" /> 符号 !@#$%
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<Switch v-model="excludeAmbiguous" /> 排除易混淆字符
</label>
</div>
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
<template v-if="passwords.length">
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<Label class="text-xs">生成结果</Label>
<span class="text-xs" :class="strength.class">熵约 {{ entropy }} bit · {{ strength.label }}</span>
</div>
<Textarea
readonly
:model-value="allText"
class="min-h-[120px] font-mono text-xs resize-y"
/>
</div>
<Button size="sm" variant="outline" class="gap-1 w-fit" @click="copyAll">
<Copy class="size-3.5" />
复制全部{{ passwords.length }}
</Button>
</template>
</div>
</template>
+192
View File
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import ResultArea from '../components/ResultArea.vue'
const pattern = ref('')
const testText = ref('')
const flags = ref({ g: true, i: false, m: false, s: false, u: false })
interface MatchInfo {
index: number
full: string
groups: string[]
groupsLabel: string
}
const raw = computed<{ matches: MatchInfo[]; error: string }>(() => {
if (!pattern.value) return { matches: [], error: '' }
// 防卡 UI:同步 exec 无超时保护,超长文本截断测试(灾难性回溯正则仍可能慢,限制输入规模是主要防线)
const MAX_TEXT = 200_000
const text = testText.value.length > MAX_TEXT ? testText.value.slice(0, MAX_TEXT) : testText.value
const truncated = testText.value.length > MAX_TEXT
let fl = ''
const f = flags.value
if (f.g) fl += 'g'
if (f.i) fl += 'i'
if (f.m) fl += 'm'
if (f.s) fl += 's'
if (f.u) fl += 'u'
try {
const re = new RegExp(pattern.value, fl)
const list: MatchInfo[] = []
let m: RegExpExecArray | null
let guard = 0
while ((m = re.exec(text)) !== null) {
const groups = m.slice(1)
list.push({
index: m.index,
full: m[0],
groups,
groupsLabel: groups && groups.length > 0
? groups.map((g, i) => `$${i + 1}=${g === undefined ? '∅' : g}`).join(', ')
: ''
})
if (!f.g) break
if (list.length >= 5000) break // 匹配数上限,防止大文本 + 极宽正则刷爆列表
if (m[0] === '') {
if (++guard > 100000) break
re.lastIndex++
}
}
if (list.length >= 5000) {
const extra = truncated ? `(文本超过 ${MAX_TEXT} 字符,仅测试前 ${MAX_TEXT} 字符)` : ''
return { matches: list, error: `匹配数已达 5000 上限,已停止${extra}` }
}
return { matches: list, error: '' }
} catch (e) {
return { matches: [], error: '正则语法错误:' + String(e) }
}
})
const matches = computed(() => raw.value.matches)
const error = computed(() => raw.value.error)
const isValidPattern = computed(() => {
if (!pattern.value) return null
try {
new RegExp(pattern.value, flags.value.g ? 'g' : '')
return true
} catch {
return false
}
})
const matchesText = computed(() => {
if (matches.value.length === 0) return ''
return matches.value
.map((m, i) => `[${i}] @${m.index}: ${m.full}` + (m.groupsLabel ? ` (${m.groupsLabel})` : ''))
.join('\n')
})
const flagDefs = [
{ key: 'g' as const, label: 'g', title: '全局' },
{ key: 'i' as const, label: 'i', title: '忽略大小写' },
{ key: 'm' as const, label: 'm', title: '多行' },
{ key: 's' as const, label: 's', title: '点匹配换行' },
{ key: 'u' as const, label: 'u', title: 'Unicode' }
]
// ===== 替换预览 =====
const showReplace = ref(false)
const replacement = ref('')
const replaceError = computed(() => {
if (!showReplace.value || !pattern.value) return ''
try {
new RegExp(pattern.value, flags.value.g ? 'g' : '')
return ''
} catch (e) {
return '正则语法错误:' + String(e)
}
})
const replacedText = computed<{ text: string; count: number }>(() => {
if (!showReplace.value || !pattern.value || !testText.value) return { text: '', count: 0 }
const text = testText.value.length > 200_000 ? testText.value.slice(0, 200_000) : testText.value
try {
const fl = (flags.value.g ? 'g' : '') + (flags.value.i ? 'i' : '') + (flags.value.m ? 'm' : '') + (flags.value.s ? 's' : '') + (flags.value.u ? 'u' : '')
const re = new RegExp(pattern.value, fl)
const all = (text.match(new RegExp(pattern.value, fl.includes('g') ? fl : fl + 'g')) ?? []).length
return { text: text.replace(re, replacement.value), count: flags.value.g ? all : Math.min(all, 1) }
} catch {
return { text: '', count: 0 }
}
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">正则表达式</Label>
<div class="flex items-center gap-2">
<span class="text-muted-foreground font-mono text-sm">/</span>
<Input v-model="pattern" placeholder="如 \b\w+@\w+\.\w+\b" class="font-mono text-sm flex-1" :class="{ 'border-destructive': isValidPattern === false }" />
<span class="text-muted-foreground font-mono text-sm">/</span>
</div>
<div class="flex items-center gap-1 flex-wrap">
<button
v-for="fd in flagDefs"
:key="fd.key"
type="button"
class="h-6 px-2 rounded font-mono text-xs border transition-colors cursor-pointer"
:class="flags[fd.key] ? 'border-primary text-primary bg-primary/10' : 'border-border text-muted-foreground hover:text-foreground'"
:title="fd.title"
@click="flags[fd.key] = !flags[fd.key]"
>
{{ fd.label }}
</button>
<span v-if="matches.length" class="ml-auto text-xs text-muted-foreground">匹配 {{ matches.length }} </span>
<span v-else-if="pattern && testText && !error" class="ml-auto text-xs text-muted-foreground">无匹配</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">测试文本</Label>
<Textarea v-model="testText" placeholder="在此输入文本..." class="min-h-[120px] font-mono text-xs resize-y" />
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">匹配结果</Label>
<div class="rounded-md border border-border max-h-56 overflow-y-auto divide-y divide-border">
<div v-if="matches.length === 0 && !error" class="p-3 text-xs text-muted-foreground">输入正则与文本查看匹配</div>
<div v-for="(m, i) in matches" :key="i" class="flex items-start gap-2 p-2 text-xs font-mono">
<Badge variant="outline" class="shrink-0 py-0 px-1.5 text-[10px]">@{{ m.index }}</Badge>
<span class="break-all min-w-0">{{ m.full }}</span>
<span v-if="m.groupsLabel" class="text-muted-foreground break-all ml-auto pl-2">{{ m.groupsLabel }}</span>
</div>
</div>
</div>
<ResultArea :text="matchesText" label="匹配列表(可复制)" placeholder="匹配列表" />
<!-- 替换预览 -->
<div class="rounded-md border border-border">
<button
type="button"
class="w-full flex items-center justify-between px-3 py-2 text-xs font-medium cursor-pointer hover:bg-muted/40 transition-colors"
@click="showReplace = !showReplace"
>
<span>替换预览支持 $1$&lt;name&gt; 引用分组</span>
<span class="text-muted-foreground">{{ showReplace ? '收起' : '展开' }}</span>
</button>
<div v-if="showReplace" class="flex flex-col gap-3 p-3 border-t border-border">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">替换为</Label>
<Input v-model="replacement" placeholder="如 [$1](留空则删除匹配内容)" class="font-mono text-sm" />
</div>
<ResultArea
v-if="replacedText.text"
:text="replacedText.text"
:label="`替换结果(已替换 ${replacedText.count} 处)`"
placeholder="替换结果"
/>
<p v-else-if="replacement" class="text-xs text-muted-foreground">替换后无内容或无匹配</p>
<p v-if="replaceError" class="text-xs text-destructive">{{ replaceError }}</p>
</div>
</div>
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
</div>
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import ResultArea from '../components/ResultArea.vue'
const input = ref('')
const find = ref('')
const replacement = ref('')
const useRegex = ref(false)
const caseInsensitive = ref(false)
const multiline = ref(false)
const result = computed<{ text: string; count: number; error: string }>(() => {
const v = input.value
if (!v || !find.value) return { text: '', count: 0, error: '' }
try {
let re: RegExp
let replacementText: string
if (useRegex.value) {
const flags = 'g' + (caseInsensitive.value ? 'i' : '') + (multiline.value ? 'm' : '')
re = new RegExp(find.value, flags)
// 正则模式:原生支持 $1、$<name> 等引用
replacementText = replacement.value
} else {
// 纯文本模式:查找与替换都按字面处理,需转义正则元字符与替换串中的 $
const esc = find.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
re = new RegExp(esc, 'g' + (caseInsensitive.value ? 'i' : ''))
replacementText = replacement.value.replace(/\$/g, '$$$$')
}
const count = (v.match(re) ?? []).length
return { text: v.replace(re, replacementText), count, error: '' }
} catch (e) {
return { text: '', count: 0, error: '正则语法错误:' + String(e) }
}
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">原文</Label>
<Textarea v-model="input" placeholder="在此输入文本..." class="min-h-[140px] font-mono text-xs resize-y" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">查找</Label>
<Input v-model="find" :placeholder="useRegex ? '正则表达式' : '纯文本'" class="font-mono text-sm" />
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">替换为留空删除匹配</Label>
<Input v-model="replacement" :placeholder="useRegex ? '支持 $1、$<name>' : '纯文本'" class="font-mono text-sm" />
</div>
</div>
<div class="flex items-center gap-4 flex-wrap">
<label class="flex items-center gap-2 text-xs cursor-pointer">
<Switch v-model="useRegex" /> 正则模式
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<Switch v-model="caseInsensitive" /> 忽略大小写
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer" :class="{ 'opacity-50': !useRegex }">
<Switch v-model="multiline" :disabled="!useRegex" /> 多行模式^$ 匹配行首尾
</label>
</div>
<p v-if="result.error" class="text-xs text-destructive">{{ result.error }}</p>
<ResultArea
v-if="result.text"
:text="result.text"
:label="`替换结果(已替换 ${result.count} 处)`"
placeholder="替换结果"
/>
<p v-else-if="input && find" class="text-xs text-muted-foreground">无匹配或替换后为空</p>
<p v-else class="text-xs text-muted-foreground">输入原文与查找内容实时预览替换结果</p>
</div>
</template>
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { Clock } from '@lucide/vue'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import Segmented from '../components/Segmented.vue'
import ResultArea from '../components/ResultArea.vue'
type Unit = 'auto' | 's' | 'ms'
const unit = ref<Unit>('auto')
const numberInput = ref('')
const dateInput = ref('')
const toNumber = (v: string): number | null => {
const n = Number(v.trim())
return Number.isFinite(n) ? n : null
}
/** 自动识别:13 位(>1e11)视为毫秒,10 位(>1e8)视为秒,其他按数值范围推断 */
function resolveUnit(n: number): 's' | 'ms' {
if (unit.value !== 'auto') return unit.value
if (Math.abs(n) >= 1e11) return 'ms'
return 's'
}
const unixToDate = (n: number, u: 's' | 'ms'): Date =>
new Date(u === 's' ? n * 1000 : n)
const formatLocal = (d: Date): string => {
const pad = (x: number) => String(x).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
const formatUtc = (d: Date): string => {
const pad = (x: number) => String(x).padStart(2, '0')
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
}
const fromNumber = computed<{ local: string; utc: string; iso: string; ts: string; unitUsed: 's' | 'ms' } | null>(() => {
const n = toNumber(numberInput.value)
if (n === null) return null
const u = resolveUnit(n)
const d = unixToDate(n, u)
if (Number.isNaN(d.getTime())) return null
return {
local: formatLocal(d),
utc: formatUtc(d),
iso: d.toISOString(),
ts: String(n),
unitUsed: u
}
})
const fromDate = computed<string>(() => {
if (!dateInput.value) return ''
const d = new Date(dateInput.value)
if (Number.isNaN(d.getTime())) return ''
// 日期输入统一同时给出秒与毫秒
return `${Math.floor(d.getTime() / 1000)}(秒)\n${d.getTime()}(毫秒)`
})
// ===== 当前时间戳 =====
const nowTick = ref(0)
const timer = window.setInterval(() => (nowTick.value++), 1000)
onUnmounted(() => window.clearInterval(timer))
const now = computed(() => {
void nowTick.value
const d = new Date()
return {
s: String(Math.floor(d.getTime() / 1000)),
ms: String(d.getTime()),
local: formatLocal(d)
}
})
function copy(text: string) {
void navigator.clipboard.writeText(text)
}
</script>
<template>
<div class="flex flex-col gap-4">
<Segmented
v-model="unit"
label="单位"
:options="[
{ value: 'auto', label: '自动' },
{ value: 's', label: '秒' },
{ value: 'ms', label: '毫秒' }
]"
/>
<!-- 时间戳 时间 -->
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">Unix 时间戳 日期时间</Label>
<Input v-model="numberInput" placeholder="例如 1757419200 或 1757419200000" class="font-mono text-sm" />
</div>
<template v-if="fromNumber">
<ResultArea :text="fromNumber.local" label="本地时间" placeholder="本地时间" />
<ResultArea :text="fromNumber.utc" label="UTC 时间" placeholder="UTC 时间" />
<ResultArea :text="fromNumber.iso" label="ISO 8601" placeholder="ISO 8601" />
<p class="text-xs text-muted-foreground">已识别为{{ fromNumber.unitUsed === 's' ? '秒级' : '毫秒级' }}时间戳</p>
</template>
<p v-else-if="numberInput" class="text-xs text-destructive">请输入有效的数字时间戳</p>
</div>
<Separator />
<!-- 时间 时间戳 -->
<div class="flex flex-col gap-1.5">
<Label class="text-xs">日期时间 Unix 时间戳</Label>
<Input v-model="dateInput" type="datetime-local" class="font-mono text-sm" />
<ResultArea v-if="fromDate" :text="fromDate" label="结果" placeholder="结果" />
</div>
<Separator />
<!-- 当前时间戳 -->
<div class="flex flex-col gap-2">
<Label class="text-xs">当前时间{{ now.local }}</Label>
<div class="flex items-center gap-2 flex-wrap">
<Button size="sm" variant="outline" class="h-8 font-mono text-xs gap-1.5" @click="copy(now.s)">
<Clock class="size-3.5" />
{{ now.s }}点击复制
</Button>
<Button size="sm" variant="outline" class="h-8 font-mono text-xs gap-1.5" @click="copy(now.ms)">
<Clock class="size-3.5" />
{{ now.ms }}毫秒点击复制
</Button>
</div>
</div>
</div>
</template>
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ArrowDownUp } from '@lucide/vue'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import Segmented from '../components/Segmented.vue'
import ResultArea from '../components/ResultArea.vue'
type Tab = 'convert' | 'parse'
const tab = ref<Tab>('convert')
const mode = ref<'encode' | 'decode'>('encode')
const usePlus = ref(false)
const input = ref('')
// ===== 编解码 =====
const output = computed<{ text: string; error: string }>(() => {
const v = input.value
if (!v) return { text: '', error: '' }
if (mode.value === 'encode') {
const enc = encodeURIComponent(v)
return { text: usePlus.value ? enc.replace(/%20/g, '+') : enc, error: '' }
}
// decode:先处理 + 与 %20 两种形式
const normalized = usePlus.value ? v.replace(/\+/g, ' ') : v
try {
return { text: decodeURIComponent(normalized), error: '' }
} catch (e) {
return { text: '', error: '解码失败(存在非法百分号序列):' + String(e) }
}
})
const swap = () => {
if (output.value.text) {
input.value = output.value.text
mode.value = mode.value === 'encode' ? 'decode' : 'encode'
}
}
// ===== URL 解析 =====
const parseInput = ref('https://user:pass@example.com:8080/path/to/page?a=1&b=hello%20world&c=3#section')
interface ParsedUrl {
href: string
protocol: string
username: string
password: string
host: string
hostname: string
port: string
pathname: string
search: string
hash: string
origin: string
params: Array<[string, string]>
paramError: string
}
const parsedUrl = computed<{ url: ParsedUrl | null; error: string }>(() => {
const v = parseInput.value.trim()
if (!v) return { url: null, error: '' }
let u: URL
try {
u = new URL(v)
} catch {
// 无协议时尝试补 http:// 再解析
try {
u = new URL('http://' + v)
} catch (e) {
return { url: null, error: 'URL 解析失败:' + String(e) }
}
}
const params: Array<[string, string]> = []
let paramError = ''
try {
u.searchParams.forEach((value, key) => params.push([key, value]))
} catch (e) {
paramError = String(e)
}
return {
url: {
href: u.href,
protocol: u.protocol,
username: u.username,
password: u.password,
host: u.host,
hostname: u.hostname,
port: u.port,
pathname: u.pathname,
search: u.search,
hash: u.hash,
origin: u.origin,
params,
paramError
},
error: ''
}
})
const FIELDS: Array<{ key: keyof ParsedUrl; label: string }> = [
{ key: 'protocol', label: '协议' },
{ key: 'username', label: '用户名' },
{ key: 'password', label: '密码' },
{ key: 'hostname', label: '主机名' },
{ key: 'port', label: '端口' },
{ key: 'pathname', label: '路径' },
{ key: 'search', label: '查询串' },
{ key: 'hash', label: '锚点' },
{ key: 'origin', label: 'Origin' }
]
</script>
<template>
<div class="flex flex-col gap-4">
<Segmented
v-model="tab"
:options="[
{ value: 'convert', label: '编解码' },
{ value: 'parse', label: 'URL 解析' }
]"
/>
<!-- 编解码 -->
<template v-if="tab === 'convert'">
<div class="flex items-center justify-between flex-wrap gap-3">
<Segmented
v-model="mode"
label="操作"
:options="[
{ value: 'encode', label: '编码' },
{ value: 'decode', label: '解码' }
]"
/>
<label class="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
空格用
<Switch v-model="usePlus" />
<span class="font-mono">{{ usePlus ? '+(表单)' : '%20' }}</span>
</label>
<Button size="sm" variant="outline" class="h-8 text-sm gap-1" :disabled="!output.text" @click="swap">
<ArrowDownUp class="size-3.5" />
结果回填
</Button>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">{{ mode === 'encode' ? '待编码文本' : '待解码字符串' }}</Label>
<Textarea v-model="input" placeholder="在此输入..." class="min-h-[120px] font-mono text-xs resize-y" />
</div>
<ResultArea :text="output.text" placeholder="结果" />
<p v-if="output.error" class="text-xs text-destructive">{{ output.error }}</p>
</template>
<!-- URL 解析 -->
<template v-else>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">URL</Label>
<Textarea v-model="parseInput" placeholder="https://example.com/path?a=1#hash" class="min-h-[70px] font-mono text-xs resize-y" />
</div>
<template v-if="parsedUrl.url">
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div v-for="f in FIELDS" :key="f.key" class="flex gap-3 px-3 py-1.5">
<span class="w-16 shrink-0 text-muted-foreground">{{ f.label }}</span>
<span class="font-mono break-all">{{ (parsedUrl.url[f.key] as string) || '—' }}</span>
</div>
</div>
<div v-if="parsedUrl.url.params.length > 0" class="flex flex-col gap-1.5">
<Label class="text-xs">查询参数{{ parsedUrl.url.params.length }} </Label>
<div class="rounded-md border border-border divide-y divide-border text-xs">
<div v-for="([k, v], i) in parsedUrl.url.params" :key="i" class="flex gap-3 px-3 py-1.5">
<span class="font-mono text-primary break-all">{{ k }}</span>
<span class="font-mono break-all">{{ v }}</span>
</div>
</div>
</div>
<p v-if="parsedUrl.url.paramError" class="text-xs text-destructive">{{ parsedUrl.url.paramError }}</p>
<p v-else-if="parsedUrl.url.params.length === 0" class="text-xs text-muted-foreground">无查询参数</p>
</template>
<p v-else-if="!parseInput" class="text-xs text-muted-foreground">输入 URL 查看解析结果</p>
<p v-else class="text-xs text-destructive">{{ parsedUrl.error }}</p>
</template>
</div>
</template>
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RefreshCw, Copy } from '@lucide/vue'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import Segmented from '../components/Segmented.vue'
import ResultArea from '../components/ResultArea.vue'
const count = ref('5')
const version = ref<'v4' | 'v7'>('v4')
const uppercase = ref(false)
const noDashes = ref(false)
// 原始随机 UUID 与格式化后的展示
const baseUuids = ref<string[]>([])
const batchCount = computed(() => {
const n = Math.floor(Number(count.value))
if (!Number.isFinite(n)) return 5
return Math.min(Math.max(n || 0, 1), 100)
})
function formatUuid(u: string): string {
let out = uppercase.value ? u.toUpperCase() : u
if (noDashes.value) out = out.replace(/-/g, '')
return out
}
const uuids = computed(() => baseUuids.value.map(formatUuid))
/** UUID v4:纯随机 */
function uuidv4(): string {
return crypto.randomUUID()
}
/** UUID v7:毫秒时间戳前缀(48 bit)+ 随机,时间有序、适合数据库索引 */
function uuidv7(): string {
const ts = BigInt(Date.now())
const b = crypto.getRandomValues(new Uint8Array(16))
b[0] = Number((ts >> 40n) & 0xffn)
b[1] = Number((ts >> 32n) & 0xffn)
b[2] = Number((ts >> 24n) & 0xffn)
b[3] = Number((ts >> 16n) & 0xffn)
b[4] = Number((ts >> 8n) & 0xffn)
b[5] = Number(ts & 0xffn)
b[6] = (b[6] & 0x0f) | 0x70 // version 7
b[8] = (b[8] & 0x3f) | 0x80 // variant 10xx
const hex = Array.from(b, x => x.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
function generate() {
const gen = version.value === 'v4' ? uuidv4 : uuidv7
baseUuids.value = Array.from({ length: batchCount.value }, gen)
}
const allText = computed(() => uuids.value.join('\n'))
function copyAll() {
try {
void navigator.clipboard.writeText(allText.value)
} catch {
/* ignore */
}
}
// 切换版本时立即重新生成
watch(version, generate)
onMounted(generate)
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between flex-wrap gap-3">
<div class="flex items-center gap-4 flex-wrap">
<div class="flex flex-col gap-1.5">
<Label class="text-xs">生成数量1-100</Label>
<Input v-model="count" type="number" min="1" max="100" class="w-24 font-mono text-sm" />
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">版本</Label>
<div class="flex items-center gap-2">
<Segmented
v-model="version"
:options="[
{ value: 'v4', label: 'v4 随机' },
{ value: 'v7', label: 'v7 时间有序' }
]"
/>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
大写
<Switch v-model="uppercase" />
</label>
<label class="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
去除连字符
<Switch v-model="noDashes" />
</label>
<Button size="sm" class="gap-1" @click="generate">
<RefreshCw class="size-3.5" />
重新生成
</Button>
</div>
</div>
<ResultArea :text="allText" label="UUID 列表" placeholder="点击重新生成" />
<div class="flex items-center gap-2">
<Button size="sm" variant="outline" class="gap-1" :disabled="!allText" @click="copyAll">
<Copy class="size-3.5" />
复制全部{{ uuids.length }}
</Button>
<span class="text-xs text-muted-foreground">{{ version === 'v4' ? 'UUID v4(纯随机)' : 'UUID v7(毫秒时间戳前缀,适合数据库主键索引)' }}</span>
</div>
</div>
</template>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Segmented from '../../components/Segmented.vue'
import ResultArea from '../../components/ResultArea.vue'
type Radix = '2' | '8' | '10' | '16'
const bases: { value: Radix; label: string }[] = [
{ value: '2', label: '二进制(2)' },
{ value: '8', label: '八进制(8)' },
{ value: '10', label: '十进制(10)' },
{ value: '16', label: '十六进制(16)' }
]
const from = ref<Radix>('10')
const to = ref<Radix>('16')
const input = ref('')
const error = ref('')
const RADIX_MAP: Record<Radix, number> = { '2': 2, '8': 8, '10': 10, '16': 16 }
/** 按进制精确解析为 BigInt(大数字不丢精度),非法输入抛错 */
function parseBigInt(s: string, radix: number): bigint {
const digits = '0123456789abcdef'
const trimmed = s.trim().toLowerCase()
const negative = trimmed.startsWith('-')
const body = trimmed.replace(/^[+-]/, '')
if (!body) throw new Error('empty')
let n = 0n
for (const ch of body) {
const d = digits.indexOf(ch)
if (d === -1 || d >= radix) throw new Error('invalid digit')
n = n * BigInt(radix) + BigInt(d)
}
return negative ? -n : n
}
const result = computed(() => {
error.value = ''
const v = input.value.trim()
if (!v) return { dec: null, output: '', outputUppercase: '' }
try {
const parsed = parseBigInt(v, RADIX_MAP[from.value])
const out = parsed.toString(RADIX_MAP[to.value])
return {
dec: parsed,
output: out,
outputUppercase: to.value === '16' ? out.toUpperCase() : out
}
} catch {
error.value = '输入不合法,请确认数字与当前进制匹配(如十六进制仅含 0-9a-f)'
return { dec: null, output: '', outputUppercase: '' }
}
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3 flex-wrap">
<Segmented v-model="from" label="从" :options="bases" />
<span class="text-xs text-muted-foreground"></span>
<Segmented v-model="to" label="到" :options="bases" />
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">输入{{ from === '2' ? '二进制' : from === '8' ? '八进制' : from === '10' ? '十进制' : '十六进制' }}</Label>
<Input v-model="input" placeholder="输入数字" class="font-mono text-sm" />
</div>
<ResultArea :text="result.output" label="结果" placeholder="结果" />
<ResultArea v-if="to === '16' && result.outputUppercase !== result.output" :text="result.outputUppercase" label="大写形式" placeholder="大写形式" />
<div v-if="result.dec !== null" class="text-xs text-muted-foreground">十进制值{{ result.dec.toString() }}</div>
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
</div>
</template>
+171
View File
@@ -0,0 +1,171 @@
import type { Component } from 'vue'
import { defineAsyncComponent } from 'vue'
import {
FileJson, Binary, Link, Clock, MoveHorizontal, Fingerprint,
Wand2, KeyRound, CaseSensitive, Hash, Code, GitCompare, Shield,
Palette, Lock, CalendarClock, Network, Sigma, Replace, Globe
} from '@lucide/vue'
import { registerTool, type DevTool } from '../registry'
/** 工具元数据(与组件解耦,供模块配置生成搜索项) */
export const TOOLS_META: Array<Omit<DevTool, 'component' | 'icon'>> = [
{
id: 'json', name: 'JSON 格式化', category: 'transform',
description: '格式化 / 压缩 / 校验,支持键排序',
keywords: ['json', '格式化', '美化', '压缩', '校验', '排序']
},
{
id: 'timestamp', name: '时间戳转换', category: 'transform',
description: 'Unix 时间戳与日期时间互转,自动识别秒 / 毫秒',
keywords: ['时间戳', 'timestamp', 'unix', '日期', '时间', '转换', '现在']
},
{
id: 'base', name: '进制转换', category: 'transform',
description: '二进制 / 八进制 / 十进制 / 十六进制互转',
keywords: ['进制', '二进制', '十六进制', 'hex', 'bin', 'oct', 'dec', 'base']
},
{
id: 'color', name: '颜色转换器', category: 'transform',
description: 'HEX / RGB / HSL / HSV / CMYK 互转,明暗梯度',
keywords: ['颜色', 'color', 'hex', 'rgb', 'hsl', 'hsv', 'cmyk', '调色', '取色']
},
{
id: 'ip', name: 'IP / CIDR 计算', category: 'transform',
description: '子网划分、掩码换算、IP 与整数互转',
keywords: ['ip', 'cidr', '子网', '掩码', '网段', '广播', '网络', 'subnet', 'mask']
},
{
id: 'cron', name: 'Cron 表达式', category: 'transform',
description: '解析 Cron 表达式,预览接下来 6 次执行时间',
keywords: ['cron', 'crontab', '定时', '计划任务', '表达式', 'schedule']
},
{
id: 'base64', name: 'Base64 转换', category: 'encoding',
description: '文本与 Base64 互转(支持中文),文件转 Base64 / Data URL',
keywords: ['base64', '编码', '解码', 'encode', 'decode', 'dataurl', '文件']
},
{
id: 'url', name: 'URL 编解码', category: 'encoding',
description: 'URL 编码 / 解码,URL 结构解析',
keywords: ['url', 'encode', 'decode', '编码', '解码', '链接', '解析', '参数', 'query']
},
{
id: 'jwt', name: 'JWT 解码', category: 'encoding',
description: '本地解析 JWT 的 Header 与 Payload,含过期时间提示',
keywords: ['jwt', 'token', '解码', 'header', 'payload', '过期', 'exp']
},
{
id: 'escape', name: '转义 / 反转义', category: 'encoding',
description: 'HTML 实体与 JSON 字符串转义、反转义',
keywords: ['转义', '反转义', 'html', 'entity', 'json', 'escape']
},
{
id: 'case', name: '大小写 / 命名转换', category: 'text',
description: 'camel / Pascal / snake / kebab 等命名转换',
keywords: ['大小写', '命名', 'camel', 'snake', 'kebab', 'pascal', '转换']
},
{
id: 'regex', name: '正则测试', category: 'text',
description: '在线测试正则表达式,实时匹配、分组查看与替换预览',
keywords: ['正则', 'regex', '匹配', 'test', 're', '替换']
},
{
id: 'diff', name: '文本对比', category: 'text',
description: '两段文本逐行差异对比',
keywords: ['对比', '差异', 'diff', '比较', 'compare']
},
{
id: 'eol', name: '行尾符转换', category: 'text',
description: 'CRLF / LF / CR 行尾符统一',
keywords: ['行尾', '换行', 'crlf', 'lf', 'cr', 'eol', '转行']
},
{
id: 'replace', name: '批量查找替换', category: 'text',
description: '纯文本 / 正则批量替换,支持分组引用',
keywords: ['替换', '查找', 'replace', '批量', '正则替换']
},
{
id: 'charcount', name: '字符统计', category: 'text',
description: '字符 / 字节 / 词数 / 行数统计与高频分析',
keywords: ['统计', '字数', '字符数', '词频', 'count', 'words', '字节数']
},
{
id: 'uuid', name: 'UUID 生成', category: 'generate',
description: '批量生成 UUID v4 / v7,支持大写与去连字符',
keywords: ['uuid', 'guid', '生成', '随机', 'id', 'v4', 'v7']
},
{
id: 'hash', name: '哈希计算', category: 'generate',
description: 'MD5 / SHA-1 / SHA-256 / SHA-384 / SHA-512 与 HMAC,支持文件',
keywords: ['哈希', 'hash', 'sha', '摘要', 'md5', 'hmac', '文件', '校验']
},
{
id: 'password', name: '密码生成', category: 'generate',
description: '随机密码批量生成,字符集可选,附熵值评估',
keywords: ['密码', 'password', '随机', '生成', '安全', 'entropy']
},
{
id: 'httpstatus', name: 'HTTP 速查', category: 'reference',
description: 'HTTP 状态码与常用 MIME 类型速查',
keywords: ['http', '状态码', 'status', 'mime', 'content-type', '速查', '429', '404']
}
]
/** 图标映射(按工具 id */
const TOOL_ICONS: Record<string, Component> = {
json: FileJson,
timestamp: Clock,
base: Hash,
color: Palette,
ip: Network,
cron: CalendarClock,
base64: Binary,
url: Link,
jwt: KeyRound,
escape: Code,
case: CaseSensitive,
regex: Wand2,
diff: GitCompare,
eol: MoveHorizontal,
replace: Replace,
charcount: Sigma,
uuid: Fingerprint,
hash: Shield,
password: Lock,
httpstatus: Globe
}
// 工具组件(懒加载路径映射)
const TOOL_COMPONENTS: Record<string, () => Promise<{ default: Component }>> = {
json: () => import('./JsonTools.vue'),
timestamp: () => import('./TimestampTools.vue'),
base: () => import('./base/BaseTools.vue'),
color: () => import('./ColorTools.vue'),
ip: () => import('./IpTools.vue'),
cron: () => import('./CronTools.vue'),
base64: () => import('./Base64Tools.vue'),
url: () => import('./UrlTools.vue'),
jwt: () => import('./JwtTools.vue'),
escape: () => import('./EscapeTools.vue'),
case: () => import('./CaseTools.vue'),
regex: () => import('./RegexTools.vue'),
diff: () => import('./DiffTools.vue'),
eol: () => import('./EolTools.vue'),
replace: () => import('./ReplaceTools.vue'),
charcount: () => import('./CharCountTools.vue'),
uuid: () => import('./UuidTools.vue'),
hash: () => import('./HashTools.vue'),
password: () => import('./PasswordTools.vue'),
httpstatus: () => import('./HttpStatusTools.vue')
}
/** 将所有工具注册进注册表(幂等) */
export function registerAllTools(): void {
for (const meta of TOOLS_META) {
registerTool({
...meta,
icon: TOOL_ICONS[meta.id],
component: defineAsyncComponent(TOOL_COMPONENTS[meta.id])
})
}
}