diff --git a/package.json b/package.json index ff8681a..cca1dad 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1e455e..2ccde22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68d36a3..fde1d8e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ allowBuilds: '@parcel/watcher': true core-js: false + cos-js-sdk-v5: false es5-ext: false esbuild: true vue-demi: true diff --git a/src/api/global.ts b/src/api/global.ts index 8a5d058..7c24ab9 100644 --- a/src/api/global.ts +++ b/src/api/global.ts @@ -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', - }, - 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) + }, // 上报课堂监控截图 reportSnapshot: (data: { sessionId: string; url: string; userId: string }) => diff --git a/src/api/http.ts b/src/api/http.ts index e52d85c..4633bf6 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -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)) diff --git a/src/components/question/answer/CameraPreview.vue b/src/components/question/answer/CameraPreview.vue index b955fd1..f1f9269 100644 --- a/src/components/question/answer/CameraPreview.vue +++ b/src/components/question/answer/CameraPreview.vue @@ -167,7 +167,7 @@ function handleDel(url: 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; diff --git a/src/page/system/homeWork/temporary/index.vue b/src/page/system/homeWork/temporary/index.vue index 116b3bd..bb8cde9 100644 --- a/src/page/system/homeWork/temporary/index.vue +++ b/src/page/system/homeWork/temporary/index.vue @@ -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 () => {
$XXL.uploadFile(config)) path = res.url } } else { diff --git a/src/page/system/homework copy/temporary/index.vue b/src/page/system/homework copy/temporary/index.vue index 116b3bd..bb8cde9 100644 --- a/src/page/system/homework copy/temporary/index.vue +++ b/src/page/system/homework copy/temporary/index.vue @@ -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 () => {
{ }) 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, diff --git a/src/utils/cosUpload.ts b/src/utils/cosUpload.ts new file mode 100644 index 0000000..ad012e0 --- /dev/null +++ b/src/utils/cosUpload.ts @@ -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 +export type CosFileType = 1 | 2 + +const stsCache: Partial> = {} +const stsPromise: Partial>> = {} + +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('/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 +} + +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 => { + 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 => { + 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 + } +}