feat(ai-chat): frontend 媒体上传/播放全栈接通 (PR-FRONTEND-MEDIA-UPLOAD 2026-09-16)
P0 — 新建 common/js/media-upload.js (12KB)
- uploadImage(filePath) / uploadMedia(filePath, kind) 落地
- 此前 ai-chat-tools-panel.vue 引用但文件从未入库, 选图立即抛 "Cannot find module"
- 12 cloud provider 分流: 4-field STS (alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) vs single-URL signed PUT (huaweiobs/volcengine/awsoss/minio)
- 端点必须 addon namespace: /<addon>/api/upload/{image|file|ossSts}, 普通 /api/kefu/upload/* 不命中 InitRoute
- 补 docs 设计稿漏写的 `import store from '@/store/index.js'` (H5 上传需带 token)
P1 — ai-chat-message.vue 用户附件全支持
- P1.1 filter 不再丢 file/audio/video 消息
- P1.2 用户气泡加 type==='file' 分支: 文件图标 + 名称 + 大小 + 上传状态
- P1.3 sendMessage type 不再强制把 audio/video 折成 file
P2 — AI 侧 audio 播放引擎接通
- P2.1 previewAttachment audio 分支 → 跳 file-preview
- P2.2 音频气泡接 uni.createInnerAudioContext: data 加 innerAudioCtx/_innerAudioSrc, 改写 playAudio/pauseAudio, beforeDestroy 必 destroy, slider 区分 change (显示) / changeend (实际 seek)
- P2.3 file-preview.vue 加 image-viewer + audio-viewer 两个分支;inferFileType 识别 jpg/png/mp3/wav 等扩展
P3 — sendMessageWithContent 透传 attachments 参数
- 默认 attachments = [], 传给 sendStreamMessage/sendNormalMessage
- tools panel 选完文件 push 到 attachments → 链到 send
lang — zh-cn + en-us file-preview 加 4 项
- audioUrlNotExist / audioDefaultTitle / audioPlaying / audioPaused
docs — docs/ai-agent-integration.md (跨 PR 累积同步, 2149 行)
- 版本基线提到 PR-WB-MP-INLINE-MEDIA (2026-09-16) 及之后
- 加 §3.1.5 attachments.data 字段协议 (PR-WB-MP-INLINE-MEDIA)
- 12 cloud provider 替换原"阿里云"单点 (PR-OSS-REGISTRY)
- media-upload.js 章节补 fileToBase64 (PR-FRONTEND-MEDIA-UPLOAD)
- 上传策略表 backend/oss_sts/auto 全替换为"云存储"措辞
对齐后端:
- PR-WB-MP-INLINE-MEDIA (a707b90): media data 字段
- PR-OSS-REGISTRY (12 provider): OssProviderRegistry
- PR-FRONTEND-MEDIA-UPLOAD (本 commit): 前端落地
兼容性:
- 后端未升级到 PR-WB-MP-INLINE-MEDIA 之前, attachments 不带 data 字段, 走 URL 路径 (退化到老行为, 不破)
- 后端 oss_sts 单点 alioss 时, securityToken 不带 -SIGNED 后缀, 走 4-field STS 分支 (与 PR-OSS-REGISTRY 之前兼容)
回归:
- node --check media-upload.js OK
- lang zh ESM import OK, 输出 8 个 key 全对
- ai-chat-message.vue 11 处 PR-FRONTEND-MEDIA-UPLOAD 标记, file-preview.vue 9 处
- 旧 chat 路径不带 attachments 走默认 [], 不破坏现有响应结构
安全约束: 不涉及 nginx 层防盗链 / 限速
This commit is contained in:
325
common/js/media-upload.js
Normal file
325
common/js/media-upload.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// 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 /<addon>/api/upload/<image|file> multipart/form-data
|
||||
// STS 凭证: POST /<addon>/api/upload/ossSts application/json
|
||||
// STS 直传: PUT <provider>/<key> 云存储协议 (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
|
||||
|
||||
/**
|
||||
* 上传图片 (type=image)
|
||||
* @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
|
||||
* @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
|
||||
*/
|
||||
export async function uploadImage(filePath) {
|
||||
const addon = aiService.getAddonName() // 'aikefu' | 'aiagent'
|
||||
const config = await aiService.getServiceConfig()
|
||||
const strategy = config?.upload_strategy || 'backend'
|
||||
|
||||
// H5 端: >10MB 先压
|
||||
let compressedPath = filePath
|
||||
// #ifdef H5
|
||||
const fileSize = await getFileSize(filePath)
|
||||
if (fileSize > 10 * 1024 * 1024) {
|
||||
try {
|
||||
const compressed = await uni.compressImage({
|
||||
src: filePath,
|
||||
quality: 80,
|
||||
compressedWidth: 2048,
|
||||
compressedHeight: 2048,
|
||||
})
|
||||
compressedPath = compressed.tempFilePath || filePath
|
||||
} catch (e) {
|
||||
console.warn('[media-upload] compressImage failed, use original:', e)
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 检查是否需要走 STS (>2MB 或商家选 oss_sts)
|
||||
const fileSizeFinal = await getFileSize(compressedPath)
|
||||
const useSts = strategy === 'oss_sts'
|
||||
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
|
||||
|
||||
if (useSts) {
|
||||
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config)
|
||||
} else {
|
||||
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 audio / video / file (一律 STS, 因为体积大概率 >2MB)
|
||||
* @param {string} filePath
|
||||
* @param {'audio'|'video'|'file'} kind
|
||||
*/
|
||||
export async function uploadMedia(filePath, kind) {
|
||||
if (!['audio', 'video', 'file'].includes(kind)) {
|
||||
throw new Error(`invalid kind: ${kind}`)
|
||||
}
|
||||
const addon = aiService.getAddonName()
|
||||
const config = await aiService.getServiceConfig()
|
||||
return await uploadViaSts(filePath, kind, addon, aiService.getAgentId(), config)
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP 中转 (multipart/form-data)
|
||||
* 端点必须用 addon 模式 URL: /<addon>/api/upload/<image|file>
|
||||
* 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下,
|
||||
* /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中
|
||||
* (那是普通接口命名空间, 上传走的是 addon 专属路由)
|
||||
*/
|
||||
async function uploadViaPhp(filePath, kind, addon, agentId) {
|
||||
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,
|
||||
agent_id: addon === 'aiagent' ? agentId : undefined,
|
||||
config_uuid: 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,
|
||||
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 form.append('config_uuid', aiService.getCurrentConfigUuid?.() || '')
|
||||
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,
|
||||
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 <ak>:<sk>
|
||||
* B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio)
|
||||
* → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='<PROVIDER>-SIGNED'
|
||||
* 标记, 不带 Authorization
|
||||
*
|
||||
* 后端 stsResp.data.data 可能字段:
|
||||
* { provider, bucket, region, accessKeyId, accessKeySecret, securityToken,
|
||||
* key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) }
|
||||
*/
|
||||
async function uploadViaSts(filePath, kind, addon, agentId, config) {
|
||||
// 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: addon === 'aiagent' ? agentId : undefined,
|
||||
config_uuid: 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 ext = (filePath.split('.').pop() || 'bin').toLowerCase()
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// 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: file.size || 0,
|
||||
name: key.split('/').pop(),
|
||||
kind,
|
||||
used_strategy: 'oss_sts',
|
||||
provider,
|
||||
}
|
||||
}
|
||||
|
||||
async function getFileSize(filePath) {
|
||||
// #ifdef MP-WEIXIN
|
||||
return new Promise((resolve) => {
|
||||
uni.getFileInfo({
|
||||
filePath,
|
||||
success: (res) => resolve(res.size),
|
||||
fail: () => resolve(0),
|
||||
})
|
||||
})
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
try {
|
||||
const res = await fetch(filePath)
|
||||
const blob = await res.blob()
|
||||
return blob.size
|
||||
} catch (e) { return 0 }
|
||||
// #endif
|
||||
}
|
||||
|
||||
async function pathToFile(filePath, kind) {
|
||||
// #ifdef H5
|
||||
const res = await fetch(filePath)
|
||||
const blob = await res.blob()
|
||||
return new File([blob], filePath.split('/').pop() || `${kind}.bin`, { type: blob.type })
|
||||
// #endif
|
||||
}
|
||||
|
||||
function guessMime(ext) {
|
||||
const map = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
|
||||
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
|
||||
mp4: 'video/mp4', mov: 'video/quicktime',
|
||||
pdf: 'application/pdf', txt: 'text/plain',
|
||||
}
|
||||
return map[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
export default { uploadImage, uploadMedia }
|
||||
2149
docs/ai-agent-integration.md
Normal file
2149
docs/ai-agent-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,11 @@ export const lang = {
|
||||
fileUrlNotExist: 'File URL does not exist',
|
||||
// Video URL does not exist
|
||||
videoUrlNotExist: 'Video URL does not exist',
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): audio related
|
||||
audioUrlNotExist: 'Audio URL does not exist',
|
||||
audioDefaultTitle: 'Voice message',
|
||||
audioPlaying: 'Playing…',
|
||||
audioPaused: 'Paused',
|
||||
// This file type preview is not supported
|
||||
unsupportedFileType: 'This file type preview is not supported',
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export const lang = {
|
||||
fileUrlNotExist: '文件地址不存在',
|
||||
// 视频地址不存在
|
||||
videoUrlNotExist: '视频地址不存在',
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频相关
|
||||
audioUrlNotExist: '音频地址不存在',
|
||||
audioDefaultTitle: '语音消息',
|
||||
audioPlaying: '播放中…',
|
||||
audioPaused: '已暂停',
|
||||
// 暂不支持该类型文件预览
|
||||
unsupportedFileType: '暂不支持该类型文件预览',
|
||||
}
|
||||
|
||||
@@ -36,9 +36,16 @@
|
||||
</view>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<view
|
||||
v-for="(message, index) in messages.filter(msg => msg.type === 'loading' || msg.imageUrl || (msg.content && msg.content.trim() !== ''))"
|
||||
:key="getMessageKey(message, index)"
|
||||
<view
|
||||
v-for="(message, index) in messages.filter(msg =>
|
||||
msg.type === 'loading'
|
||||
|| msg.imageUrl
|
||||
|| msg.fileName
|
||||
|| msg.audioUrl
|
||||
|| msg.videoUrl
|
||||
|| (msg.content && msg.content.trim() !== '')
|
||||
)"
|
||||
:key="getMessageKey(message, index)"
|
||||
:id="getMessageKey(message, index)"
|
||||
class="message-item"
|
||||
:class="[message.role, { 'first-message': index === 0, 'search-current': showSearchBar && searchResultFilteredIndex === index, 'search-flash': showSearchBar && searchFlashAnimatingIndex === index }]">
|
||||
@@ -51,6 +58,21 @@
|
||||
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||
</view>
|
||||
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
|
||||
tools-panel _uploadOneFile 选 kind='file' (audio/video 当前未走
|
||||
用户侧, 走 AI 侧 attachments) 时, m.type='file' + m.fileName/size
|
||||
+ m.imageUrl=本地预览图(若有) / m.uploaded.url 上传后 URL -->
|
||||
<view v-else-if="message.type === 'file'" class="message-bubble user-file-bubble" @click="previewUserFile(message)">
|
||||
<view class="file-item">
|
||||
<view class="file-icon"><text class="iconfont icon-file"></text></view>
|
||||
<view class="file-info">
|
||||
<text class="file-name">{{ message.fileName || '文件' }}</text>
|
||||
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
|
||||
</view>
|
||||
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="message-bubble">
|
||||
<rich-text v-if="searchKeyword" :nodes="highlightContent(message.content)" class="message-rich-text" />
|
||||
<text v-else class="message-text" selectable>{{ message.content }}</text>
|
||||
@@ -504,6 +526,10 @@ export default {
|
||||
voiceInputing: false,
|
||||
audioContext: null,
|
||||
currentAudio: null,
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频播放引擎
|
||||
// 单例 createInnerAudioContext, 切换 message 时按需重建 (src 变了才重建)
|
||||
innerAudioCtx: null,
|
||||
_innerAudioSrc: '',
|
||||
messageId: 0,
|
||||
showToolsPanel: false,
|
||||
enableVoicePanelShow: false,
|
||||
@@ -605,6 +631,11 @@ export default {
|
||||
clearInterval(msg._rotationTimer);
|
||||
}
|
||||
});
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 销毁 innerAudioCtx
|
||||
if (this.innerAudioCtx) {
|
||||
try { this.innerAudioCtx.stop(); this.innerAudioCtx.destroy(); } catch (e) {}
|
||||
this.innerAudioCtx = null;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.messages = [...this.initialMessages]
|
||||
@@ -964,7 +995,10 @@ async loadChatHistoryIfExist() {
|
||||
.map(m => {
|
||||
m.sendWithBatch = true
|
||||
return {
|
||||
type: m.uploaded.kind || (m.type === 'image' ? 'image' : 'file'),
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 不再把 audio/video 强转 file
|
||||
// m.uploaded.kind 是后端响应 (image/file); m.type 是前端分类
|
||||
// (audio/video 当前未走用户侧, 但保留路径供未来 audio recorder 接入)
|
||||
type: m.uploaded.kind || m.type || 'file',
|
||||
url: m.uploaded.url,
|
||||
mime: m.uploaded.mime,
|
||||
name: m.uploaded.name,
|
||||
@@ -1345,7 +1379,15 @@ previewAttachment(att) {
|
||||
if (att.type === 'image') {
|
||||
uni.previewImage({ urls: [att.url], current: att.url })
|
||||
} else if (att.type === 'video') {
|
||||
uni.navigateTo({ url: '/pages_tool/file-preview/file-preview?url=' + encodeURIComponent(att.url) })
|
||||
uni.navigateTo({ url: '/pages_tool/file-preview/file-preview?url=' + encodeURIComponent(att.url) + '&fileType=video' })
|
||||
} else if (att.type === 'audio') {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频附件 — 跳到 file-preview 复用音频播放器
|
||||
// file-preview.vue 加了 audio fileType 分支, 用 createInnerAudioContext 播放
|
||||
uni.navigateTo({
|
||||
url: '/pages_tool/file-preview/file-preview?url=' + encodeURIComponent(att.url)
|
||||
+ '&fileType=audio'
|
||||
+ (att.name ? '&fileName=' + encodeURIComponent(att.name) : '')
|
||||
})
|
||||
} else {
|
||||
// #ifdef H5
|
||||
window.open(att.url, '_blank')
|
||||
@@ -1886,11 +1928,11 @@ _handleStreamError(msg) {
|
||||
// 预览文件
|
||||
previewFile(message) {
|
||||
uni.showLoading({ title: this.$lang('ai-chat.index.openingFile') })
|
||||
|
||||
|
||||
// #ifdef H5
|
||||
window.open(message.url, '_blank')
|
||||
// #endif
|
||||
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
plus.runtime.openFile(message.url, {
|
||||
error: (e) => {
|
||||
@@ -1901,10 +1943,28 @@ _handleStreamError(msg) {
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
|
||||
uni.hideLoading()
|
||||
this.$emit('file-preview', message)
|
||||
},
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡点击预览
|
||||
// 用户刚选完文件时可能还没上传完成, 此时 uploaded=null, imageUrl 是本地 blob/tempFilePath
|
||||
// - 已上传: 用 uploaded.url (云端公网 URL)
|
||||
// - 上传中/失败: 不响应点击 (避免点开失效链接)
|
||||
previewUserFile(message) {
|
||||
const url = message.uploaded && message.uploaded.url
|
||||
if (!url) {
|
||||
if (message.uploadError) {
|
||||
uni.showToast({ title: message.uploadError, icon: 'none' })
|
||||
} else {
|
||||
uni.showToast({ title: '文件上传中...', icon: 'none' })
|
||||
}
|
||||
return
|
||||
}
|
||||
// 复用 AI 侧文件预览: H5 new tab; APP plus.runtime.openFile
|
||||
this.previewFile({ url })
|
||||
},
|
||||
// 切换音频播放
|
||||
toggleAudio(message) {
|
||||
if (message.playing) {
|
||||
@@ -1913,27 +1973,79 @@ _handleStreamError(msg) {
|
||||
this.playAudio(message)
|
||||
}
|
||||
},
|
||||
|
||||
// 播放音频
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频播放 — 接 createInnerAudioContext
|
||||
// 单例 ctx, src 变化时重建 (不同 message 不同 url)
|
||||
// H5 + MP-WEIXIN 都原生支持
|
||||
playAudio(message) {
|
||||
if (!message || !message.url) {
|
||||
uni.showToast({ title: '音频地址为空', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// 切换 message 时先停掉旧的
|
||||
if (this.currentAudio && this.currentAudio !== message) {
|
||||
this.pauseAudio(this.currentAudio)
|
||||
}
|
||||
|
||||
// 模拟音频播放
|
||||
|
||||
// src 变了才重建 ctx (避免重复创建, 微信小程序 createInnerAudioContext 单实例可复用但设 src 必须先 stop)
|
||||
if (!this.innerAudioCtx || this._innerAudioSrc !== message.url) {
|
||||
if (this.innerAudioCtx) {
|
||||
try { this.innerAudioCtx.stop(); this.innerAudioCtx.destroy(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
try {
|
||||
this.innerAudioCtx = uni.createInnerAudioContext()
|
||||
this.innerAudioCtx.src = message.url
|
||||
this._innerAudioSrc = message.url
|
||||
this.innerAudioCtx.onTimeUpdate(() => {
|
||||
if (!this.innerAudioCtx) return
|
||||
message.currentTime = this.innerAudioCtx.currentTime
|
||||
if (this.innerAudioCtx.duration && !isNaN(this.innerAudioCtx.duration)) {
|
||||
message.duration = this.innerAudioCtx.duration
|
||||
}
|
||||
})
|
||||
this.innerAudioCtx.onEnded(() => {
|
||||
message.playing = false
|
||||
message.currentTime = 0
|
||||
this.currentAudio = null
|
||||
})
|
||||
this.innerAudioCtx.onError((e) => {
|
||||
message.playing = false
|
||||
uni.showToast({ title: '音频播放失败', icon: 'none' })
|
||||
console.error('[AI audio] play error:', e)
|
||||
})
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '音频引擎初始化失败', icon: 'none' })
|
||||
console.error('[AI audio] createInnerAudioContext failed:', e)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// seek 到上次位置 (暂停后继续播放)
|
||||
if (message.currentTime && message.currentTime > 0 && this.innerAudioCtx) {
|
||||
try { this.innerAudioCtx.seek(message.currentTime) } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
message.playing = true
|
||||
message.currentTime = message.currentTime || 0
|
||||
|
||||
try {
|
||||
this.innerAudioCtx.play()
|
||||
} catch (e) {
|
||||
message.playing = false
|
||||
uni.showToast({ title: '音频播放失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.currentAudio = message
|
||||
this.$emit('audio-play', message)
|
||||
},
|
||||
|
||||
|
||||
// 暂停音频
|
||||
pauseAudio(message) {
|
||||
message.playing = false
|
||||
if (this.innerAudioCtx) {
|
||||
try { this.innerAudioCtx.pause(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
if (message) message.playing = false
|
||||
this.$emit('audio-pause', message)
|
||||
},
|
||||
|
||||
|
||||
// 音频滑块变化
|
||||
onAudioSliderChange(e) {
|
||||
if (this.currentAudio) {
|
||||
@@ -1944,6 +2056,10 @@ _handleStreamError(msg) {
|
||||
onAudioSliderChangeEnd(e) {
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.currentTime = e.detail.value
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 同步 seek 到 innerAudioCtx
|
||||
if (this.innerAudioCtx) {
|
||||
try { this.innerAudioCtx.seek(e.detail.value) } catch (e) { /* ignore */ }
|
||||
}
|
||||
this.$emit('audio-seek', this.currentAudio)
|
||||
}
|
||||
},
|
||||
@@ -2292,7 +2408,10 @@ _handleStreamError(msg) {
|
||||
},
|
||||
|
||||
// 使用指定内容发送消息
|
||||
async sendMessageWithContent(content) {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 增加 attachments 参数
|
||||
// 之前 sendStreamMessage/sendNormalMessage 已经接收 attachments 但这里没传,
|
||||
// 导致商品页 / 重发 路径丢附件
|
||||
async sendMessageWithContent(content, attachments = []) {
|
||||
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
|
||||
this._aiRequestStart = Date.now()
|
||||
|
||||
@@ -2303,9 +2422,9 @@ _handleStreamError(msg) {
|
||||
content: content,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
|
||||
this.messages.push(userMessage)
|
||||
|
||||
|
||||
// 显示AI正在输入
|
||||
const loadingMessage = {
|
||||
id: ++this.messageId,
|
||||
@@ -2327,18 +2446,18 @@ _handleStreamError(msg) {
|
||||
// 使用AI服务获取回复
|
||||
if (this.enableStreaming) {
|
||||
// 流式响应
|
||||
await this.sendStreamMessage(userMessage.content)
|
||||
await this.sendStreamMessage(userMessage.content, attachments)
|
||||
} else {
|
||||
// 普通响应
|
||||
await this.sendNormalMessage(userMessage.content)
|
||||
await this.sendNormalMessage(userMessage.content, attachments)
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI服务调用失败:', error)
|
||||
|
||||
|
||||
// 移除加载状态
|
||||
this._removeLoadingMessages()
|
||||
|
||||
|
||||
// 显示错误消息
|
||||
const errorMessage = {
|
||||
id: ++this.messageId,
|
||||
@@ -2350,11 +2469,11 @@ _handleStreamError(msg) {
|
||||
{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
this.messages.push(errorMessage)
|
||||
this.$emit('ai-response', errorMessage)
|
||||
}
|
||||
|
||||
|
||||
// 触发消息发送事件
|
||||
this.$emit('message-sent', userMessage)
|
||||
},
|
||||
@@ -4408,4 +4527,55 @@ _handleStreamError(msg) {
|
||||
&.upload-error .upload-tip { color: #ffb3b3; }
|
||||
}
|
||||
}
|
||||
|
||||
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
|
||||
复用 AI 侧 .file-item 样式 (line 2943), 仅包一层 user-file-bubble 容器 */
|
||||
.user-file-bubble {
|
||||
padding: 12rpx 16rpx !important;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
.file-item {
|
||||
/* 复用 AI 侧定义; 复制过来以避免修改原有 AI 侧样式 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 240rpx;
|
||||
max-width: 360rpx;
|
||||
.file-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
.iconfont { font-size: 48rpx; color: #6b7c93; }
|
||||
}
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.file-name {
|
||||
font-size: 26rpx;
|
||||
color: #2c3e50;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240rpx;
|
||||
display: block;
|
||||
}
|
||||
.file-size {
|
||||
font-size: 22rpx;
|
||||
color: #8a9fb8;
|
||||
margin-top: 4rpx;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
.upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.upload-tip { color: #fff; font-size: 24rpx; }
|
||||
&.upload-error .upload-tip { color: #ffb3b3; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,6 +36,47 @@
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="content">
|
||||
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片预览分支
|
||||
aikefu 测试对话附件 type='image' / AI 侧 attachments.image 跳转时传 fileType=image -->
|
||||
<view v-if="fileType === 'image'" class="image-viewer">
|
||||
<image v-if="fileUrl" :src="fileUrl" mode="aspectFit" class="image-element" @click="previewImageFullscreen" />
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频预览分支
|
||||
AI 侧 attachments.audio 跳转时传 fileType=audio, 用 createInnerAudioContext 播放 -->
|
||||
<view v-else-if="fileType === 'audio'" class="audio-viewer">
|
||||
<view v-if="fileUrl" class="audio-player-card">
|
||||
<view class="audio-icon">
|
||||
<text class="iconfont icon-voice"></text>
|
||||
</view>
|
||||
<view class="audio-info">
|
||||
<view class="audio-title">{{ fileName || $lang('file-preview.file-preview.audioDefaultTitle') }}</view>
|
||||
<view class="audio-status">{{ audioPlaying ? $lang('file-preview.file-preview.audioPlaying') : $lang('file-preview.file-preview.audioPaused') }}</view>
|
||||
<view class="audio-progress">
|
||||
<text class="audio-time">{{ formatAudioTime(audioCurrentTime) }}</text>
|
||||
<view class="audio-progress-bar">
|
||||
<view class="audio-progress-fill" :style="{ width: audioProgressPct + '%' }"></view>
|
||||
</view>
|
||||
<text class="audio-time">{{ formatAudioTime(audioDuration) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="audio-play-btn" @click="toggleAudioPlay">
|
||||
<text class="iconfont" :class="audioPlaying ? 'icon-pause' : 'icon-play'"></text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.audioUrlNotExist') }}</view>
|
||||
<!-- #ifdef H5 -->
|
||||
<view class="h5-action-bar">
|
||||
<view class="h5-action-tip">如无法播放,请使用下方按钮:</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="openInNewTab">新标签页打开</button>
|
||||
<button class="h5-action-btn" @click="triggerDownload">下载文件</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<!-- PDF 文件预览 -->
|
||||
<view v-if="fileType === 'pdf'" class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
@@ -101,9 +142,20 @@ export default {
|
||||
fileUrl: '',
|
||||
fileType: '',
|
||||
statusBarHeight: 20,
|
||||
windowWidth: 375
|
||||
windowWidth: 375,
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频播放引擎
|
||||
audioCtx: null,
|
||||
audioPlaying: false,
|
||||
audioCurrentTime: 0,
|
||||
audioDuration: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
audioProgressPct() {
|
||||
if (!this.audioDuration || this.audioDuration <= 0) return 0;
|
||||
return Math.min(100, (this.audioCurrentTime / this.audioDuration) * 100);
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 整个自定义 header 高度(状态栏 + 标题栏 88rpx),单位 px —— 用于占位 view 和内容区下移
|
||||
@@ -178,6 +230,13 @@ export default {
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 离开页面销毁音频 ctx
|
||||
onUnload() {
|
||||
if (this.audioCtx) {
|
||||
try { this.audioCtx.stop(); this.audioCtx.destroy(); } catch (e) {}
|
||||
this.audioCtx = null;
|
||||
}
|
||||
},
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序:右上角胶囊"分享给朋友" / button[open-type=share] 触发
|
||||
onShareAppMessage() {
|
||||
@@ -196,25 +255,24 @@ export default {
|
||||
// #endif
|
||||
methods: {
|
||||
inferFileType() {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 加 image / audio 识别
|
||||
const classify = (ext) => {
|
||||
if (['pdf'].includes(ext)) return 'pdf';
|
||||
if (['doc', 'docx'].includes(ext)) return 'word';
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext)) return 'video';
|
||||
if (['mp3', 'wav', 'aac', 'm4a', 'ogg', 'flac'].includes(ext)) return 'audio';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) return 'image';
|
||||
return null;
|
||||
};
|
||||
if (this.fileUrl) {
|
||||
const clean = String(this.fileUrl).split('?')[0];
|
||||
const ext = clean.split('.').pop().toLowerCase();
|
||||
if (['pdf'].includes(ext)) {
|
||||
this.fileType = 'pdf';
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
this.fileType = 'word';
|
||||
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
|
||||
this.fileType = 'video';
|
||||
}
|
||||
const t = classify(ext);
|
||||
if (t) this.fileType = t;
|
||||
} else if (this.fileName) {
|
||||
const ext = this.fileName.split('.').pop().toLowerCase();
|
||||
if (['pdf'].includes(ext)) {
|
||||
this.fileType = 'pdf';
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
this.fileType = 'word';
|
||||
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
|
||||
this.fileType = 'video';
|
||||
}
|
||||
const t = classify(ext);
|
||||
if (t) this.fileType = t;
|
||||
}
|
||||
},
|
||||
goBack() {
|
||||
@@ -259,6 +317,87 @@ export default {
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片分支 — 点击全屏预览
|
||||
previewImageFullscreen() {
|
||||
if (!this.fileUrl) return;
|
||||
// #ifdef H5 || MP-WEIXIN
|
||||
uni.previewImage({ urls: [this.fileUrl], current: this.fileUrl });
|
||||
// #endif
|
||||
},
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频分支 — 切换播放/暂停
|
||||
toggleAudioPlay() {
|
||||
if (!this.fileUrl) {
|
||||
uni.showToast({ title: '音频地址为空', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (this.audioPlaying) {
|
||||
this._pauseAudio();
|
||||
} else {
|
||||
this._playAudio();
|
||||
}
|
||||
},
|
||||
|
||||
_ensureAudioCtx() {
|
||||
if (this.audioCtx) return this.audioCtx;
|
||||
try {
|
||||
const ctx = uni.createInnerAudioContext();
|
||||
ctx.src = this.fileUrl;
|
||||
ctx.onTimeUpdate(() => {
|
||||
this.audioCurrentTime = ctx.currentTime || 0;
|
||||
if (ctx.duration && !isNaN(ctx.duration)) {
|
||||
this.audioDuration = ctx.duration;
|
||||
}
|
||||
});
|
||||
ctx.onEnded(() => {
|
||||
this.audioPlaying = false;
|
||||
this.audioCurrentTime = 0;
|
||||
});
|
||||
ctx.onError((e) => {
|
||||
this.audioPlaying = false;
|
||||
uni.showToast({ title: '音频播放失败', icon: 'none' });
|
||||
console.error('[file-preview audio] error:', e);
|
||||
});
|
||||
this.audioCtx = ctx;
|
||||
return ctx;
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '音频引擎初始化失败', icon: 'none' });
|
||||
console.error('[file-preview audio] createInnerAudioContext failed:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
_playAudio() {
|
||||
const ctx = this._ensureAudioCtx();
|
||||
if (!ctx) return;
|
||||
if (this.audioCurrentTime > 0) {
|
||||
try { ctx.seek(this.audioCurrentTime); } catch (e) { /* ignore */ }
|
||||
}
|
||||
this.audioPlaying = true;
|
||||
try {
|
||||
ctx.play();
|
||||
} catch (e) {
|
||||
this.audioPlaying = false;
|
||||
uni.showToast({ title: '音频播放失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
|
||||
_pauseAudio() {
|
||||
if (this.audioCtx) {
|
||||
try { this.audioCtx.pause(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
this.audioPlaying = false;
|
||||
},
|
||||
|
||||
// 00:00 格式
|
||||
formatAudioTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '00:00';
|
||||
const total = Math.floor(seconds);
|
||||
const mm = String(Math.floor(total / 60)).padStart(2, '0');
|
||||
const ss = String(total % 60).padStart(2, '0');
|
||||
return mm + ':' + ss;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -486,6 +625,128 @@ export default {
|
||||
background-color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片预览 */
|
||||
.image-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
background-color: #000;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.image-element {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频预览 */
|
||||
.audio-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.audio-player-card {
|
||||
width: 90%;
|
||||
max-width: 560rpx;
|
||||
padding: 40rpx 32rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
.audio-icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6b7c93 0%, #4a5a72 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 24rpx;
|
||||
.iconfont {
|
||||
font-size: 48rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
|
||||
.audio-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.audio-status {
|
||||
font-size: 22rpx;
|
||||
color: #8a9fb8;
|
||||
}
|
||||
.audio-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 8rpx;
|
||||
.audio-time {
|
||||
font-size: 22rpx;
|
||||
color: #8a9fb8;
|
||||
flex-shrink: 0;
|
||||
min-width: 60rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.audio-progress-bar {
|
||||
flex: 1;
|
||||
height: 6rpx;
|
||||
background-color: #e8edf3;
|
||||
border-radius: 3rpx;
|
||||
overflow: hidden;
|
||||
.audio-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6b7c93 0%, #4a5a72 100%);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio-play-btn {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6b7c93 0%, #4a5a72 100%);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 16rpx;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
&::after { border: none; }
|
||||
.iconfont {
|
||||
font-size: 36rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user