- 新增 UploadDrop 通用组件:点击选择 + 拖拽上传、图片缩略图回显、 批量/单文件、删除,替代各页面的 t-upload 文件模式 - 图片压缩重写:批量(最多 20 张)、按质量/限制大小双模式、 等比缩放、保持原格式、JPG 透明图白底填充、jszip 批量下载 - 修复压缩页历史解析正则 bug(/| jpg/ 恒为 true)与 slider label 类型错误 - 裁剪/二维码/Base64/哈希/OCR 页面统一接入 UploadDrop,文件变化改用 watch 响应 - 更新 agents.md 与 README:UploadDrop 约定、新坑位与依赖说明
252 lines
7.8 KiB
Vue
252 lines
7.8 KiB
Vue
<template>
|
||
<div class="page-wrap">
|
||
<h2 class="page-title">二维码生成 / 解析</h2>
|
||
<p class="page-desc">输入文本或链接生成二维码;上传二维码图片解析其内容。全部在浏览器本地完成。</p>
|
||
|
||
<t-tabs v-model="tab">
|
||
<!-- ==================== 生成 ==================== -->
|
||
<t-tab-panel value="generate" label="生成二维码">
|
||
<t-card class="tool-section" title="内容与样式">
|
||
<t-textarea
|
||
v-model="text"
|
||
placeholder="输入要生成二维码的文本或链接,例如 https://example.com"
|
||
:maxlength="2000"
|
||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||
/>
|
||
<div class="form-row" style="margin-top: 12px">
|
||
<span class="form-label">尺寸</span>
|
||
<t-select v-model="size" :options="sizeOptions" class="form-select" />
|
||
<span class="form-label">容错率</span>
|
||
<t-select v-model="level" :options="levelOptions" class="form-select" />
|
||
</div>
|
||
<div class="form-row" style="margin-top: 12px">
|
||
<span class="form-label">前景色</span>
|
||
<input v-model="dark" type="color" class="color-input" />
|
||
<span class="form-label">背景色</span>
|
||
<input v-model="light" type="color" class="color-input" />
|
||
</div>
|
||
<div class="form-actions">
|
||
<t-button theme="primary" :loading="genBusy" @click="generate">生成二维码</t-button>
|
||
<t-button variant="outline" @click="clearGen">清空</t-button>
|
||
</div>
|
||
</t-card>
|
||
|
||
<ErrorAlert :message="genError" @close="genError = ''" />
|
||
|
||
<t-card v-if="qrUrl" class="tool-section" title="生成结果">
|
||
<div class="qr-result">
|
||
<img :src="qrUrl" alt="生成的二维码" class="qr-img" />
|
||
<div class="form-actions">
|
||
<DownloadButton :filename="`qrcode_${size}.png`" :get-blob="getQrBlob" label="下载 PNG" />
|
||
<CopyButton :text="text" label="复制内容" />
|
||
</div>
|
||
</div>
|
||
</t-card>
|
||
|
||
<HistoryPanel
|
||
:items="history.items"
|
||
@select="(t: string) => (text = t)"
|
||
@remove="history.remove"
|
||
@clear="history.clear"
|
||
/>
|
||
</t-tab-panel>
|
||
|
||
<!-- ==================== 解析 ==================== -->
|
||
<t-tab-panel value="parse" label="解析二维码">
|
||
<t-card class="tool-section" title="上传二维码图片">
|
||
<UploadDrop v-model:files="parseFiles" accept="image/*" tip="上传一张二维码图片自动解析" />
|
||
</t-card>
|
||
|
||
<ErrorAlert :message="parseError" @close="parseError = ''" />
|
||
|
||
<t-card v-if="parseResult" class="tool-section" title="解析结果">
|
||
<div class="code-block">{{ parseResult }}</div>
|
||
<div class="form-actions">
|
||
<CopyButton :text="parseResult" />
|
||
</div>
|
||
</t-card>
|
||
<t-card v-else-if="parsed" class="tool-section" title="解析结果">
|
||
<t-alert theme="warning" message="未能从图片中识别出二维码,请确认图片清晰且包含完整二维码" />
|
||
</t-card>
|
||
|
||
<HistoryPanel
|
||
:items="parseHistory.items"
|
||
@select="(t: string) => (parseResult = t)"
|
||
@remove="parseHistory.remove"
|
||
@clear="parseHistory.clear"
|
||
/>
|
||
</t-tab-panel>
|
||
</t-tabs>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch } from 'vue'
|
||
import QRCode from 'qrcode'
|
||
import jsQR from 'jsqr'
|
||
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 { dataUrlToBlob } from '@/utils'
|
||
|
||
const tab = ref('generate')
|
||
|
||
/* ---------- 生成 ---------- */
|
||
const sizeOptions = [
|
||
{ label: '128 × 128', value: 128 },
|
||
{ label: '256 × 256', value: 256 },
|
||
{ label: '512 × 512', value: 512 },
|
||
{ label: '1024 × 1024', value: 1024 },
|
||
]
|
||
const levelOptions = ['L', 'M', 'Q', 'H'].map((v) => ({ label: v, value: v }))
|
||
|
||
const text = ref('')
|
||
const size = ref(256)
|
||
const level = ref('M')
|
||
const dark = ref('#000000')
|
||
const light = ref('#ffffff')
|
||
const genBusy = ref(false)
|
||
const genError = ref('')
|
||
const qrUrl = ref('')
|
||
|
||
const history = useHistory('qrcode-generate')
|
||
|
||
/** 生成二维码:qrcode 库渲染到离屏 canvas 后导出 DataURL */
|
||
async function generate() {
|
||
if (!text.value.trim()) {
|
||
genError.value = '请输入要生成二维码的内容'
|
||
return
|
||
}
|
||
genBusy.value = true
|
||
genError.value = ''
|
||
qrUrl.value = ''
|
||
try {
|
||
const canvas = document.createElement('canvas')
|
||
await QRCode.toCanvas(canvas, text.value, {
|
||
width: size.value,
|
||
margin: 2,
|
||
errorCorrectionLevel: level.value as 'L' | 'M' | 'Q' | 'H',
|
||
color: { dark: dark.value, light: light.value },
|
||
})
|
||
qrUrl.value = canvas.toDataURL('image/png')
|
||
history.add(text.value)
|
||
} catch {
|
||
genError.value = '生成失败:内容过长或参数异常,请缩短内容后重试'
|
||
} finally {
|
||
genBusy.value = false
|
||
}
|
||
}
|
||
|
||
function getQrBlob(): Blob | null {
|
||
if (!qrUrl.value) return null
|
||
try {
|
||
return dataUrlToBlob(qrUrl.value)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function clearGen() {
|
||
text.value = ''
|
||
qrUrl.value = ''
|
||
genError.value = ''
|
||
}
|
||
|
||
/* ---------- 解析 ---------- */
|
||
const parseFiles = ref<File[]>([])
|
||
const parseError = ref('')
|
||
const parseResult = ref('')
|
||
const parsed = ref(false)
|
||
const parseHistory = useHistory('qrcode-parse')
|
||
|
||
/** 文件变化时(含拖拽删除/清空)自动解析 */
|
||
watch(parseFiles, (list) => {
|
||
const raw = list[0]
|
||
if (!(raw instanceof File)) {
|
||
parseError.value = ''
|
||
parseResult.value = ''
|
||
parsed.value = false
|
||
return
|
||
}
|
||
void parseImage(raw)
|
||
})
|
||
|
||
async function parseImage(raw: File) {
|
||
parseError.value = ''
|
||
parseResult.value = ''
|
||
parsed.value = false
|
||
|
||
try {
|
||
// 将图片绘制到 canvas 获取像素数据,再用 jsqr 识别
|
||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onload = () => resolve(reader.result as string)
|
||
reader.onerror = () => reject(reader.error)
|
||
reader.readAsDataURL(raw)
|
||
})
|
||
const img = new Image()
|
||
img.src = dataUrl
|
||
await new Promise((resolve, reject) => {
|
||
img.onload = resolve
|
||
img.onerror = () => reject(new Error('图片加载失败'))
|
||
})
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = img.naturalWidth
|
||
canvas.height = img.naturalHeight
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) throw new Error('无法创建画布')
|
||
ctx.drawImage(img, 0, 0)
|
||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||
const result = jsQR(imageData.data, imageData.width, imageData.height)
|
||
parsed.value = true
|
||
if (result?.data) {
|
||
parseResult.value = result.data
|
||
parseHistory.add(result.data)
|
||
}
|
||
} catch {
|
||
parseError.value = '解析失败:图片格式不受支持,请换一张图片重试'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.form-label {
|
||
width: 72px;
|
||
flex-shrink: 0;
|
||
font-size: 14px;
|
||
color: var(--td-text-color-secondary);
|
||
}
|
||
.form-select {
|
||
width: 150px;
|
||
}
|
||
.color-input {
|
||
width: 44px;
|
||
height: 32px;
|
||
padding: 0 2px;
|
||
border: 1px solid var(--td-border-level-2-color);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
}
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.qr-result {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.qr-img {
|
||
max-width: 280px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--td-border-level-1-color);
|
||
}
|
||
</style>
|