v1.2.61:二维码改前端解析(jsQR)+ 图片上传前压缩 + 修复 message 解构错误 + 上传 loading
ci/woodpecker/tag/build Pipeline was successful
ci/woodpecker/tag/build Pipeline was successful
① 修复 message 解构错误(导致「undefined is not an object (evaluating 't.warning')」):
useMessage() 返回的是 { toasts, success, error, info, warning, removeToast } 对象本身,
而 HomeView 与 LeaveView 写成了 const { message } = useMessage(),解构出的 message 恒为 undefined,
所有 message.xxx() 调用都会抛错。改为 const message = useMessage()(其余 18 个文件均为正确写法)。
② 二维码改为前端 jsQR 解析(问题 3、4):
上传二维码时不再走后端解析,直接在前端用 jsQR 从画布像素解码(多尺度尝试 + attemptBoth),
提取 uuid 存表;前端识别失败时降级调用后端 /task/qr/decode(gozxing 多尺度重试)兜底。
好处:免上传、免压缩、响应快;且能正确解析 https://...?uuid=xxx&isNeedExtra=0&schoolId=... 这类链接。
新增依赖 jsqr@^1.4.0。
③ 图片上传前前端压缩到 ≤300KB(问题 4):
签到照片等需上传 OBS 的图片,先经 canvas 逐步降质量/降尺寸压缩到 300KB 以下再上传,
避免大图(如 4032x3024)直传。二维码字段不走压缩(不保存图片,只存 uuid)。
④ 二维码上传增加 loading(问题 1):
原二维码分支从未设置 uploadingImage,故无 loading 效果。现统一走 uploadingImage + uploadingLabel
(解析中/压缩中/上传中),二维码与图片两种字段的 loading 块都补上阶段文案。
⑤ 验证:vue-tsc --noEmit、npm run build 通过。不检查部署。
This commit is contained in:
Generated
+6
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string> {
|
||||
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<string> {
|
||||
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<File> {
|
||||
if (file.size <= maxBytes) return file
|
||||
const dataUrl = await new Promise<string>((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<HTMLImageElement | null>((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<Blob | null>((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<string>((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) {
|
||||
<!-- 上传中:loading 动画 -->
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="w-24 h-24 flex items-center justify-center border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-[10px]">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<!-- 已有图片:显示缩略图,点击替换 -->
|
||||
<img
|
||||
@@ -1483,9 +1613,10 @@ function getGPSLocation(source: string) {
|
||||
<!-- 上传中:loading 动画 -->
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="w-24 h-24 flex items-center justify-center border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-[10px]">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
|
||||
@@ -628,7 +628,7 @@ import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
|
||||
const { message } = useMessage()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
/** 跳转工单页面(无法请/销假时提交工单反馈) */
|
||||
|
||||
Reference in New Issue
Block a user