// common/js/media-upload.js // ai-chat / ai-agent 媒体上传: 图片 (PHP 中转 + STS 直传) / 音视频 / 其他文件 // // PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 之前 ai-chat-tools-panel.vue 引用了 // uploadImage / uploadMedia, 但本文件从未落库, 选图 / 选文件立即抛 // "Cannot find module '@/common/js/media-upload.js'". 此次按 // docs/ai-agent-integration.md §3.4 Step 1 落地, 顺手补 store import (设计稿漏). // // 调用方: // ai-chat-tools-panel.vue → selectImage() / _uploadOneFile() // ai-chat-message.vue → 直接用 aiService.sendMessage 传 attachments (本模块不出场) // // 上传策略: // 图片: 优先 PHP 中转 (≤2MB 走 backend), 超过或商家选了 oss_sts/auto → STS 直传 // 音视频/文件: 一律 STS 直传 (体积大概率 >2MB) // // 端点: // PHP 中转: POST //api/upload/ multipart/form-data // STS 凭证: POST //api/upload/ossSts application/json // STS 直传: PUT / 云存储协议 (4-field STS 或 single-URL) import aiService from './ai-service.js' import Config from './config.js' import store from '@/store/index.js' const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB const SIZE_LIMIT_H5_COMPRESS = 3 * 1024 * 1024 // 3MB (从 10MB 下调,移动端 H5 体验更流畅) // PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): WB Agent J7 inline base64 上限 5MB // 与 backend WbAgentOsClient::INLINE_MEDIA_LIMIT = 5242880 对齐 // 超过这个 size 后 buildMediaBlocks 会改走 URL fetch 路径, 上限失效 const SIZE_LIMIT_INLINE_BASE64 = 5 * 1024 * 1024 // 5MB (与后端 INLINE_MEDIA_LIMIT 对齐) // PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 现代格式 mime 同步后端 guessMimeFromPath // STS 直传绕过后端 mime 推断, 前端必须自带, 否则附件 mime 降级 application/octet-stream const EXT_MIME_MAP = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', heic: 'image/heic', heif: 'image/heif', avif: 'image/avif', bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff', mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac', m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/opus', amr: 'audio/amr', wma: 'audio/x-ms-wma', aiff: 'audio/aiff', aif: 'audio/aiff', ape: 'audio/x-ape', mid: 'audio/midi', mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv', m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp', mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', zip: 'application/zip', rar: 'application/x-rar-compressed', '7z': 'application/x-7z-compressed', json: 'application/json', xml: 'application/xml', md: 'text/markdown', pdf: 'application/pdf', txt: 'text/plain', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', } /** * 上传图片 (type=image) * @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0] * @returns {Promise<{url, mime, size, name, kind, used_strategy}>} */ export async function uploadImage(filePath, opts = {}) { const addon = aiService.getAddonName() // 'aikefu' | 'aiagent' const config = await aiService.getServiceConfig() const strategy = config?.upload_strategy || 'backend' // 压缩: H5/MP-WEIXIN 双端都做, iPhone HEIC 大图走不通 PHP 中转也走不通 STS, 必须本地压 let compressedPath = filePath let detectedSize = 0 try { detectedSize = await getFileSize(filePath) } catch (e) { // PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测失败不静默降级到 0 (会让 auto 模式永远走 PHP 中转) // 改为抛错, 上层选择兜底走 STS (size=0 → 大于任何阈值 → 走 STS) console.warn('[media-upload] getFileSize failed:', e) } // H5 + MP-WEIXIN 都压缩 (微信 chooseImage 默认 sizeType:['compressed'] 但 HEIC 不在压缩白名单) // #ifdef H5 || MP-WEIXIN if (detectedSize > SIZE_LIMIT_H5_COMPRESS || opts.forceCompress) { try { const compressed = await uni.compressImage({ src: filePath, quality: 80, compressedWidth: 2048, compressedHeight: 2048, }) compressedPath = compressed.tempFilePath || filePath console.log('[media-upload] compressImage OK', detectedSize, '→', compressedPath) } catch (e) { console.warn('[media-upload] compressImage failed, use original:', e) } } // #endif // 检查是否需要走 STS (>2MB 或商家选 oss_sts) // 决策: STS 触发条件 = oss_sts(总是) || auto + 大于 2MB // fileSizeFinal = 探测失败的兜底: // - 第一次探测失败(原始大小未知) → 保守走 STS // - 第一次成功, 第二次探测压缩后失败 → 用原始 detectedSize let fileSizeFinal = detectedSize if (compressedPath !== filePath) { try { fileSizeFinal = await getFileSize(compressedPath) } catch (e) { // 压缩后探测失败, 退回到原始大小 (可能没压成功) fileSizeFinal = detectedSize } } const useSts = strategy === 'oss_sts' || (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH) // ★ PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测完全失败(原始+压缩后都 0) → 保守走 STS // 原代码 fileSizeFinal === 0 会让"auto 模式永远走 PHP 中转", 大文件 OOM // 但 fileSizeFinal = detectedSize (有值) 时按值判断; 只有两边都失败才兜底 STS const totallyUnknown = detectedSize === 0 && fileSizeFinal === 0 const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown) if (finalUseSts) { return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize) } else { return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize) } } /** * 上传 audio / video / file * 策略: 商家配了云存储 (oss_sts/auto) → STS 直传, 失败回退 PHP 中转; * 未配云存储 (backend) → 直接 PHP 中转 * @param {string} filePath * @param {'audio'|'video'|'file'} kind * @param {{name?:string, mime?:string, size?:number}} [opts] * PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传进来的是 blob URL (无扩展名), * 必须由调用方把原始 File 的 name/mime/size 带进来, 否则 STS key 会以 .bin 结尾, * Content-Type 退化成 application/octet-stream, 音频/视频播放器取不到正确类型。 */ export async function uploadMedia(filePath, kind, opts = {}) { if (!['audio', 'video', 'file'].includes(kind)) { throw new Error(`invalid kind: ${kind}`) } const addon = aiService.getAddonName() const config = await aiService.getServiceConfig() const agentId = aiService.getAgentId() const hints = { name: opts.name || '', mime: opts.mime || '' } const size = opts.size || 0 const strategy = (config && config.upload_strategy) || 'backend' // PHP 中转 (kind 以调用方传入的音视频分类为准, 后端只区分 image|file) const doPhpUpload = async (fallbackErr) => { let phpRes = null try { phpRes = await uploadViaPhp(filePath, kind, addon, agentId, size) } catch (err) { throw new Error((err && err.message) || (fallbackErr && fallbackErr.message) || '上传失败') } if (!phpRes || !phpRes.url) { throw new Error((fallbackErr && fallbackErr.message) || '上传失败: 当前平台不支持该上传方式') } return Object.assign({}, phpRes, { kind }) } // 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转, // 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用 if (strategy === 'backend') { return await doPhpUpload() } try { return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints) } catch (e) { // PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败) // 时回退 PHP 中转, 保证音频/视频文件仍能上传 console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message) return await doPhpUpload(e) } } /** * PHP 中转 (multipart/form-data) * 端点必须用 addon 模式 URL: //api/upload/ * 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下, * /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中 * (那是普通接口命名空间, 上传走的是 addon 专属路由) */ async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) { const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + (kind === 'image' ? 'image' : 'file') // #ifdef MP-WEIXIN return new Promise((resolve, reject) => { uni.uploadFile({ url: Config.baseUrl + url, filePath, name: 'file', formData: { kind, // aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底 agent_id: agentId || undefined, config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, uniacid: aiService.getUniacid(), user_id: aiService.getUserId?.() || '', }, success: (res) => { try { const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data if (data.code === 0 || data.success) { resolve({ url: data.data.url, mime: data.data.mime, size: data.data.size || knownSize || 0, name: data.data.name, kind: data.data.kind || kind, used_strategy: data.data.used_strategy || 'backend', oss_sts_required: data.data.oss_sts_required || false, }) } else { reject(new Error(data.msg || data.message || '上传失败')) } } catch (e) { reject(new Error('解析响应失败: ' + e.message)) } }, fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))), }) }) // #endif // #ifdef H5 const form = new FormData() const file = await pathToFile(filePath, kind) form.append('file', file) form.append('kind', kind) if (addon === 'aiagent') form.append('agent_id', agentId) else { // aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底 const uuid = aiService.getCurrentConfigUuid?.() || '' form.append('config_uuid', uuid) form.append('config_uid', uuid) form.append('agent_id', agentId) } form.append('uniacid', aiService.getUniacid()) form.append('user_id', aiService.getUserId?.() || '') const res = await fetch(Config.baseUrl + url, { method: 'POST', headers: { 'token': store.state.token || '' }, body: form, }) const data = await res.json() if (data.code === 0 || data.success) { return { url: data.data.url, mime: data.data.mime, size: data.data.size || knownSize || 0, name: data.data.name, kind: data.data.kind || kind, used_strategy: data.data.used_strategy || 'backend', oss_sts_required: data.data.oss_sts_required || false, } } throw new Error(data.msg || data.message || '上传失败') // #endif } /** * STS 直传 (拿临时凭证 → 直传云存储) * * PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类: * A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) * → 前端拼 PUT URL, Authorization header = STS : * B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio) * → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='-SIGNED' * 标记, 不带 Authorization * * 后端 stsResp.data.data 可能字段: * { provider, bucket, region, accessKeyId, accessKeySecret, securityToken, * key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) } */ /** * PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 三段兜底扩展名提取 * 1) filePath 最后一段正则匹配 .xxx * 2) filePath.name 兜底 (H5 File 对象 / APP tempFile.name) * 3) mimeHint 反查 EXT_MIME_MAP (H5 blob URL 没扩展名但 blob.type 通常是 image/png) * 4) 最终 'bin' (防 URL 片段被误当扩展名) * * PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层, * 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined" */ function extractExt(filePath, mimeHint = '', nameHint = '') { let ext = 'bin' if (typeof filePath === 'string') { const lastSeg = filePath.split('?')[0].split('/').pop() || '' const m = lastSeg.match(/\.([a-z0-9]+)$/i) if (m) ext = m[1].toLowerCase() } if (ext === 'bin' && filePath && filePath.name) { const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i) if (m2) ext = m2[1].toLowerCase() } // PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 blob URL + 调用方带下来的原始文件名 if (ext === 'bin' && nameHint) { const m3 = String(nameHint).match(/\.([a-z0-9]+)$/i) if (m3) ext = m3[1].toLowerCase() } // 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但 // blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png if (ext === 'bin' && mimeHint) { for (const [e, m] of Object.entries(EXT_MIME_MAP)) { if (m === mimeHint) { ext = e; break } } } if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin' return ext } async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) { // PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取 // H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL // 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin' // PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 走 blob URL 时 file.name 拿不到, // 由 uploadMedia 的 opts.name/mime 兜底 (音频 .mp3 / 视频 .mp4 才不会被写成 .bin) const ext = extractExt(filePath, hints.mime || '', hints.name || '') // 1. 拿 STS 凭证 (addon 模式 URL) const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts' const stsResp = await uni.request({ url: Config.baseUrl + stsUrl, method: 'POST', header: { 'Content-Type': 'application/json', 'token': store.state.token || '' }, data: { agent_id: agentId || undefined, config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, uniacid: aiService.getUniacid(), user_id: aiService.getUserId?.() || '', }, }) if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) { throw new Error(stsResp.data?.msg || stsResp.data?.message || 'STS 凭证获取失败') } const sts = stsResp.data.data const provider = sts.provider || 'alioss' // 老后端默认 alioss // 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符) const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0 return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16) }) const now = new Date() const key = (sts.key_prefix_tpl || 'site/{site_id}/{yyyy}/{mm}/{dd}/{rand}') .replace('{site_id}', sts.site_id || '0') .replace('{agent_id}', agentId || '0') .replace('{yyyy}', now.getFullYear().toString()) .replace('{mm}', String(now.getMonth() + 1).padStart(2, '0')) .replace('{dd}', String(now.getDate()).padStart(2, '0')) .replace('{rand}', uuid) + '.' + ext // ★ PR-OSS-REGISTRY (2026-09-15): provider 分流 // single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED) const isSingleUrlMode = typeof sts.securityToken === 'string' && /-(SIGNED)$/.test(sts.securityToken) let ossUrl, headers if (isSingleUrlMode) { // B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header ossUrl = sts.accessKeyId headers = { 'Content-Type': guessMime(ext) } } else if (provider === 'qiniu') { // A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key headers = { 'Authorization': 'UpToken ' + sts.accessKeyId, 'Content-Type': guessMime(ext), } } else { // A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) // endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼 const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com` : (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com` : (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com` : (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com` : (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}` : (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`) ossUrl = `${tpl}/${key}` headers = { 'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`, 'x-oss-security-token': sts.securityToken, 'Content-Type': guessMime(ext), } } // PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): MP-WEIXIN uni.uploadFile 拿不到 file.size // H5 pathToFile 拿到的 file 有 size, MP-WEIXIN 没有; STS 完成后用已知 size 或后端返回 let uploadedSize = knownSize || 0 // #ifdef H5 try { const probe = await pathToFile(filePath, kind) if (probe && probe.size) uploadedSize = probe.size } catch (e) {} // #endif // 3. 直传云存储 // #ifdef MP-WEIXIN await new Promise((resolve, reject) => { uni.uploadFile({ url: ossUrl, filePath, name: 'file', formData: {}, header: headers, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) resolve() else reject(new Error(`${provider} PUT ${res.statusCode}`)) }, fail: reject, }) }) // #endif // #ifdef H5 const file = await pathToFile(filePath, kind) const ossRes = await fetch(ossUrl, { method: 'PUT', headers, body: file, }) if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`) // #endif // 4. 返回公网 URL return { url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL mime: guessMime(ext), size: uploadedSize, name: key.split('/').pop(), kind, used_strategy: 'oss_sts', provider, } } async function getFileSize(filePath) { // PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 失败抛错而不是 resolve(0) // 0 会让 auto 模式永远走 PHP 中转 (0 > 2MB = false), 失去 STS 直传能力 // 上层 uploadImage 用 try/catch 兜底, 探测失败视为 "大小未知, 默认走 STS" // #ifdef MP-WEIXIN return new Promise((resolve, reject) => { uni.getFileInfo({ filePath, success: (res) => resolve(res.size || 0), fail: (err) => reject(new Error('getFileInfo failed: ' + (err.errMsg || ''))), }) }) // #endif // #ifdef H5 try { const res = await fetch(filePath) const blob = await res.blob() return blob.size } catch (e) { throw new Error('getFileSize fetch failed: ' + e.message) } // #endif } async function pathToFile(filePath, kind) { // #ifdef H5 const res = await fetch(filePath) const blob = await res.blob() // PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): H5 blob URL 路径修复扩展名 // 原代码 `filePath.split('/').pop()` 对 blob:http://xxx/uuid 取出 'uuid' (无扩展名) // → 后端 multipart filename 缺失扩展名 → ext_rule 拒绝 "上传文件后缀不允许" // extractExt 三段兜底: 1) lastSegment 正则 2) file.name 3) 'bin' 最终兜底 // 若 ext='bin', 走 guessMime(EXT_MIME_MAP) 看能不能从 mime 反推 jpg/png (mime→ext) const rawName = filePath.split('/').pop() || `${kind}.bin` const ext = extractExt(filePath, blob.type) const finalName = ext !== 'bin' ? `${rawName}.${ext}` : rawName // blob.type 通常是 image/png 或空; 空时用 guessMime 兜底 const mime = blob.type || guessMime(ext) return new File([blob], finalName, { type: mime }) // #endif } /** * PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 把附件原文件读成 base64, * 用于 ai-chat-message.vue sendMessage 给 attachment 加 `data` 字段, * 让 backend 走 WbAgentOsClient::buildMediaBlocks 路径 ① (inline base64) * 而不是路径 ②/③ (URL fetch / resource_link). * * 为什么必要: 之前 PR-FRONTEND-MEDIA-UPLOAD-FIX 已让上传链路 (uni.compressImage * → backend Upload.php) 返回了公网 URL, 但此 URL 是 backend Upload.php::buildPublicUrl * 用 request()->domain() 拼出来的 (例如 http://192.168.x.x:8050/upload/...). * 买家端 AI chat 把这个 URL 透给 backend, backend MediaGuard::urlAllowed * 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝, 附件被静默丢弃. * * 修复: 同步给 attachment 加 data: 字段. * - backend MessageMedia::normalizeInput 直接透传 data (line 207-208) * - backend MediaGuard::check 看到 data 非空就无条件放行, 不参与 URL 校验 (line 137-140) * - backend WbAgentOsClient::buildMediaBlocks 路径 ① 优先 data + image/audio → 直接 base64 内联 * 全链路 bypass URL 公网可达性 (无论 LAN 还是公网, 都不依赖 WB Cloud 拉 URL). * * 返回: { data: '', size, mime } | null * - null 时表示文件 > maxBytes (默认 5MB, J7 上限) 或读取失败 → 调用方跳过 data 字段, 走 URL 路径 * - bare-base64 (不带 data:image/png;base64, 前缀) — 后端 buildMediaBlocks 需要纯 base64 * - mime 从 blob.type (H5) / 调用方传 mimeHint (uni-app 平台不知道 blob.type) * * @param {string} filePath 选图/选文件时的原始路径 (blob: URL 或 _doc/tmp_xxx 路径) * @param {object} [opts] * @param {number} [opts.maxBytes=5MB] 超限返回 null (frontend 主动限流, 减轻带宽/内存) * @param {string} [opts.mimeHint] 非 H5 平台拿不到 blob.type, 用 pending.uploaded.mime 兜底 */ export async function fileToBase64(filePath, opts = {}) { const maxBytes = opts.maxBytes || SIZE_LIMIT_INLINE_BASE64 if (!filePath) return null // #ifdef H5 try { const res = await fetch(filePath) if (!res.ok) throw new Error('fetch failed: ' + res.status) const blob = await res.blob() if (blob.size > maxBytes) { console.log('[media-upload] fileToBase64: size', blob.size, '> maxBytes', maxBytes, '→ 跳过内联') return null } const dataUri = await new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result) reader.onerror = () => reject(new Error('FileReader failed')) reader.readAsDataURL(blob) }) const m = /^data:([^;,]+);base64,(.*)$/.exec(dataUri) if (!m) throw new Error('unexpected data URI shape: ' + String(dataUri).slice(0, 40)) return { data: m[2], size: blob.size, mime: m[1] } } catch (e) { console.warn('[media-upload] fileToBase64 H5 失败:', e) return null } // #endif // #ifdef MP-WEIXIN || APP-PLUS try { const fsm = uni.getFileSystemManager() // 1. 探测大小, 超限直接返回 null let size = 0 try { const stat = fsm.statSync(filePath) // statSync 在部分平台返回 {size}, 部分返回 {fileSize}, 部分直接是数字 size = (stat && (stat.size || stat.fileSize)) || 0 } catch (e) { // statSync 失败 (权限/路径) → 直接尝试 readFile, 后端会再校验 size console.warn('[media-upload] fileToBase64 statSync failed, fallback to readFile:', e) } if (size > maxBytes) { console.log('[media-upload] fileToBase64: size', size, '> maxBytes', maxBytes, '→ 跳过内联') return null } // 2. readFile 拿 base64 — 同步 API 简单可靠, base64 在 5MB 文件 ≈ 6.7MB 字符串, 内存可控 const data = fsm.readFileSync(filePath, 'base64') return { data, size: size || 0, mime: opts.mimeHint || '' } } catch (e) { console.warn('[media-upload] fileToBase64 uni-app 失败:', e) return null } // #endif } function guessMime(ext) { return EXT_MIME_MAP[ext] || 'application/octet-stream' } export default { uploadImage, uploadMedia }