245 lines
7.3 KiB
Vue
245 lines
7.3 KiB
Vue
<template>
|
||
<div class="page-wrap">
|
||
<h2 class="page-title">Base64 编解码</h2>
|
||
<p class="page-desc">文本 Base64 编码/解码;图片文件与 Base64 字符串互转,全部在浏览器本地完成。</p>
|
||
|
||
<t-tabs v-model="tab">
|
||
<!-- 文本编解码 -->
|
||
<t-tab-panel value="text" label="文本编解码">
|
||
<t-card class="tool-section" title="输入">
|
||
<t-textarea
|
||
v-model="textInput"
|
||
placeholder="输入要编码/解码的文本"
|
||
:autosize="{ minRows: 5, maxRows: 10 }"
|
||
/>
|
||
<div class="form-actions">
|
||
<t-button theme="primary" @click="encodeText">编码(文本 → Base64)</t-button>
|
||
<t-button theme="primary" variant="outline" @click="decodeText">解码(Base64 → 文本)</t-button>
|
||
<t-button variant="outline" @click="clearText">清空</t-button>
|
||
</div>
|
||
</t-card>
|
||
|
||
<ErrorAlert :message="error" @close="error = ''" />
|
||
|
||
<t-card v-if="textOutput" class="tool-section" title="结果">
|
||
<t-textarea v-model="textOutput" :autosize="{ minRows: 5, maxRows: 10 }" readonly />
|
||
<div class="form-actions">
|
||
<CopyButton :text="textOutput" />
|
||
</div>
|
||
</t-card>
|
||
</t-tab-panel>
|
||
|
||
<!-- 图片互转 -->
|
||
<t-tab-panel value="image" label="图片互转">
|
||
<!-- 图片 -> Base64 -->
|
||
<t-card class="tool-section" title="图片 → Base64">
|
||
<UploadDrop v-model:files="files" accept="image/*" tip="上传图片,自动生成 Base64 字符串" />
|
||
</t-card>
|
||
|
||
<ErrorAlert :message="imgError" @close="imgError = ''" />
|
||
|
||
<t-card v-if="imgBase64" class="tool-section" title="Base64 结果">
|
||
<t-textarea v-model="imgBase64" :autosize="{ minRows: 4, maxRows: 8 }" readonly />
|
||
<div class="form-actions">
|
||
<CopyButton :text="imgBase64" />
|
||
</div>
|
||
</t-card>
|
||
|
||
<!-- Base64 -> 图片 -->
|
||
<t-card class="tool-section" title="Base64 → 图片">
|
||
<t-textarea
|
||
v-model="b64Input"
|
||
placeholder="粘贴图片的 Base64 字符串(可包含 data:image/...;base64, 前缀)"
|
||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||
/>
|
||
<div class="form-actions">
|
||
<t-button theme="primary" @click="b64ToImage">转换为图片</t-button>
|
||
<t-button variant="outline" @click="clearImage">清空</t-button>
|
||
</div>
|
||
</t-card>
|
||
|
||
<ErrorAlert :message="b64Error" @close="b64Error = ''" />
|
||
|
||
<t-card v-if="imgUrl" class="tool-section" title="转换结果">
|
||
<img
|
||
:src="imgUrl"
|
||
alt="Base64 还原的图片"
|
||
class="result-img img-previewable"
|
||
title="点击预览大图"
|
||
@click="openViewer([imgUrl])"
|
||
/>
|
||
<div class="form-actions">
|
||
<DownloadButton :filename="imgFilename" :get-blob="getImgBlob" label="下载图片" />
|
||
</div>
|
||
</t-card>
|
||
|
||
<!-- 结果图放大预览 -->
|
||
<AppImageViewer v-model:visible="viewerVisible" v-model:index="viewerIndex" :images="viewerImages" />
|
||
</t-tab-panel>
|
||
</t-tabs>
|
||
|
||
<HistoryPanel
|
||
:items="history.items"
|
||
@select="onHistorySelect"
|
||
@remove="history.remove"
|
||
@clear="history.clear"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import UploadDrop from '@/components/common/UploadDrop.vue'
|
||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||
import CopyButton from '@/components/common/CopyButton.vue'
|
||
import DownloadButton from '@/components/common/DownloadButton.vue'
|
||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||
import { useHistory } from '@/composables/useHistory'
|
||
import { useImageViewer } from '@/composables/useImageViewer'
|
||
import AppImageViewer from '@/components/common/AppImageViewer.vue'
|
||
import { dataUrlToBlob, fileToDataUrl } from '@/utils'
|
||
|
||
const tab = ref('text')
|
||
|
||
/* ---------- 文本编解码 ---------- */
|
||
const textInput = ref('')
|
||
const textOutput = ref('')
|
||
const error = ref('')
|
||
|
||
const history = useHistory('base64')
|
||
|
||
/** 结果图放大预览(TDesign ImageViewer) */
|
||
const { visible: viewerVisible, index: viewerIndex, images: viewerImages, open: openViewer } = useImageViewer()
|
||
|
||
/** 编码:UTF-8 安全的 base64(先转字节再 btoa,避免中文报错) */
|
||
function encodeText() {
|
||
error.value = ''
|
||
textOutput.value = ''
|
||
if (!textInput.value) {
|
||
error.value = '请输入要编码的文本'
|
||
return
|
||
}
|
||
try {
|
||
const bytes = new TextEncoder().encode(textInput.value)
|
||
let bin = ''
|
||
bytes.forEach((b) => (bin += String.fromCharCode(b)))
|
||
textOutput.value = btoa(bin)
|
||
history.add(textInput.value)
|
||
} catch {
|
||
error.value = '编码失败:输入包含无法处理的字符'
|
||
}
|
||
}
|
||
|
||
function decodeText() {
|
||
error.value = ''
|
||
textOutput.value = ''
|
||
if (!textInput.value.trim()) {
|
||
error.value = '请输入要解码的 Base64 文本'
|
||
return
|
||
}
|
||
try {
|
||
const bin = atob(textInput.value.trim())
|
||
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))
|
||
textOutput.value = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
|
||
} catch {
|
||
error.value = '解码失败:不是合法的 Base64 字符串'
|
||
}
|
||
}
|
||
|
||
function clearText() {
|
||
textInput.value = ''
|
||
textOutput.value = ''
|
||
error.value = ''
|
||
}
|
||
|
||
/* ---------- 图片互转 ---------- */
|
||
const files = ref<File[]>([])
|
||
const imgBase64 = ref('')
|
||
const imgError = ref('')
|
||
|
||
const b64Input = ref('')
|
||
const b64Error = ref('')
|
||
const imgUrl = ref('')
|
||
const imgMime = ref('image/png')
|
||
|
||
const imgFilename = computed(() => {
|
||
const ext = imgMime.value.split('/')[1] ?? 'png'
|
||
return `decoded_image.${ext}`
|
||
})
|
||
|
||
/** 文件变化时(含拖拽删除/清空)自动转 Base64 */
|
||
watch(files, (list) => {
|
||
const raw = list[0]
|
||
imgError.value = ''
|
||
imgBase64.value = ''
|
||
if (!(raw instanceof File)) return
|
||
void convertToBase64(raw)
|
||
})
|
||
|
||
async function convertToBase64(raw: File) {
|
||
try {
|
||
imgBase64.value = await fileToDataUrl(raw)
|
||
} catch {
|
||
imgError.value = '读取图片失败,请重试'
|
||
}
|
||
}
|
||
|
||
/** Base64 -> 图片:校验格式后渲染预览并生成 Blob */
|
||
function b64ToImage() {
|
||
b64Error.value = ''
|
||
imgUrl.value = ''
|
||
const raw = b64Input.value.trim()
|
||
if (!raw) {
|
||
b64Error.value = '请粘贴图片的 Base64 字符串'
|
||
return
|
||
}
|
||
try {
|
||
// 兼容带 data:image 前缀与纯 Base64 两种输入
|
||
const dataUrl = raw.startsWith('data:') ? raw : `data:image/png;base64,${raw}`
|
||
const blob = dataUrlToBlob(dataUrl)
|
||
if (!blob.type.startsWith('image/')) throw new Error('not image')
|
||
imgMime.value = blob.type
|
||
imgUrl.value = dataUrl
|
||
} catch {
|
||
b64Error.value = '转换失败:Base64 数据不合法或不是图片'
|
||
}
|
||
}
|
||
|
||
function getImgBlob(): Blob | null {
|
||
if (!imgUrl.value) return null
|
||
try {
|
||
return dataUrlToBlob(imgUrl.value)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function clearImage() {
|
||
b64Input.value = ''
|
||
b64Error.value = ''
|
||
imgUrl.value = ''
|
||
}
|
||
|
||
function onHistorySelect(text: string) {
|
||
tab.value = 'text'
|
||
textInput.value = text
|
||
encodeText()
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.result-img {
|
||
max-width: 100%;
|
||
max-height: 300px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--td-border-level-1-color);
|
||
margin-bottom: 12px;
|
||
}
|
||
</style>
|