feat: 接入腾讯云cos上传

This commit is contained in:
shawko 2026-05-12 11:15:18 +08:00
parent 3e3525aee1
commit dd9000bf48
16 changed files with 319 additions and 70 deletions

View File

@ -28,6 +28,7 @@
"axios": "^1.7.2",
"chart.js": "^4.4.3",
"compare-versions": "^6.1.1",
"cos-js-sdk-v5": "^1.10.1",
"dayjs": "^1.11.12",
"echarts": "^5.5.1",
"element-plus": "^2.7.8",

23
pnpm-lock.yaml generated
View File

@ -47,6 +47,9 @@ importers:
compare-versions:
specifier: ^6.1.1
version: 6.1.1
cos-js-sdk-v5:
specifier: ^1.10.1
version: 1.10.1
dayjs:
specifier: ^1.11.12
version: 1.11.13
@ -1517,6 +1520,9 @@ packages:
core-js@3.41.0:
resolution: {integrity: sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==}
cos-js-sdk-v5@1.10.1:
resolution: {integrity: sha512-a4SRfCY5g6Z35C7OWe9te/S1zk77rVQzfpvZ33gmTdJQzKxbNbEG7Aw/v453XwVMsQB352FIf7KRMm5Ya/wlZQ==}
cose-base@1.0.3:
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
@ -2098,6 +2104,10 @@ packages:
fast-uri@3.0.6:
resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==}
fast-xml-parser@4.5.0:
resolution: {integrity: sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==}
hasBin: true
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
@ -3347,6 +3357,9 @@ packages:
strip-literal@2.1.1:
resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==}
strnum@1.1.2:
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
@ -5087,6 +5100,10 @@ snapshots:
core-js@3.41.0: {}
cos-js-sdk-v5@1.10.1:
dependencies:
fast-xml-parser: 4.5.0
cose-base@1.0.3:
dependencies:
layout-base: 1.0.2
@ -5806,6 +5823,10 @@ snapshots:
fast-uri@3.0.6: {}
fast-xml-parser@4.5.0:
dependencies:
strnum: 1.1.2
fastq@1.19.1:
dependencies:
reusify: 1.1.0
@ -7172,6 +7193,8 @@ snapshots:
dependencies:
js-tokens: 9.0.1
strnum@1.1.2: {}
stylis@4.3.6: {}
supports-color@7.2.0:

View File

@ -1,6 +1,7 @@
allowBuilds:
'@parcel/watcher': true
core-js: false
cos-js-sdk-v5: false
es5-ext: false
esbuild: true
vue-demi: true

View File

