chore:AI智能输入框样式改变
This commit is contained in:
@@ -218,6 +218,10 @@ const localDevConfig = ({
|
||||
uniacid: 317,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-312': { // 电磁兼容,模板显示不全商品
|
||||
uniacid: 312,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
|
||||
@@ -38,7 +38,12 @@ const EXT_MIME_MAP = {
|
||||
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',
|
||||
@@ -117,17 +122,55 @@ export async function uploadImage(filePath, opts = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 audio / video / file (一律 STS, 因为体积大概率 >2MB)
|
||||
* 上传 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) {
|
||||
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()
|
||||
return await uploadViaSts(filePath, kind, addon, aiService.getAgentId(), config)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,7 +284,7 @@ async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
||||
* PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
|
||||
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
|
||||
*/
|
||||
function extractExt(filePath, mimeHint = '') {
|
||||
function extractExt(filePath, mimeHint = '', nameHint = '') {
|
||||
let ext = 'bin'
|
||||
if (typeof filePath === 'string') {
|
||||
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
|
||||
@@ -252,6 +295,11 @@ function extractExt(filePath, mimeHint = '') {
|
||||
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) {
|
||||
@@ -263,11 +311,13 @@ function extractExt(filePath, mimeHint = '') {
|
||||
return ext
|
||||
}
|
||||
|
||||
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0) {
|
||||
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'
|
||||
const ext = extractExt(filePath)
|
||||
// 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'
|
||||
|
||||
@@ -118,6 +118,8 @@ export const lang = {
|
||||
voice: 'Voice',
|
||||
recordVoice: 'Record Voice',
|
||||
uploadAudio: 'Upload Audio',
|
||||
pickVideo: 'Choose Video',
|
||||
pickAudio: 'Choose Audio',
|
||||
chooseAudioFail: 'Failed to select audio',
|
||||
chooseVideoFail: 'Failed to select video',
|
||||
audioNotReady: 'Audio not ready',
|
||||
|
||||
@@ -118,6 +118,8 @@ export const lang = {
|
||||
voice: '语音',
|
||||
recordVoice: '录制语音',
|
||||
uploadAudio: '上传音频',
|
||||
pickVideo: '选择视频',
|
||||
pickAudio: '选择音频',
|
||||
chooseAudioFail: '选择音频失败',
|
||||
chooseVideoFail: '选择视频失败',
|
||||
audioNotReady: '音频尚未就绪',
|
||||
|
||||
@@ -355,7 +355,7 @@
|
||||
|
||||
<!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) -->
|
||||
<view class="input-area" :style="inputAreaStyle">
|
||||
<view class="input-card">
|
||||
<view class="input-card" :class="{ 'is-voice-mode': voiceMode, 'is-text-mode': showTextMode }">
|
||||
<!-- 待发送图片附件预览: 挂在输入条内部顶端(文字行上方), 出现时输入框自动向上撑高, 删除后恢复原高度 -->
|
||||
<view v-if="pendingImages.length" class="pending-attachment">
|
||||
<view v-for="p in pendingImages" :key="p.localId" class="pending-item">
|
||||
@@ -368,17 +368,35 @@
|
||||
<view class="pending-remove" @tap="removePendingImage(p.localId)">×</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 顶部输入行: 语音图标 + 输入区 + 加号 + 发送 -->
|
||||
<view class="input-row-top">
|
||||
<!-- 无文字时左侧显示语音图标,点击才打开语音面板 -->
|
||||
<view v-if="!hasInputText" class="input-row-icon" @click="onVoiceClick">
|
||||
<!-- PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态
|
||||
① 默认(未输入/未聚焦): 单行胶囊 —— 语音图标 + 输入框 + 加号
|
||||
② 语音模式(点语音图标进入): 「按住说话」按钮 + 底部工具栏(表情/模型/加号)
|
||||
③ 文字输入(聚焦或有文字): 输入框独占一行 + 底部工具栏(语音/模型/加号/发送) -->
|
||||
<!-- 语音模式: 按住说话 -->
|
||||
<view
|
||||
v-if="voiceMode"
|
||||
class="voice-hold-btn"
|
||||
:class="{ 'is-holding': voiceHolding }"
|
||||
@touchstart.stop.prevent="onVoiceHoldStart"
|
||||
@touchend.stop.prevent="onVoiceHoldEnd"
|
||||
@touchcancel.stop.prevent="onVoiceHoldCancel"
|
||||
@click.stop="onVoiceHoldEnd">
|
||||
<text class="voice-hold-text">{{ voiceHolding ? '松开发送' : '按住说话' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="input-row-top">
|
||||
<!-- 默认态左侧: 点击进入语音(按住说话)模式 -->
|
||||
<!-- PR-FRONTEND-H5-INPUT-FOCUS (2026-09-17): 兄弟节点加 key, 防止 H5 下 Vue 无 key 复用
|
||||
同标签 view 导致 textarea 被销毁重建、聚焦即失焦(塌回胶囊无法输入) -->
|
||||
<view v-if="!showTextMode" key="voice-icon" class="input-row-icon" @click="enterVoiceMode">
|
||||
<!-- 微信小程序不支持内联 SVG,改用 base64 SVG 背景图 -->
|
||||
<view class="voice-icon-bg"></view>
|
||||
</view>
|
||||
|
||||
<!-- 输入区: textarea 始终可点击输入,有文字时下方显示 Auto 选择器 -->
|
||||
<view class="input-left-content" :class="{ 'with-voice-icon': !hasInputText }">
|
||||
<!-- 输入区: textarea 三态共用同一个节点, 避免切换时丢焦点 -->
|
||||
<view key="input-content" class="input-left-content" :class="{ 'with-voice-icon': !showTextMode }">
|
||||
<textarea
|
||||
ref="messageInput"
|
||||
v-model="inputText"
|
||||
class="message-input"
|
||||
placeholder="发消息或按住说话"
|
||||
@@ -386,34 +404,58 @@
|
||||
:maxlength="500"
|
||||
:auto-height="true"
|
||||
:show-confirm-bar="false"
|
||||
:adjust-position="false"
|
||||
:adjust-position="adjustPosition"
|
||||
:cursor-spacing="150"
|
||||
@confirm="sendMessage"
|
||||
@input="onInput"
|
||||
@touchstart="onInputPointerDown"
|
||||
@mousedown="onInputPointerDown"
|
||||
@focus="onInputFocus"
|
||||
@blur="onInputBlur"
|
||||
@linechange="onLineChange" />
|
||||
<view v-if="hasInputText" class="model-selector" @click="onToggleModelSelector">
|
||||
<!-- 圆圈 @ 图标,H5/小程序通用 -->
|
||||
<view class="model-icon-circle">
|
||||
<text class="model-icon-text">@</text>
|
||||
</view>
|
||||
<text class="model-name">{{ selectedModel }}</text>
|
||||
<text class="model-arrow">▾</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-right-actions">
|
||||
<!-- 默认态右侧: 只有加号 -->
|
||||
<view v-if="!showTextMode" key="right-actions" class="input-right-actions">
|
||||
<button class="top-plus-btn" @click="showMoreTools">
|
||||
<text class="plus-icon">+</text>
|
||||
</button>
|
||||
|
||||
<!-- 有文字或图片附件上传完成时显示发送按钮 -->
|
||||
<button v-if="hasInputText || canSendPendingImage" class="top-send-btn" @click="sendMessage">
|
||||
<!-- H5/小程序都兼容:旋转箭头模拟向上的纸飞机 -->
|
||||
<text class="send-plane-text">➤</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部工具栏行: 语音模式 / 文字输入时显示 -->
|
||||
<view v-if="voiceMode || showTextMode" class="input-toolbar-row">
|
||||
<!-- 语音模式: 键盘图标 (点击回到文字输入) -->
|
||||
<view v-if="voiceMode" class="toolbar-round-btn" @click="exitVoiceMode">
|
||||
<view class="emoji-icon-bg"></view>
|
||||
</view>
|
||||
<!-- 文字输入: 语音图标 (切到按住说话) -->
|
||||
<view v-else class="toolbar-round-btn" @click="enterVoiceMode">
|
||||
<view class="voice-icon-bg"></view>
|
||||
</view>
|
||||
|
||||
<view class="model-selector" @click="onToggleModelSelector">
|
||||
<!-- 圆圈 @ 图标,H5/小程序通用 -->
|
||||
<view class="model-icon-circle">
|
||||
<text class="model-icon-text">@</text>
|
||||
</view>
|
||||
<text class="model-name">{{ selectedModel }}</text>
|
||||
<text class="model-arrow">▾</text>
|
||||
</view>
|
||||
|
||||
<view class="toolbar-flex-spacer"></view>
|
||||
|
||||
<button class="top-plus-btn" @click="showMoreTools">
|
||||
<text class="plus-icon">+</text>
|
||||
</button>
|
||||
|
||||
<!-- 有文字或图片附件上传完成时显示发送按钮 -->
|
||||
<button class="top-send-btn" :class="{ 'is-disabled': !canSend }" @click="sendMessage">
|
||||
<!-- H5/小程序都兼容:旋转箭头模拟向上的纸飞机 -->
|
||||
<text class="send-plane-text">➤</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 底部工具栏: 5 个图标按钮均匀分布(当前隐藏,保留代码) -->
|
||||
<view class="input-toolbar" style="display: none;">
|
||||
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('task')">
|
||||
@@ -586,6 +628,15 @@ export default {
|
||||
hasMoreHistory: false,
|
||||
loadingText: '',
|
||||
voiceInputing: false,
|
||||
// PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态
|
||||
// voiceMode 语音模式(按住说话) / voiceHolding 按住中 / inputFocused 输入框是否聚焦
|
||||
voiceMode: false,
|
||||
voiceHolding: false,
|
||||
inputFocused: false,
|
||||
// 失焦定时器: 用于延迟收起文字输入态, 避免 H5 布局变化触发的瞬时 blur 误收起
|
||||
_blurTimer: null,
|
||||
// 平台相关: H5 让浏览器自动把输入框顶起(adjust-position=true), 小程序用键盘垫高自行处理
|
||||
adjustPosition: false,
|
||||
audioContext: null,
|
||||
currentAudio: null,
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频播放引擎
|
||||
@@ -678,10 +729,25 @@ export default {
|
||||
},
|
||||
hasInputText() {
|
||||
return this.inputText && this.inputText.trim() !== ''
|
||||
},
|
||||
// PR-FRONTEND-INPUT-3STATES (2026-09-17): 文字输入态
|
||||
// 聚焦 或 已有文字时展开为"输入框一行 + 工具栏一行", 否则回到单行胶囊。
|
||||
// 用聚焦(而不是只判断文字)是为了: 删除最后一个字时布局不会突然塌回胶囊。
|
||||
// 另外: 有"上传完成的待发送图片"时也必须展开, 否则发送按钮(在工具栏行)不可见。
|
||||
showTextMode() {
|
||||
return !this.voiceMode && (this.inputFocused || this.hasInputText || this.canSendPendingImage)
|
||||
},
|
||||
// 发送按钮可用态 (无内容时按钮保留但置灰)
|
||||
canSend() {
|
||||
return this.hasInputText || this.canSendPendingImage
|
||||
}
|
||||
},
|
||||
// 新增:组件销毁时清除所有定时器
|
||||
beforeDestroy() {
|
||||
if (this._blurTimer) clearTimeout(this._blurTimer);
|
||||
// #ifdef H5
|
||||
this._teardownH5KeyboardObserver && this._teardownH5KeyboardObserver();
|
||||
// #endif
|
||||
if (this.scrollTimer) clearTimeout(this.scrollTimer);
|
||||
if (this.searchFlashTimer) {
|
||||
clearTimeout(this.searchFlashTimer);
|
||||
@@ -728,6 +794,11 @@ export default {
|
||||
// 确保 H5 在组件挂载后也尝试加载历史(兜底)
|
||||
this.loadHistoryIfNeeded()
|
||||
})
|
||||
// #ifdef H5
|
||||
// H5 下 uni.onKeyboardHeightChange 不会触发, 改用 visualViewport/window 监听键盘高度,
|
||||
// 通过 keyboard-spacer 把输入框顶起, 行为与小程序一致(否则键盘会遮住输入框/输入区)。
|
||||
this._setupH5KeyboardObserver()
|
||||
// #endif
|
||||
},
|
||||
watch: {
|
||||
messages: {
|
||||
@@ -962,6 +1033,47 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// #ifdef H5
|
||||
// H5 软键盘高度检测: 监听 visualViewport(优先) / window.resize(降级)。
|
||||
// 键盘弹起时可视区域高度变小, 差值即为键盘高度, 写入 keyboardHeight 由视图垫高层顶起输入框。
|
||||
_setupH5KeyboardObserver() {
|
||||
try {
|
||||
const vv = window.visualViewport
|
||||
if (vv && typeof vv.addEventListener === 'function') {
|
||||
this._h5KbOnResize = () => {
|
||||
const kb = Math.max(0, window.innerHeight - vv.height - (vv.offsetTop || 0))
|
||||
this.updateKeyboardHeight(kb)
|
||||
}
|
||||
vv.addEventListener('resize', this._h5KbOnResize)
|
||||
vv.addEventListener('scroll', this._h5KbOnResize)
|
||||
// 初始记录一次基准高度
|
||||
this._h5KbOnResize()
|
||||
} else {
|
||||
this._h5WinH = window.innerHeight
|
||||
this._h5KbOnResize = () => {
|
||||
const kb = Math.max(0, this._h5WinH - window.innerHeight)
|
||||
this.updateKeyboardHeight(kb)
|
||||
}
|
||||
window.addEventListener('resize', this._h5KbOnResize)
|
||||
}
|
||||
} catch (e) { /* H5 键盘监听失败不阻断输入 */ }
|
||||
},
|
||||
|
||||
_teardownH5KeyboardObserver() {
|
||||
try {
|
||||
const vv = window.visualViewport
|
||||
if (vv && this._h5KbOnResize) {
|
||||
vv.removeEventListener('resize', this._h5KbOnResize)
|
||||
vv.removeEventListener('scroll', this._h5KbOnResize)
|
||||
}
|
||||
if (this._h5KbOnResize) {
|
||||
window.removeEventListener('resize', this._h5KbOnResize)
|
||||
}
|
||||
} catch (e) { /* noop */ }
|
||||
this._h5KbOnResize = null
|
||||
},
|
||||
// #endif
|
||||
|
||||
// 【新增:会话 ID 本地缓存方法】
|
||||
saveConversationIdToLocal(convId) {
|
||||
if (convId) {
|
||||
@@ -2727,7 +2839,106 @@ _handleStreamError(msg) {
|
||||
} catch (e) { /* noop */ }
|
||||
},
|
||||
|
||||
// 语音按钮点击: 复用 showVoicePanel 流程 (若需要触发工具面板里的语音入口)
|
||||
// ==================== PR-FRONTEND-INPUT-3STATES (2026-09-17) ====================
|
||||
// 进入语音模式(按住说话): 收起键盘, 输入区从"单行胶囊"切成"按住说话 + 工具栏"
|
||||
enterVoiceMode() {
|
||||
if (this.voiceMode) return
|
||||
try { uni.hideKeyboard() } catch (e) { /* noop */ }
|
||||
this.voiceMode = true
|
||||
this.voiceHolding = false
|
||||
this.inputFocused = false
|
||||
},
|
||||
|
||||
// 退出语音模式, 回到文字输入态
|
||||
exitVoiceMode() {
|
||||
this.voiceMode = false
|
||||
this.voiceHolding = false
|
||||
},
|
||||
|
||||
// 按住说话: 按下 (文案切"松开发送" + 高亮)
|
||||
onVoiceHoldStart() {
|
||||
this.voiceHolding = true
|
||||
},
|
||||
|
||||
// 按住说话: 松开
|
||||
// 项目当前没有录音 + 语音识别实现, 松开后复用原有「语音输入」面板入口, 保证功能不丢。
|
||||
// H5 鼠标(桌面预览)不会触发 touch 事件, 所以 click 也绑到同一个方法; showVoicePanel 幂等, 重复调用无副作用。
|
||||
onVoiceHoldEnd() {
|
||||
this.voiceHolding = false
|
||||
this.showVoicePanel()
|
||||
},
|
||||
|
||||
// 按住说话: 手势被系统打断 (来电/滑动取消)
|
||||
onVoiceHoldCancel() {
|
||||
this.voiceHolding = false
|
||||
},
|
||||
|
||||
// 输入框聚焦 / 失焦: 驱动"文字输入态"布局
|
||||
onInputFocus() {
|
||||
// 聚焦时取消尚未触发的失焦收起定时器(处理 H5 布局变化导致的瞬时 blur->focus)
|
||||
if (this._blurTimer) {
|
||||
clearTimeout(this._blurTimer)
|
||||
this._blurTimer = null
|
||||
}
|
||||
this._focusTs = Date.now()
|
||||
// 幻影失焦恢复触发的 focus 不重置计数, 避免失去防循环保护
|
||||
if (!this._refocusing) this._refocusCount = 0
|
||||
this.inputFocused = true
|
||||
if (this.voiceMode) this.voiceMode = false
|
||||
},
|
||||
|
||||
// PR-FRONTEND-H5-INPUT-FOCUS (2026-09-17): 按下阶段预展开
|
||||
// H5 上聚焦会触发布局从中途展开(语音图标移除/工具栏行插入), 该变动会导致 textarea 被动失焦,
|
||||
// 表现为"短暂展开后立刻塌回胶囊, 无法输入"。在 touchstart/mousedown 阶段先置 inputFocused=true,
|
||||
// 让布局在原生 focus 到来之前就完成展开, 聚焦过程中不再有布局变动。
|
||||
onInputPointerDown() {
|
||||
if (this._blurTimer) {
|
||||
clearTimeout(this._blurTimer)
|
||||
this._blurTimer = null
|
||||
}
|
||||
this._lastInputPointerTs = Date.now()
|
||||
if (!this.voiceMode && !this.inputFocused) this.inputFocused = true
|
||||
},
|
||||
|
||||
onInputBlur() {
|
||||
// 延迟收起: 先让工具栏按钮的 click 派发完, 避免点"发送/加号"时布局先塌回胶囊。
|
||||
// 同时防止 H5 因布局变化(出现/隐藏工具栏行)触发的瞬时 blur 误将输入态收起:
|
||||
// 到时若输入框仍是真实焦点(document.activeElement 仍是本 textarea), 则不收起。
|
||||
// #ifdef H5
|
||||
if (this._blurTimer) clearTimeout(this._blurTimer)
|
||||
this._blurTimer = setTimeout(() => {
|
||||
this._blurTimer = null
|
||||
const el = this.$refs.messageInput && this.$refs.messageInput.$el
|
||||
const ta = el && el.querySelector ? el.querySelector('textarea') : null
|
||||
const stillFocused = ta
|
||||
? (document.activeElement === ta || el.contains(document.activeElement))
|
||||
: false
|
||||
if (stillFocused) return
|
||||
// 幻影失焦恢复: 聚焦后极短时间内(键盘弹起/布局重排导致)失焦, 且用户刚在输入框上按下,
|
||||
// 说明用户意图是输入而非点别处 —— 主动把焦点拉回, 不塌回胶囊。最多恢复 2 次防死循环。
|
||||
const withinBounceWindow = this._focusTs && (Date.now() - this._focusTs < 800)
|
||||
const tappedInside = this._lastInputPointerTs && (Date.now() - this._lastInputPointerTs < 800)
|
||||
if (withinBounceWindow && tappedInside && (this._refocusCount || 0) < 2) {
|
||||
this._refocusCount = (this._refocusCount || 0) + 1
|
||||
this._refocusing = true
|
||||
this._focusTs = Date.now()
|
||||
try { ta && ta.focus({ preventScroll: true }) } catch (e) { try { ta && ta.focus() } catch (e2) { /* noop */ } }
|
||||
setTimeout(() => { this._refocusing = false }, 0)
|
||||
return
|
||||
}
|
||||
this.inputFocused = false
|
||||
}, 200)
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
if (this._blurTimer) clearTimeout(this._blurTimer)
|
||||
this._blurTimer = setTimeout(() => {
|
||||
this._blurTimer = null
|
||||
this.inputFocused = false
|
||||
}, 200)
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 语音按钮点击: 兼容旧入口 (打开语音输入面板)
|
||||
onVoiceClick() {
|
||||
try {
|
||||
this.showVoicePanel && this.showVoicePanel()
|
||||
@@ -3884,10 +4095,6 @@ _handleStreamError(msg) {
|
||||
.voice-icon-bg {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTAgMTIgaDYgbDYgLTQgdjE2IGwtNiAtNCBoLTYgYTIgMiAwIDAgMSAtMiAtMiB2LTQgYTIgMiAwIDAgMSAyIC0yIHoiLz48cGF0aCBkPSJNMjQgMTAgYzIgMyAyIDkgMCAxMiIvPjwvc3ZnPg==");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3974,58 +4181,194 @@ _handleStreamError(msg) {
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.top-plus-btn {
|
||||
flex-shrink: 0;
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.plus-icon {
|
||||
font-size: 52rpx;
|
||||
color: #1f1f1f;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
/* ===== PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态 =====
|
||||
① 默认(未聚焦无文字): 单行胶囊 .input-row-top
|
||||
② 语音模式 .is-voice-mode: 「按住说话」+ 工具栏行(表情/模型/加号)
|
||||
③ 文字输入 .is-text-mode: 输入框一行 + 工具栏行(语音/模型/加号/发送) */
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
/* 语音图标 / 键盘图标 (base64 SVG: 小程序不支持内联 SVG) */
|
||||
.voice-icon-bg {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTAgMTIgaDYgbDYgLTQgdjE2IGwtNiAtNCBoLTYgYTIgMiAwIDAgMSAtMiAtMiB2LTQgYTIgMiAwIDAgMSAyIC0yIHoiLz48cGF0aCBkPSJNMjQgMTAgYzIgMyAyIDkgMCAxMiIvPjwvc3ZnPg==");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
/* 键盘图标: 语音模式下点击切回文字输入 */
|
||||
.emoji-icon-bg {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB4PSIzLjUiIHk9IjkiIHdpZHRoPSIyNSIgaGVpZ2h0PSIxNSIgcng9IjIuNSIvPjxwYXRoIGQ9Ik04IDE0aC4wMU0xMi41IDE0aC4wMU0xNyAxNGguMDFNMjEuNSAxNGguMDFNOCAxOC41aC4wMU0xMiAxOC41aC4wMU0xNiAxOC41aC4wMU0yMCAxOC41aC4wMU0yNCAxOC41aC4wMU0xMCAyMi41aDEyIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLC0xKSIvPjwvc3ZnPg==");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
/* 展开态: 卡片变白底圆角容器, 内部输入行不再单独成胶囊 */
|
||||
.input-card {
|
||||
&.is-voice-mode,
|
||||
&.is-text-mode {
|
||||
background: #ffffff;
|
||||
border-radius: 28rpx;
|
||||
padding: 14rpx 16rpx 6rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.top-send-btn {
|
||||
&.is-voice-mode .input-row-top,
|
||||
&.is-text-mode .input-row-top {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
min-height: 76rpx;
|
||||
padding: 6rpx 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 语音模式: 按住说话 */
|
||||
.voice-hold-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 96rpx;
|
||||
margin: 4rpx 0 8rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f2f2f3;
|
||||
|
||||
&.is-holding {
|
||||
background: #e2e2e4;
|
||||
}
|
||||
|
||||
.voice-hold-text {
|
||||
font-size: 30rpx;
|
||||
color: #1f1f1f;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部工具栏行: 语音模式 / 文字输入态 */
|
||||
.input-toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 76rpx;
|
||||
padding: 0 2rpx 4rpx;
|
||||
|
||||
.toolbar-round-btn {
|
||||
flex-shrink: 0;
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 50%;
|
||||
background: #1f1f1f;
|
||||
border: none;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
|
||||
.send-plane-text {
|
||||
color: #ffffff;
|
||||
font-size: 34rpx;
|
||||
.toolbar-flex-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 模型选择器 (从输入框内部移到工具栏行) */
|
||||
.model-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
height: 44rpx;
|
||||
padding: 0 6rpx;
|
||||
|
||||
.model-icon-circle {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #666666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
.model-icon-text {
|
||||
font-size: 18rpx;
|
||||
color: #666666;
|
||||
line-height: 1;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 26rpx;
|
||||
color: #1f1f1f;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
transform: rotate(-90deg); // 箭头向上,呈向上纸飞机
|
||||
display: inline-block;
|
||||
margin-left: -2rpx; // 视觉居中
|
||||
margin-top: -2rpx;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
.model-arrow {
|
||||
font-size: 22rpx;
|
||||
color: #999999;
|
||||
line-height: 1;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 默认态右侧操作按钮: + / 发送 (展开态工具栏行共用同一套样式) */
|
||||
.top-plus-btn {
|
||||
flex-shrink: 0;
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
|
||||
.plus-icon {
|
||||
font-size: 52rpx;
|
||||
color: #1f1f1f;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.top-send-btn {
|
||||
flex-shrink: 0;
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 50%;
|
||||
background: #1f1f1f;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
|
||||
.send-plane-text {
|
||||
color: #ffffff;
|
||||
font-size: 34rpx;
|
||||
line-height: 1;
|
||||
transform: rotate(-90deg); // 箭头向上,呈向上纸飞机
|
||||
display: inline-block;
|
||||
margin-left: -2rpx; // 视觉居中
|
||||
margin-top: -2rpx;
|
||||
}
|
||||
|
||||
/* 无可发送内容时置灰 (仍有 sendMessage 内部兜底拦截) */
|
||||
&.is-disabled {
|
||||
background: #e6e6e8;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部工具栏: 5 个图标按钮均匀分布 */
|
||||
.input-toolbar {
|
||||
display: flex;
|
||||
|
||||
@@ -160,23 +160,56 @@ export default {
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 选择视频并上传 (H5 / 微信小程序 / App)
|
||||
// 选择「视频/音频」并上传 (H5 / 微信小程序 / App)
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): 原来 H5 只写 accept='video/*',
|
||||
// 系统文件选择器会把本地 .mp3/.wav 等音频文件全部过滤掉, 用户看不到也选不了音频。
|
||||
// 现在 accept 同时包含 audio/*, 选中后按 mime / 扩展名自动判定 kind (video | audio)。
|
||||
selectVideo() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const videoInput = document.createElement('input')
|
||||
videoInput.type = 'file'
|
||||
videoInput.accept = 'video/*'
|
||||
videoInput.onchange = async (e) => {
|
||||
const mediaInput = document.createElement('input')
|
||||
mediaInput.type = 'file'
|
||||
mediaInput.accept = 'video/*,audio/*'
|
||||
mediaInput.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'video')
|
||||
await that._uploadOneFile(file, that._guessMediaKind(file))
|
||||
}
|
||||
videoInput.click()
|
||||
mediaInput.click()
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// #ifdef MP-WEIXIN || APP-PLUS
|
||||
// 小程序/App 没有"音视频二合一"的选择器, 用二次弹窗让用户明确选视频还是音频
|
||||
uni.showActionSheet({
|
||||
itemList: [
|
||||
that.$lang('ai-chat.index.pickVideo', '选择视频'),
|
||||
that.$lang('ai-chat.index.pickAudio', '选择音频')
|
||||
],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) that._chooseVideo()
|
||||
else if (res.tapIndex === 1) that.selectAudio()
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 按 mime / 扩展名判断选中的文件是视频还是音频
|
||||
_guessMediaKind(file) {
|
||||
const type = (file && file.type ? file.type : '').toLowerCase()
|
||||
if (type.indexOf('audio/') === 0) return 'audio'
|
||||
if (type.indexOf('video/') === 0) return 'video'
|
||||
const ext = String((file && file.name) || '').split('.').pop().toLowerCase()
|
||||
const audioExt = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid']
|
||||
if (audioExt.indexOf(ext) !== -1) return 'audio'
|
||||
// 兜底: mime 为空且扩展名不认识的 (如 Windows 未关联的音频) 交给后端按 URL 判断, 先按音频走
|
||||
return type ? 'video' : 'audio'
|
||||
},
|
||||
|
||||
// 选视频 (微信小程序 / App 共用)
|
||||
_chooseVideo() {
|
||||
const that = this
|
||||
uni.chooseVideo({
|
||||
sourceType: ['album', 'camera'],
|
||||
compressed: true,
|
||||
@@ -197,25 +230,6 @@ export default {
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
uni.chooseVideo({
|
||||
sourceType: ['album', 'camera'],
|
||||
compressed: true,
|
||||
maxDuration: 60,
|
||||
success: async (res) => {
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile({
|
||||
path: res.tempFilePath,
|
||||
size: res.size || 0,
|
||||
duration: res.duration || 0,
|
||||
name: 'video_' + Date.now() + '.mp4',
|
||||
type: 'video/mp4'
|
||||
}, 'video')
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
// “语音/音频”: 提供 录制语音 / 上传音频 两种方式
|
||||
@@ -334,9 +348,12 @@ export default {
|
||||
// 传给 uploadImage/uploadMedia: H5 传 file 对象 (media-upload 内部 pathToFile 转),
|
||||
// 非 H5 传 file.path (uni.uploadFile 需要)
|
||||
const inputArg = blobUrl || file.path || file
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传的是 blob URL (无扩展名),
|
||||
// 把原始 name/mime/size 传给 media-upload, 保证 STS 上传 key 保留 .mp3/.mp4 后缀,
|
||||
// 否则云端存成 .bin + application/octet-stream, 音频/视频播放器拿不到正确 Content-Type
|
||||
const uploaded = kind === 'image'
|
||||
? await uploadImage(inputArg)
|
||||
: await uploadMedia(inputArg, kind)
|
||||
: await uploadMedia(inputArg, kind, { name: file.name, mime: file.type, size: file.size })
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploaded', uploaded)
|
||||
if (uploaded.url) {
|
||||
|
||||
Reference in New Issue
Block a user