diff --git a/frondend/package-lock.json b/frondend/package-lock.json index 7db3f8b..ecf9b99 100644 --- a/frondend/package-lock.json +++ b/frondend/package-lock.json @@ -11,6 +11,7 @@ "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jsqr": "^1.4.0", "lucide-vue-next": "^1.0.0", "qrcode": "^1.5.4", "reka-ui": "^2.10.4", @@ -1954,6 +1955,11 @@ "node": ">=6" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/frondend/package.json b/frondend/package.json index fe29d00..a9d0c6f 100644 --- a/frondend/package.json +++ b/frondend/package.json @@ -12,6 +12,7 @@ "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jsqr": "^1.4.0", "lucide-vue-next": "^1.0.0", "qrcode": "^1.5.4", "reka-ui": "^2.10.4", diff --git a/frondend/src/views/home/HomeView.vue b/frondend/src/views/home/HomeView.vue index 9670c93..0b4dedc 100644 --- a/frondend/src/views/home/HomeView.vue +++ b/frondend/src/views/home/HomeView.vue @@ -4,11 +4,12 @@ import { useRouter } from 'vue-router' import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import AppDialog from '@/components/ui/dialog/AppDialog.vue' +import jsQR from 'jsqr' import * as authApi from '@/api/auth' import { CPD_PASSWORD_ERROR } from '@/api' import { useMessage } from '@/composables/useMessage' -const { message } = useMessage() +const message = useMessage() const router = useRouter() const loading = ref(true) @@ -344,54 +345,182 @@ function authHeaders(): Record { return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } } +// ======== 从二维码文本中提取 uuid(兼容完整链接、纯 uuid)======== +function extractQrUuid(text: string): string { + const m = /uuid=([0-9a-zA-Z_-]+)/.exec(text) + if (m) return m[1] + const t = text.trim() + return /^[0-9a-zA-Z_-]{8,64}$/.test(t) ? t : '' +} + +// ======== 前端解码二维码(jsQR):读取文件 → 绘制到 canvas → 取像素解码 ======== +// 支持多尺度尝试,提高"拍摄屏幕"这类低质量图片的识别率。 +function decodeQrInBrowser(file: File): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file) + const img = new Image() + img.onload = () => { + try { + const scales = [1, 0.6, 0.4, 1.5] + for (const s of scales) { + const w = Math.max(1, Math.round(img.naturalWidth * s)) + const h = Math.max(1, Math.round(img.naturalHeight * s)) + // 限制最长边,避免超大图(如 4032x3024)绘制过慢 + const cap = 1600 + const ratio = Math.min(1, cap / Math.max(w, h)) + const cw = Math.max(1, Math.round(w * ratio)) + const ch = Math.max(1, Math.round(h * ratio)) + const canvas = document.createElement('canvas') + canvas.width = cw + canvas.height = ch + const ctx = canvas.getContext('2d') + if (!ctx) continue + ctx.drawImage(img, 0, 0, cw, ch) + const data = ctx.getImageData(0, 0, cw, ch) + const code = jsQR(data.data, cw, ch, { inversionAttempts: 'attemptBoth' }) + if (code?.data) { + URL.revokeObjectURL(url) + resolve(code.data) + return + } + } + URL.revokeObjectURL(url) + resolve('') + } catch { + URL.revokeObjectURL(url) + resolve('') + } + } + img.onerror = () => { + URL.revokeObjectURL(url) + resolve('') + } + img.src = url + }) +} + +// ======== 前端压缩图片(默认目标 ≤300KB),返回压缩后的 File ======== +// 仅对签到照片等需上传 OBS 的图片使用;二维码不上传、无需压缩。 +async function compressImage(file: File, maxBytes = 300 * 1024): Promise { + if (file.size <= maxBytes) return file + const dataUrl = await new Promise((resolve) => { + const r = new FileReader() + r.onload = (e) => resolve((e.target?.result as string) || '') + r.onerror = () => resolve('') + r.readAsDataURL(file) + }) + if (!dataUrl) return file + + const img = await new Promise((resolve) => { + const i = new Image() + i.onload = () => resolve(i) + i.onerror = () => resolve(null) + i.src = dataUrl + }) + if (!img) return file + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) return file + + // 逐步降低质量/尺寸,直到体积达标(下限保证清晰度) + let scale = 1 + let quality = 0.85 + let out: Blob | null = null + for (let i = 0; i < 8; i++) { + const w = Math.max(1, Math.round(img.naturalWidth * scale)) + const h = Math.max(1, Math.round(img.naturalHeight * scale)) + canvas.width = w + canvas.height = h + ctx.clearRect(0, 0, w, h) + ctx.drawImage(img, 0, 0, w, h) + out = await new Promise((resolve) => + canvas.toBlob((b) => resolve(b), 'image/jpeg', quality), + ) + if (!out) break + if (out.size <= maxBytes) break + // 先降质量,质量到底后再降尺寸 + if (quality > 0.5) quality -= 0.15 + else scale *= 0.8 + if (scale < 0.3) break + } + if (!out) return file + const name = file.name.replace(/\.[^.]+$/, '') + '.jpg' + return new File([out], name, { type: 'image/jpeg' }) +} + // ======== 图片上传 → 浏览器直传到今日校园 OBS(不经过服务器中转),成功后立即保存表单 ======== const uploadingImage = ref(false) +const uploadingLabel = ref('上传中...') async function handleImageUpload(index: number, isEdit: boolean) { if (uploadingImage.value) return const input = document.createElement('input') input.type = 'file' input.accept = 'image/*' input.onchange = async () => { - const file = input.files?.[0] - if (!file) return + const picked = input.files?.[0] + if (!picked) return const taskType = isEdit ? (editTask.value?.taskType || 'sign') : (selectedItem.value?.taskType || 'sign') const items = isEdit ? editFormItems.value : formItems.value - // 二维码字段:仅解析出 uuid 保存(不存整张图片,避免 formData 达数 MB) - if (isQrField(items[index])) { - const qrReader = new FileReader() - qrReader.onload = async (e) => { - const dataUrl = (e.target?.result as string) || '' - if (!dataUrl) return - try { - const res = await authApi.decodeQr(dataUrl) - const uuid = res.data.data.uuid - if (!uuid) { - message.error('未识别到二维码,请重新上传清晰的二维码图片') - return - } - items[index].content = uuid - message.success('二维码已解析') - } catch (err: any) { - message.error(err?.response?.data?.message || '二维码解析失败,请重新上传') - } - } - qrReader.readAsDataURL(file) - return - } - // 记录原值:任何失败路径都回滚,避免本地预览的 base64 被当成正式值保存入库 const prevContent = items[index].content || '' - // 先显示本地预览(仅界面展示,上传成功后会覆盖为 OBS 正式 URL) - const reader = new FileReader() - reader.onload = (e) => { - items[index].content = (e.target?.result as string) || '' + // ---- 二维码字段:前端 jsQR 直接解析出 uuid,不上传、不压缩 ---- + if (isQrField(items[index])) { + uploadingImage.value = true + uploadingLabel.value = '解析二维码中...' + try { + const text = await decodeQrInBrowser(picked) + let uuid = text ? extractQrUuid(text) : '' + // 前端识别失败时,降级走后端已有识别能力(复用 gozxing,多尺度重试) + if (!uuid) { + const dataUrl = await new Promise((resolve) => { + const r = new FileReader() + r.onload = (e) => resolve((e.target?.result as string) || '') + r.onerror = () => resolve('') + r.readAsDataURL(picked) + }) + if (dataUrl) { + try { + const res = await authApi.decodeQr(dataUrl) + uuid = res.data.data.uuid || '' + } catch { + // 忽略,走统一失败提示 + } + } + } + if (!uuid) { + message.error('未识别到二维码,请换一张更清晰的二维码图片') + return + } + items[index].content = uuid + message.success('二维码已解析') + } finally { + uploadingImage.value = false + } + return } - reader.readAsDataURL(file) + // ---- 其它图片(签到照片等):先压缩到 ≤300KB,再上传 OBS ---- + let file = picked uploadingImage.value = true + uploadingLabel.value = '压缩中...' try { + try { + file = await compressImage(picked, 300 * 1024) + } catch { + file = picked + } + + // 本地预览(仅界面展示,上传成功后会覆盖为 OBS 正式 URL) + const reader = new FileReader() + reader.onload = (e) => { + items[index].content = (e.target?.result as string) || '' + } + reader.readAsDataURL(file) + + uploadingLabel.value = '上传中...' // Step 1: 从服务器获取 OBS 上传策略(仅获取凭证,不传文件) const policyRes = await fetch('/api/task/upload-policy', { method: 'POST', @@ -1291,9 +1420,10 @@ function getGPSLocation(source: string) {
+ {{ uploadingLabel }}
+ {{ uploadingLabel }}