@ -1,6 +1,7 @@
import str from '@/utils/str'
import { http } from './http'
import { get } from './request'
import { toCosUploadResponse, uploadFileToCos } from '@/utils/cosUpload'
export type Option = {
value: any
@ -199,16 +200,19 @@ const globalApi = {
},
// 上传文件
uploadFile: (data: any) =>
http.request({
url: '/sysFileInfo/tenUploadAll',
method: 'post',
timeout: 15000,
headers: {
'content-type': 'multipart/form-data',
uploadFile: async (data: any) => {
const file = data instanceof FormData ? data.get('file') : data?.file
const fileType =
Number(data instanceof FormData ? data.get('fileType') : data?.fileType) === 1 ? 1 : 2
if (!(file instanceof Blob)) {
throw new Error('请选择要上传的文件')
}
const result = await uploadFileToCos(file, {
fileType,
fileName: file instanceof File ? file.name : undefined,
})
return toCosUploadResponse(result)
},
data,
}),
// 上报课堂监控截图
reportSnapshot: (data: { sessionId: string; url: string; userId: string }) =>

View File

@ -103,7 +103,11 @@ instance.interceptors.response.use(
}
// 文件流不拦截
if (response.headers['content-type']?.indexOf('octet-stream') !== -1 || code == 200)
if (
response.headers['content-type']?.indexOf('octet-stream') !== -1 ||
code == 200 ||
code == 0
)
return response
return Promise.reject(buildError(response))

View File

@ -167,7 +167,7 @@ function handleDel(url: string) {
<el-upload
v-if="!reportFlag && canAddMore"
class="start-btn"
action="/api/main/sysFileInfo/tenUploadAll"
action="#"
:data="{ type: 1 }"
:headers="{
Authorization: token,

View File

@ -66,7 +66,7 @@ function handleShow(url: string, isTeacher = false) {
:style="{
'background-image': `url(${OSS_URL}/urm/homeWork/add-image-btn.png)`,
}"
action="/api/main/sysFileInfo/tenUploadAll"
action="#"
:data="{ type: 1 }"
:headers="{
Authorization: token,
@ -100,7 +100,7 @@ function handleShow(url: string, isTeacher = false) {
<!-- 答题 -->
<el-upload
class="upload-demo"
action="/api/main/sysFileInfo/tenUploadAll"
action="#"
:data="{ type: 1 }"
:headers="{
Authorization: token,

View File

@ -3,8 +3,8 @@ import { storeToRefs } from 'pinia'
import globalApi from '@/api/global'
import { userStore } from '@/stores'
import { $XXL, getNativeApi } from '@/utils'
import sass from '@/utils/sass'
import { useTips } from './useTips'
import { uploadFileToCos, uploadNativeFilePathToCos } from '@/utils/cosUpload'
let monitorTimer: ReturnType<typeof setInterval> | null = null
let monitorSessionId = ''
@ -76,7 +76,6 @@ const parseNativeScreenshotPath = (raw: unknown): string => {
const uploadNativeScreenshot = async () => {
try {
const { token } = storeToRefs(userStore())
const raw = $XXL.getScreenshot() || {}
monitorLog('native getScreenshot result', raw)
const filePath = parseNativeScreenshotPath(raw)
@ -86,14 +85,8 @@ const uploadNativeScreenshot = async () => {
}
monitorLog('native screenshot path', filePath)
const res = await $XXL.uploadFile({
url: `${import.meta.env.VITE_SERVER_URL}/sysFileInfo/tenUploadAll`,
header: {
Authorization: token.value,
'X-Tenant-Id': sass.platform.tenantId,
},
filePath,
fileKey: 'file',
const res = await uploadNativeFilePathToCos(filePath, config => $XXL.uploadFile(config), {
fileType: 1,
})
const url = normalizeUploadPath(res)
monitorLog('native upload result', { raw: res, url })
@ -234,9 +227,7 @@ const uploadWebScreenshot = async () => {
return ''
}
const formData = new FormData()
formData.append('file', file)
const { data: res } = await globalApi.uploadFile(formData)
const res = await uploadFileToCos(file, { fileType: 1 })
const url = normalizeUploadPath(res)
monitorLog('web upload result', { raw: res, url })
return url

View File

@ -10,6 +10,7 @@ import {
resolveMonitorWsType,
stopClassMonitor,
} from './useClassMonitor'
import { uploadNativeFilePathToCos } from '@/utils/cosUpload'
let SOCKET: UseWebSocketReturn<any>
@ -192,13 +193,8 @@ export const useSocket = () => {
simSerialNumber: SN,
msg: '',
})
const res = await $XXL.uploadFile({
url: `${import.meta.env.VITE_SERVER_URL}/sysFileInfo/tenUploadAll`,
header: {
Authorization: token,
},
filePath,
fileKey: 'file',
const res = await uploadNativeFilePathToCos(filePath, config => $XXL.uploadFile(config), {
fileType: 1,
})
sendMsg({
type: 'desktopScreenshotImage',

View File

@ -4,6 +4,7 @@ import { userStore } from '@/stores'
import { XMessage } from '@/hooks'
import dayjs from 'dayjs'
import sass from '@/utils/sass'
import { cosUploadRequest } from '@/utils/cosUpload'
const { token } = userStore()
const OSS_URL = import.meta.env.VITE_OSS_URL
@ -122,7 +123,7 @@ onActivated(() => {
<div v-else class="upload-btn">
<el-upload
class="add-image-btn pointer"
action="/api/main/sysFileInfo/tenUploadAll"
action="#"
:data="{ type: 1 }"
:headers="{
Authorization: token,
@ -130,6 +131,7 @@ onActivated(() => {
}"
accept="image/*"
:before-upload="handleBeforeUpload"
:http-request="cosUploadRequest"
:on-success="handleUploadSuccess"
:show-file-list="false"
:disabled="uploadLoading"

View File

@ -9,7 +9,7 @@ import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { $XXL, getDurationText } from '@/utils'
import { userStore, groupPaperStore } from '@/stores'
import { groupPaperStore } from '@/stores'
import {
resource_type_ai,
resource_type_analogy,
@ -25,8 +25,8 @@ import type { IQuestion } from '@/types/question'
import { globalBack } from '@/router'
import { dialog } from '@/components/mj/mj-dialog'
import hud from '@/utils/hud'
import sass from '@/utils/sass'
import homeworkApi, { saveRecordAnswer, submitExam } from '@/api/subject/homework'
import { uploadNativeFilePathToCos } from '@/utils/cosUpload'
const emit = defineEmits(['urgeSubmit'])
@ -34,7 +34,6 @@ defineOptions({
name: 'DoWork',
})
const { token } = userStore()
const { groupPaperParams } = toRefs(groupPaperStore())
const resourceTypeConfig: Record<string, string> = {
@ -247,15 +246,7 @@ async function uploadFile(options: anyObj) {
if (options.isAndroid) {
if (options.submitAnswerPicOriginal) {
const res = await withUploadTimeout(
$XXL.uploadFile({
url: `${import.meta.env.VITE_SERVER_URL}/sysFileInfo/tenUploadAll`,
header: {
Authorization: token,
'X-Tenant-Id': sass.platform.tenantId,
},
filePath: options.file,
fileKey: 'file',
}),
uploadNativeFilePathToCos(options.file, config => $XXL.uploadFile(config)),
)
path = extractUploadPath(res)
}
@ -986,7 +977,7 @@ defineExpose({
$filter: brightness(0) saturate(0) invert(0) sepia(0) hue-rotate(30deg);
.dowork {
// background: rgb(163, 175, 199);
background: #fff;
position: relative;
height: 100%;
overflow: hidden;

View File

@ -6,6 +6,7 @@ import { XMessage, debounce } from '@/hooks'
import homeWorkApi from '@/api/system/homeWork'
import LoadIcon from '@/page/system/mailbox/components/LoadIcon.vue'
import sass from '@/utils/sass'
import { cosUploadRequest } from '@/utils/cosUpload'
const router = useRouter()
const { token } = userStore()
@ -143,7 +144,7 @@ onMounted(async () => {
<div>
<el-upload
class="pointer"
action="/api/main/sysFileInfo/tenUploadAll"
action="#"
:data="{ type: 1 }"
:headers="{
Authorization: token,
@ -151,6 +152,7 @@ onMounted(async () => {
}"
accept="image/*"
:before-upload="handleBeforeUpload"
:http-request="cosUploadRequest"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:show-file-list="false"

View File

@ -7,10 +7,10 @@ import dayjs from 'dayjs'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { $XXL, getDurationText } from '@/utils'
import { loading } from '@/components/mj/mj-loading'
import { userStore } from '@/stores'
import type { IQuestion } from '@/types/question'
import { globalBack } from '@/router'
import { dialog } from '@/components/mj/mj-dialog'
import { uploadNativeFilePathToCos } from '@/utils/cosUpload'
defineOptions({
name: 'HomeWorkDoWork',
})
@ -21,8 +21,6 @@ type PaperInfo = {
currentTitleId: string
}
const { token } = userStore()
const router = useRouter()
const route = useRoute()
@ -123,14 +121,7 @@ async function uploadFile(options: anyObj) {
let path = ''
if (options.isAndroid) {
if (options.submitAnswerPicOriginal) {
const res = await $XXL.uploadFile({
url: `${import.meta.env.VITE_SERVER_URL}/sysFileInfo/tenUploadAll`,
header: {
Authorization: token,
},
filePath: options.file,
fileKey: 'file',
})
const res = await uploadNativeFilePathToCos(options.file, config => $XXL.uploadFile(config))
path = res.url
}
} else {

View File

@ -6,6 +6,7 @@ import { XMessage, debounce } from '@/hooks'
import homeWorkApi from '@/api/system/homeWork'
import LoadIcon from '@/page/system/mailbox/components/LoadIcon.vue'
import sass from '@/utils/sass'
import { cosUploadRequest } from '@/utils/cosUpload'
const router = useRouter()
const { token } = userStore()
@ -143,7 +144,7 @@ onMounted(async () => {
<div>
<el-upload
class="pointer"
action="/api/main/sysFileInfo/tenUploadAll"
action="#"
:data="{ type: 1 }"
:headers="{
Authorization: token,
@ -151,6 +152,7 @@ onMounted(async () => {
}"
accept="image/*"
:before-upload="handleBeforeUpload"
:http-request="cosUploadRequest"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:show-file-list="false"

View File

@ -16,6 +16,7 @@ import { compareVersions } from 'compare-versions'
import { userStore } from '@/stores'
import VConsole from 'vconsole'
import { XMessage } from '@/hooks'
import { uploadNativeFilePathToCos } from '@/utils/cosUpload'
import hud from '@/utils/hud'
interface App {
@ -169,17 +170,11 @@ export const globalStore = defineStore('global', () => {
})
if ((res?.appInfoList || []).length === 0) return
const unApps = res.appInfoList
const { token } = userStore()
for (const i of unApps) {
const app = apps.find((a: App) => a.pkName === i.pkName)
const logoRes = await $XXL.uploadFile({
url: `${import.meta.env.VITE_SERVER_URL}/sysFileInfo/tenUploadAll`,
header: {
Authorization: token,
},
filePath: app.iconPath,
fileKey: 'file',
})
const logoRes = await uploadNativeFilePathToCos(app.iconPath, config =>
$XXL.uploadFile(config),
)
globalApi.updateAppInfo([
{
pkName: i.pkName,

246
src/utils/cosUpload.ts Normal file
View File

@ -0,0 +1,246 @@
import COS from 'cos-js-sdk-v5'
import { post } from '@/api/request'
export interface CosStsData {
bucket: string
durationSeconds: number
expireAt: number
region: string
sessionToken: string
tmpSecretId: string
tmpSecretKey: string
uploadPathPrefix: string
}
export interface CosUploadResult {
bucket: string
filePath: string
key: string
region: string
url: string
}
type UploadProgress = (event: { percent: number }) => void
type NativeUpload = (config: anyObj) => Promise<any>
export type CosFileType = 1 | 2
const stsCache: Partial<Record<CosFileType, CosStsData>> = {}
const stsPromise: Partial<Record<CosFileType, Promise<CosStsData>>> = {}
const normalizePrefix = (prefix = '') => prefix.replace(/^\/+/, '').replace(/\/?$/, '/')
const getFileExt = (fileName = '', type = '') => {
const ext = fileName.match(/\.([a-zA-Z0-9]+)$/)?.[1]
if (ext) return ext.toLowerCase()
if (type.includes('/')) return type.split('/').pop() || 'png'
return 'png'
}
const getFileName = (file: File | Blob, fileName?: string) => {
const rawName = fileName || (file instanceof File ? file.name : '')
const ext = getFileExt(rawName, file.type)
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`
}
const buildKey = (sts: CosStsData, file: File | Blob, fileName?: string) => {
const date = new Date()
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
return `${normalizePrefix(sts.uploadPathPrefix)}${yyyy}${mm}${dd}/${getFileName(file, fileName)}`
}
const getCosHost = (sts: CosStsData) => `${sts.bucket}.cos.${sts.region}.myqcloud.com`
const normalizeCosUrl = (location: string, sts: CosStsData, key: string) => {
const url = location || `${getCosHost(sts)}/${key}`
return /^https?:\/\//i.test(url) ? url.replace(/^http:\/\//i, 'https://') : `https://${url}`
}
const normalizeStsTime = (value: number) => (value > 9999999999 ? Math.floor(value / 1000) : value)
const normalizeSts = (sts: CosStsData): CosStsData => ({
...sts,
expireAt: normalizeStsTime(Number(sts.expireAt) || 0),
})
const isValidSts = (sts: CosStsData | undefined) => {
if (!sts) return false
const now = Math.floor(Date.now() / 1000)
return (
!!sts.bucket && !!sts.region && !!sts.tmpSecretId && !!sts.tmpSecretKey && sts.expireAt > now
)
}
export const getCosSts = async (fileType: CosFileType = 2) => {
if (isValidSts(stsCache[fileType])) return stsCache[fileType] as CosStsData
if (!stsPromise[fileType]) {
stsPromise[fileType] = post<CosStsData>('/common/cos/sts', { fileType }).then(res => {
const normalized = normalizeSts(res)
stsCache[fileType] = normalized
return normalized
})
stsPromise[fileType]?.finally(() => {
stsPromise[fileType] = undefined
})
}
return stsPromise[fileType] as Promise<CosStsData>
}
const createCos = (fileType: CosFileType) =>
new COS({
Protocol: 'https:',
getAuthorization: async (_options, callback) => {
const sts = await getCosSts(fileType)
const now = Math.floor(Date.now() / 1000)
callback({
TmpSecretId: sts.tmpSecretId,
TmpSecretKey: sts.tmpSecretKey,
SecurityToken: sts.sessionToken,
StartTime: Math.max(now - 60, sts.expireAt - sts.durationSeconds),
ExpiredTime: sts.expireAt,
})
},
})
export const uploadFileToCos = async (
file: File | Blob,
options: { fileName?: string; fileType?: CosFileType; onProgress?: UploadProgress } = {},
): Promise<CosUploadResult> => {
const fileType = options.fileType || 2
const sts = await getCosSts(fileType)
const key = buildKey(sts, file, options.fileName)
const result = await createCos(fileType).putObject({
Bucket: sts.bucket,
Region: sts.region,
Key: key,
Body: file,
ContentType: file.type || undefined,
onProgress: progress => {
options.onProgress?.({ percent: Math.round((progress.percent || 0) * 100) })
},
})
const url = normalizeCosUrl(result.Location, sts, key)
return {
bucket: sts.bucket,
filePath: url,
key,
region: sts.region,
url,
}
}
const toHex = (buffer: ArrayBuffer) =>
Array.from(new Uint8Array(buffer))
.map(byte => byte.toString(16).padStart(2, '0'))
.join('')
const sha1 = async (value: string) => {
const data = new TextEncoder().encode(value)
return toHex(await crypto.subtle.digest('SHA-1', data))
}
const hmacSha1 = async (key: string, value: string) => {
const cryptoKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(key),
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign'],
)
return toHex(await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(value)))
}
const encodeBase64 = (value: string) => {
const bytes = new TextEncoder().encode(value)
let binary = ''
bytes.forEach(byte => {
binary += String.fromCharCode(byte)
})
return btoa(binary)
}
const createPostObjectFields = async (sts: CosStsData, key: string) => {
const now = Math.floor(Date.now() / 1000)
const start = Math.max(now - 60, sts.expireAt - sts.durationSeconds)
const keyTime = `${start};${sts.expireAt}`
const policy = encodeBase64(
JSON.stringify({
expiration: new Date(sts.expireAt * 1000).toISOString(),
conditions: [
{ bucket: sts.bucket },
{ key },
{ 'q-sign-algorithm': 'sha1' },
{ 'q-ak': sts.tmpSecretId },
{ 'q-sign-time': keyTime },
{ 'x-cos-security-token': sts.sessionToken },
],
}),
)
const signKey = await hmacSha1(sts.tmpSecretKey, keyTime)
const signature = await hmacSha1(signKey, await sha1(policy))
return {
key,
policy,
'q-sign-algorithm': 'sha1',
'q-ak': sts.tmpSecretId,
'q-key-time': keyTime,
'q-signature': signature,
'x-cos-security-token': sts.sessionToken,
}
}
export const uploadNativeFilePathToCos = async (
filePath: string,
nativeUpload: NativeUpload,
options: { fileName?: string; fileType?: CosFileType; mimeType?: string } = {},
): Promise<CosUploadResult> => {
const sts = await getCosSts(options.fileType || 2)
const fileName = options.fileName || filePath.split(/[\\/]/).pop() || ''
const key = buildKey(sts, new Blob([], { type: options.mimeType || 'image/png' }), fileName)
const formData = await createPostObjectFields(sts, key)
await nativeUpload({
url: `https://${getCosHost(sts)}`,
header: {},
data: formData,
filePath,
fileKey: 'file',
formData,
})
const url = normalizeCosUrl('', sts, key)
return {
bucket: sts.bucket,
filePath: url,
key,
region: sts.region,
url,
}
}
export const toCosUploadResponse = (result: CosUploadResult) => ({
data: {
code: 200,
data: result,
message: '',
},
})
export const cosUploadRequest = async (options: anyObj) => {
try {
const result = await uploadFileToCos(options.file, {
fileType: options.data?.fileType || 2,
fileName: options.file?.name,
onProgress: event => options.onProgress?.(event),
})
const response = {
code: 200,
data: result,
message: '',
}
options.onSuccess?.(response)
return response
} catch (error) {
options.onError?.(error)
throw error
}
}