chore:AI智能输入框样式改变

This commit is contained in:
2026-09-17 14:21:28 +08:00
parent 3b4051c775
commit 29699a66fd
6 changed files with 516 additions and 98 deletions

View File

@@ -218,6 +218,10 @@ const localDevConfig = ({
uniacid: 317, uniacid: 317,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
'5g-312': { // 电磁兼容,模板显示不全商品
uniacid: 312,
domain: 'https://5g.aigc-quickapp.com/',
},
// })['5g-237']; // 商品分类,没有立即咨询文字 // })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色 // })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应 // })['5g-50']; // 燃气安全产品机器人,购物车无反应

View File

@@ -38,7 +38,12 @@ const EXT_MIME_MAP = {
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff', bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac', mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg', 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', 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', doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
ppt: 'application/vnd.ms-powerpoint', 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 {string} filePath
* @param {'audio'|'video'|'file'} kind * @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)) { if (!['audio', 'video', 'file'].includes(kind)) {
throw new Error(`invalid kind: ${kind}`) throw new Error(`invalid kind: ${kind}`)
} }
const addon = aiService.getAddonName() const addon = aiService.getAddonName()
const config = await aiService.getServiceConfig() 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): 提到模块顶层, * PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined" * 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
*/ */
function extractExt(filePath, mimeHint = '') { function extractExt(filePath, mimeHint = '', nameHint = '') {
let ext = 'bin' let ext = 'bin'
if (typeof filePath === 'string') { if (typeof filePath === 'string') {
const lastSeg = filePath.split('?')[0].split('/').pop() || '' 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) const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
if (m2) ext = m2[1].toLowerCase() 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 无扩展名但 // 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但
// blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png // blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png
if (ext === 'bin' && mimeHint) { if (ext === 'bin' && mimeHint) {
@@ -263,11 +311,13 @@ function extractExt(filePath, mimeHint = '') {
return ext 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): 用更稳的扩展名提取 // PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL // H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin' // 优先 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) // 1. 拿 STS 凭证 (addon 模式 URL)
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts' const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'

View File

@@ -118,6 +118,8 @@ export const lang = {
voice: 'Voice', voice: 'Voice',
recordVoice: 'Record Voice', recordVoice: 'Record Voice',
uploadAudio: 'Upload Audio', uploadAudio: 'Upload Audio',
pickVideo: 'Choose Video',
pickAudio: 'Choose Audio',
chooseAudioFail: 'Failed to select audio', chooseAudioFail: 'Failed to select audio',
chooseVideoFail: 'Failed to select video', chooseVideoFail: 'Failed to select video',
audioNotReady: 'Audio not ready', audioNotReady: 'Audio not ready',

View File

@@ -118,6 +118,8 @@ export const lang = {
voice: '语音', voice: '语音',
recordVoice: '录制语音', recordVoice: '录制语音',
uploadAudio: '上传音频', uploadAudio: '上传音频',
pickVideo: '选择视频',
pickAudio: '选择音频',
chooseAudioFail: '选择音频失败', chooseAudioFail: '选择音频失败',
chooseVideoFail: '选择视频失败', chooseVideoFail: '选择视频失败',
audioNotReady: '音频尚未就绪', audioNotReady: '音频尚未就绪',

View File

@@ -355,7 +355,7 @@
<!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) --> <!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) -->
<view class="input-area" :style="inputAreaStyle"> <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-if="pendingImages.length" class="pending-attachment">
<view v-for="p in pendingImages" :key="p.localId" class="pending-item"> <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 class="pending-remove" @tap="removePendingImage(p.localId)">×</view>
</view> </view>
</view> </view>
<!-- 顶部输入行: 语音图标 + 输入区 + 加号 + 发送 --> <!-- PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态
<view class="input-row-top"> ① 默认(未输入/未聚焦): 单行胶囊 —— 语音图标 + 输入框 + 加号
<!-- 无文字时左侧显示语音图标,点击才打开语音面板 --> ② 语音模式(点语音图标进入): 「按住说话」按钮 + 底部工具栏(表情/模型/加号)
<view v-if="!hasInputText" class="input-row-icon" @click="onVoiceClick"> ③ 文字输入(聚焦或有文字): 输入框独占一行 + 底部工具栏(语音/模型/加号/发送) -->
<!-- 语音模式: 按住说话 -->
<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 背景图 --> <!-- 微信小程序不支持内联 SVG,改用 base64 SVG 背景图 -->
<view class="voice-icon-bg"></view> <view class="voice-icon-bg"></view>
</view> </view>
<!-- 输入区: textarea 始终可点击输入,有文字时下方显示 Auto 选择器 --> <!-- 输入区: textarea 三态共用同一个节点, 避免切换时丢焦点 -->
<view class="input-left-content" :class="{ 'with-voice-icon': !hasInputText }"> <view key="input-content" class="input-left-content" :class="{ 'with-voice-icon': !showTextMode }">
<textarea <textarea
ref="messageInput"
v-model="inputText" v-model="inputText"
class="message-input" class="message-input"
placeholder="发消息或按住说话" placeholder="发消息或按住说话"
@@ -386,34 +404,58 @@
:maxlength="500" :maxlength="500"
:auto-height="true" :auto-height="true"
:show-confirm-bar="false" :show-confirm-bar="false"
:adjust-position="false" :adjust-position="adjustPosition"
:cursor-spacing="150" :cursor-spacing="150"
@confirm="sendMessage" @confirm="sendMessage"
@input="onInput" @input="onInput"
@touchstart="onInputPointerDown"
@mousedown="onInputPointerDown"
@focus="onInputFocus"
@blur="onInputBlur"
@linechange="onLineChange" /> @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>
<view class="input-right-actions"> <!-- 默认态右侧: 只有加号 -->
<view v-if="!showTextMode" key="right-actions" class="input-right-actions">
<button class="top-plus-btn" @click="showMoreTools"> <button class="top-plus-btn" @click="showMoreTools">
<text class="plus-icon">+</text> <text class="plus-icon">+</text>
</button> </button>
<!-- 有文字或图片附件上传完成时显示发送按钮 -->
<button v-if="hasInputText || canSendPendingImage" class="top-send-btn" @click="sendMessage">
<!-- H5/小程序都兼容:旋转箭头模拟向上的纸飞机 -->
<text class="send-plane-text">➤</text>
</button>
</view> </view>
</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 个图标按钮均匀分布(当前隐藏,保留代码) --> <!-- 底部工具栏: 5 个图标按钮均匀分布(当前隐藏,保留代码) -->
<view class="input-toolbar" style="display: none;"> <view class="input-toolbar" style="display: none;">
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('task')"> <view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('task')">
@@ -586,6 +628,15 @@ export default {
hasMoreHistory: false, hasMoreHistory: false,
loadingText: '', loadingText: '',
voiceInputing: false, 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, audioContext: null,
currentAudio: null, currentAudio: null,
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频播放引擎 // PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频播放引擎
@@ -678,10 +729,25 @@ export default {
}, },
hasInputText() { hasInputText() {
return this.inputText && this.inputText.trim() !== '' 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() { beforeDestroy() {
if (this._blurTimer) clearTimeout(this._blurTimer);
// #ifdef H5
this._teardownH5KeyboardObserver && this._teardownH5KeyboardObserver();
// #endif
if (this.scrollTimer) clearTimeout(this.scrollTimer); if (this.scrollTimer) clearTimeout(this.scrollTimer);
if (this.searchFlashTimer) { if (this.searchFlashTimer) {
clearTimeout(this.searchFlashTimer); clearTimeout(this.searchFlashTimer);
@@ -728,6 +794,11 @@ export default {
// 确保 H5 在组件挂载后也尝试加载历史(兜底) // 确保 H5 在组件挂载后也尝试加载历史(兜底)
this.loadHistoryIfNeeded() this.loadHistoryIfNeeded()
}) })
// #ifdef H5
// H5 下 uni.onKeyboardHeightChange 不会触发, 改用 visualViewport/window 监听键盘高度,
// 通过 keyboard-spacer 把输入框顶起, 行为与小程序一致(否则键盘会遮住输入框/输入区)。
this._setupH5KeyboardObserver()
// #endif
}, },
watch: { watch: {
messages: { 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 本地缓存方法】 // 【新增:会话 ID 本地缓存方法】
saveConversationIdToLocal(convId) { saveConversationIdToLocal(convId) {
if (convId) { if (convId) {
@@ -2727,7 +2839,106 @@ _handleStreamError(msg) {
} catch (e) { /* noop */ } } 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() { onVoiceClick() {
try { try {
this.showVoicePanel && this.showVoicePanel() this.showVoicePanel && this.showVoicePanel()
@@ -3884,10 +4095,6 @@ _handleStreamError(msg) {
.voice-icon-bg { .voice-icon-bg {
width: 36rpx; width: 36rpx;
height: 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; 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 { /* ===== PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态 =====
font-size: 52rpx; ① 默认(未聚焦无文字): 单行胶囊 .input-row-top
color: #1f1f1f; ② 语音模式 .is-voice-mode: 「按住说话」+ 工具栏行(表情/模型/加号)
font-weight: 300; ③ 文字输入 .is-text-mode: 输入框一行 + 工具栏行(语音/模型/加号/发送) */
line-height: 1;
}
&::after { /* 语音图标 / 键盘图标 (base64 SVG: 小程序不支持内联 SVG) */
border: none; .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; flex-shrink: 0;
width: 76rpx; width: 64rpx;
height: 76rpx; height: 64rpx;
border-radius: 50%;
background: #1f1f1f;
border: none;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0; margin-right: 4rpx;
}
.send-plane-text { .toolbar-flex-spacer {
color: #ffffff; flex: 1;
font-size: 34rpx; }
/* 模型选择器 (从输入框内部移到工具栏行) */
.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; line-height: 1;
transform: rotate(-90deg); // 箭头向上,呈向上纸飞机
display: inline-block;
margin-left: -2rpx; // 视觉居中
margin-top: -2rpx;
} }
&::after { .model-arrow {
border: none; 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 个图标按钮均匀分布 */ /* 底部工具栏: 5 个图标按钮均匀分布 */
.input-toolbar { .input-toolbar {
display: flex; display: flex;

View File

@@ -160,23 +160,56 @@ export default {
// #endif // #endif
}, },
// 选择视频并上传 (H5 / 微信小程序 / App) // 选择「视频/音频」并上传 (H5 / 微信小程序 / App)
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): 原来 H5 只写 accept='video/*',
// 系统文件选择器会把本地 .mp3/.wav 等音频文件全部过滤掉, 用户看不到也选不了音频。
// 现在 accept 同时包含 audio/*, 选中后按 mime / 扩展名自动判定 kind (video | audio)。
selectVideo() { selectVideo() {
const that = this const that = this
// #ifdef H5 // #ifdef H5
const videoInput = document.createElement('input') const mediaInput = document.createElement('input')
videoInput.type = 'file' mediaInput.type = 'file'
videoInput.accept = 'video/*' mediaInput.accept = 'video/*,audio/*'
videoInput.onchange = async (e) => { mediaInput.onchange = async (e) => {
const file = e.target.files[0] const file = e.target.files[0]
if (!file) return if (!file) return
that.hideToolsPanel() that.hideToolsPanel()
await that._uploadOneFile(file, 'video') await that._uploadOneFile(file, that._guessMediaKind(file))
} }
videoInput.click() mediaInput.click()
// #endif // #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({ uni.chooseVideo({
sourceType: ['album', 'camera'], sourceType: ['album', 'camera'],
compressed: true, 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 转), // 传给 uploadImage/uploadMedia: H5 传 file 对象 (media-upload 内部 pathToFile 转),
// 非 H5 传 file.path (uni.uploadFile 需要) // 非 H5 传 file.path (uni.uploadFile 需要)
const inputArg = blobUrl || file.path || file 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' const uploaded = kind === 'image'
? await uploadImage(inputArg) ? 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, 'uploading', false)
this.$set(pending, 'uploaded', uploaded) this.$set(pending, 'uploaded', uploaded)
if (uploaded.url) { if (uploaded.url) {