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:
2026-09-16 11:42:18 +08:00
parent 3f29116345
commit 7952f5a722
6 changed files with 2957 additions and 42 deletions

View File

@@ -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>