v1.2.61:二维码改前端解析(jsQR)+ 图片上传前压缩 + 修复 message 解构错误 + 上传 loading
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:
becomer
2026-09-18 16:36:37 +08:00
parent 6e145f75e1
commit 0bd354cfee
4 changed files with 172 additions and 34 deletions
+6
View File
@@ -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",
+1
View File
@@ -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",
+164 -33
View File
@@ -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'))"
+1 -1
View File
@@ -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()
/** 跳转工单页面(无法请/销假时提交工单反馈) */ /** 跳转工单页面(无法请/销假时提交工单反馈) */