chore:AI智能输入框样式改变
This commit is contained in:
@@ -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