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",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reka-ui": "^2.10.4",
|
"reka-ui": "^2.10.4",
|
||||||
@@ -1954,6 +1955,11 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.33.0",
|
"version": "1.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reka-ui": "^2.10.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 { 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 { Button } from '@/components/ui/button'
|
||||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||||
|
import jsQR from 'jsqr'
|
||||||
import * as authApi from '@/api/auth'
|
import * as authApi from '@/api/auth'
|
||||||
import { CPD_PASSWORD_ERROR } from '@/api'
|
import { CPD_PASSWORD_ERROR } from '@/api'
|
||||||
import { useMessage } from '@/composables/useMessage'
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
|
||||||
const { message } = useMessage()
|
const message = useMessage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
@@ -344,54 +345,182 @@ function authHeaders(): Record<string, string> {
|
|||||||
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
|
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(不经过服务器中转),成功后立即保存表单 ========
|
// ======== 图片上传 → 浏览器直传到今日校园 OBS(不经过服务器中转),成功后立即保存表单 ========
|
||||||
const uploadingImage = ref(false)
|
const uploadingImage = ref(false)
|
||||||
|
const uploadingLabel = ref('上传中...')
|
||||||
async function handleImageUpload(index: number, isEdit: boolean) {
|
async function handleImageUpload(index: number, isEdit: boolean) {
|
||||||
if (uploadingImage.value) return
|
if (uploadingImage.value) return
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
input.accept = 'image/*'
|
input.accept = 'image/*'
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
const file = input.files?.[0]
|
const picked = input.files?.[0]
|
||||||
if (!file) return
|
if (!picked) return
|
||||||
const taskType = isEdit ? (editTask.value?.taskType || 'sign') : (selectedItem.value?.taskType || 'sign')
|
const taskType = isEdit ? (editTask.value?.taskType || 'sign') : (selectedItem.value?.taskType || 'sign')
|
||||||
const items = isEdit ? editFormItems.value : formItems.value
|
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 被当成正式值保存入库
|
// 记录原值:任何失败路径都回滚,避免本地预览的 base64 被当成正式值保存入库
|
||||||
const prevContent = items[index].content || ''
|
const prevContent = items[index].content || ''
|
||||||
|
|
||||||
// 先显示本地预览(仅界面展示,上传成功后会覆盖为 OBS 正式 URL)
|
// ---- 二维码字段:前端 jsQR 直接解析出 uuid,不上传、不压缩 ----
|
||||||
const reader = new FileReader()
|
if (isQrField(items[index])) {
|
||||||
reader.onload = (e) => {
|
uploadingImage.value = true
|
||||||
items[index].content = (e.target?.result as string) || ''
|
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
|
uploadingImage.value = true
|
||||||
|
uploadingLabel.value = '压缩中...'
|
||||||
try {
|
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 上传策略(仅获取凭证,不传文件)
|
// Step 1: 从服务器获取 OBS 上传策略(仅获取凭证,不传文件)
|
||||||
const policyRes = await fetch('/api/task/upload-policy', {
|
const policyRes = await fetch('/api/task/upload-policy', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1291,9 +1420,10 @@ function getGPSLocation(source: string) {
|
|||||||
<!-- 上传中:loading 动画 -->
|
<!-- 上传中:loading 动画 -->
|
||||||
<div
|
<div
|
||||||
v-if="uploadingImage"
|
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" />
|
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||||
|
<span class="text-[10px]">{{ uploadingLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 已有图片:显示缩略图,点击替换 -->
|
<!-- 已有图片:显示缩略图,点击替换 -->
|
||||||
<img
|
<img
|
||||||
@@ -1483,9 +1613,10 @@ function getGPSLocation(source: string) {
|
|||||||
<!-- 上传中:loading 动画 -->
|
<!-- 上传中:loading 动画 -->
|
||||||
<div
|
<div
|
||||||
v-if="uploadingImage"
|
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" />
|
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||||
|
<span class="text-[10px]">{{ uploadingLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
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 { useMessage } from '@/composables/useMessage'
|
||||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||||
|
|
||||||
const { message } = useMessage()
|
const message = useMessage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
/** 跳转工单页面(无法请/销假时提交工单反馈) */
|
/** 跳转工单页面(无法请/销假时提交工单反馈) */
|
||||||
|
|||||||
Reference in New Issue
Block a user