FrontTools/InputArea/InputArea.vue
Rain 3ab1f2db24 [入库]初次入库各种工具
- DBManager DB创建管理器
- FormatTimeTool
- ImageViewer工具
- InputArear工具
- OverlayPage工具
- 下拉刷新容器工具
- 视频查看器工具
2026-04-02 16:09:00 +08:00

742 lines
21 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="input-wrapper" :style="{ '--main-color': themeColor , '--bar-height' :barHeight , 'padding-bottom': dynamicBottom }">
<!-- 引用条 + 输入框纵向 flex引用条在上输入框在下 -->
<view class="input-bar-group">
<!-- 引用回复条 -->
<view v-if="isReplyValid" class="reply-bar" :style="{ '--dynamic-radius': dynamicRadius + 'rpx' }" @touchstart.prevent>
<view class="reply-bar-inner">
<view class="reply-text-wrap">
<text class="reply-name">{{ replyDisplayName }}</text>
<text class="reply-content">{{ replyDisplayContent }}</text>
</view>
</view>
</view>
<!-- 取消引用 -->
<view v-if="isReplyValid" class="reply-seam" @touchstart.prevent="clearReplyRef">
<view class="reply-seam-line" />
<text class="reply-seam-text">取消引用</text>
</view>
<view
class="input-container"
:class="{
'has-reply': isReplyValid,
'mode-longpress': isLongPressing,
'mode-cancelling': isLongPressing && isCancelling
}"
:style="{
'--dynamic-radius': dynamicRadius + 'rpx',
height: containerHeightRpx + 'rpx'
}"
@tap.stop
>
<!-- 左侧图标 -->
<view
class="toggle-wrapper"
:class="{ 'node-hidden': isLongPressing, 'btn-press': leftBtnPressed }"
v-if="showLeftBtn"
@touchstart="leftBtnPressed = true"
@touchend="leftBtnPressed = false"
@touchcancel="leftBtnPressed = false"
@tap="LeftButtonHandle"
>
<image class="toggle-icon" :src="LeftIcon" />
</view>
<!-- 中间触发区域 -->
<view
class="input-area"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
>
<textarea
class="input-text"
:class="{ 'input-hidden': isLongPressing, 'is-scrollable': !shouldAutoHeight }"
v-model="localInputText"
:adjust-position="false"
:hold-keyboard="holdKeyboard"
:focus="inputFocus"
:confirm-type="confirmType"
:auto-height="shouldAutoHeight"
:fixed="true"
:maxlength="-1"
:style="{
fontSize: textareaFontSize + 'rpx',
...(!shouldAutoHeight ? { height: MAX_TEXTAREA_HEIGHT + 'rpx' } : {})
}"
@focus="onFocus"
@blur="onBlur"
@confirm="handleConfirm"
@linechange="onLineChange"
:placeholder="placeholder"
placeholder-class="input-placeholder"
/>
<!-- 长按激活时的遮罩/提示 -->
<view v-if="isLongPressing" class="longpress-overlay">
<text class="longpress-tip">
{{ isCancelling ? cancelTip : longPressTip }}
</text>
</view>
</view>
<!-- 右侧按钮区域 -->
<view
class="attachment-btn"
:class="{ 'is-send': hasText, 'node-hidden': isLongPressing, 'btn-press': rightBtnPressed }"
@touchstart.prevent="rightBtnPressed = true; handleRightBtnTap()"
@touchend="rightBtnPressed = false"
@touchcancel="rightBtnPressed = false"
>
<image class="icon send-icon" :src="sendIcon"></image>
<image class="icon attachment-icon" :src="attachmentIcon"></image>
</view>
</view>
</view>
<!-- 悬浮面板 -->
<floatpanel
:visible="innerShowPanel"
:buttons="buttons"
:height="panelHeight"
:bottomOffset="panelBottomOffset"
:horizontalPadding="panelPadding"
@handleTap="onPanelSelect"
>
<template v-if="hasCustomPanel" #default>
<slot name="panel-content"></slot>
</template>
</floatpanel>
<!-- 遮罩层点击空白处关闭面板 -->
<view
v-if="innerShowPanel"
class="panel-mask"
@tap="setPanelVisible(false)"
/>
</view>
</template>
<script setup>
import { ref, computed, nextTick, watch, useSlots , onMounted , onUnmounted } from 'vue'
import floatpanel from './floatPanel.vue'
const slots = useSlots()
const props = defineProps({
// --- 内容与状态 ---
modelValue: { type: String, default: '' }, // 支持 v-model 直接绑定文字内容
showPanel: { type: Boolean, default: false }, // 支持 v-model:showPanel
/** 引用回复:外部传入任意对象,内部仅要求包含 content(string) 与 Name/name有效时才显示引用条支持双向 */
replyRef: { type: [Object, null], default: null },
/** 外部控制的焦点状态 */
focus: { type: Boolean, default: false },
// --- 提示文字 ---
placeholder: { type: String, default: '点击输入或按住说话....' },
longPressTip: { type: String, default: '正在录音,上滑取消' },
cancelTip: { type: String, default: '松开手指,取消发送' },
// --- 样式配置 ---
themeColor: { type: String, default: '#a846e6' },
barHeight: { type: String, default: '80rpx' },
padBottom: { type: String, default: '4rpx' },
LeftIcon: { type: String, default: '/static/Mic.png' },
sendIcon: { type: String, default: '/static/send.png' },
attachmentIcon: { type: String, default: '/static/Attachment.png' },
// --- 功能开关 ---
showLeftBtn: { type: Boolean, default: true }, // 是否显示左侧图标
// --- 面板配置 ---
buttons: { type: Array },
panelHeight: { type: String, default: '100rpx' },
panelBottomOffset: { type: String, default: '10rpx' },
panelPadding: { type: String, default: '15rpx' },
// --- 交互参数 ---
longPressDelay: { type: Number, default: 200}, // 长按判定时间(ms) -- 这个数值不建议高于500 高于会触发长按聚焦文字框
cancelThreshold: { type: Number, default: 100 }, // 上滑取消判定距离(px)
// --- 原生 Input 属性 ---
adjustPosition: { type: Boolean, default: false },
holdKeyboard: { type: Boolean, default: true },
confirmType: { type: String, default: 'send' }
})
const emit = defineEmits([
'update:modelValue',
'update:showPanel',
'update:replyRef',
'longPressStart',
'longPressEnd',
'longPressCancel',
'send',
'panelClick',
'leftButtonClick',
'focus',
'blur',
'keyboardHeightChange'
])
// --- 内部变量 ---
const inputFocus = ref(false)
const isLongPressing = ref(false)
const isCancelling = ref(false)
const startY = ref(0)
const lastMoveY = ref(0) // 记录最后一次移动的 Y 坐标
const longPressTimer = ref(null)
const focusLock = ref(false)
const didTouchMove = ref(false) // 是否发生了滑动(用于区分点击聚焦 / 滑动松手)
const leftBtnPressed = ref(false)
const rightBtnPressed = ref(false)
const keyboardHeight = ref(0)
// 监听外部传入的 focus prop立即同步以提高响应速度
watch(() => props.focus, (val) => {
if (val !== inputFocus.value) {
inputFocus.value = val
// 如果外部设置为 false触发 blur 事件
if (!val) {
emit('blur')
}
}
}, { immediate: true })
// 双向绑定输入框内容
const localInputText = ref(props.modelValue)
watch(() => props.modelValue, (val) => localInputText.value = val)
watch(localInputText, (val) => emit('update:modelValue', val))
// 双向绑定面板显示状态
const innerShowPanel = ref(props.showPanel)
watch(() => props.showPanel, (val) => innerShowPanel.value = val)
const setPanelVisible = (visible) => {
innerShowPanel.value = visible
emit('update:showPanel', visible)
}
// 引用回复:仅当包含 content(string) 与 Name/name 时视为有效
const isReplyValid = computed(() => {
const r = props.replyRef
if (!r || typeof r !== 'object') return false
const hasContent = typeof r.content === 'string'
const hasName = typeof (r.Name ?? r.name) === 'string'
return hasContent && hasName
})
const replyDisplayName = computed(() => {
if (!isReplyValid.value) return ''
const r = props.replyRef
return (r.Name ?? r.name) ?? ''
})
const replyDisplayContent = computed(() => {
if (!isReplyValid.value) return ''
const raw = props.replyRef?.content ?? ''
const oneLine = String(raw).replace(/\s*[\r\n]+\s*/g, ' ').trim()
return oneLine.length > 40 ? oneLine.slice(0, 40) + '…' : oneLine
})
const clearReplyRef = () => {
emit('update:replyRef', null)
}
// 逻辑计算
const hasCustomPanel = computed(() => !!slots['panel-content'])
const hasText = computed(() => localInputText.value.trim().length > 0)
// 输入区高度(rpx)
const textareaHeightRpx = ref(68)
// 从 barHeight prop 解析出数值(去掉 'rpx' 后缀)
const PILL_HEIGHT = computed(() => {
const heightStr = props.barHeight.replace('rpx', '')
return parseInt(heightStr) || 80
})
// 字体大小跟随 barHeight 等比例缩放(默认 80rpx 对应 28rpx
const textareaFontSize = computed(() => {
const baseHeight = 80
const baseFontSize = 28
const ratio = PILL_HEIGHT.value / baseHeight
return Math.round(baseFontSize * ratio)
})
const MAX_TEXTAREA_HEIGHT = 300 // textarea 超过此高度时变为可滚动
const MAX_HEIGHT = 16 + MAX_TEXTAREA_HEIGHT // 容器最大高度 = 内边距 + textarea高度
const PILL_RADIUS = 50
const RECT_RADIUS = 36
// 当高度超过阈值时 - 输入区域变成可滚动
const shouldAutoHeight = computed(() => textareaHeightRpx.value < MAX_TEXTAREA_HEIGHT)
// 容器高度rpx
const containerHeightRpx = computed(() => {
// 最小高度 = barHeight - padding (上下各6rpx = 12rpx)
const minInnerH = PILL_HEIGHT.value - 12
const innerH = Math.max(minInnerH, textareaHeightRpx.value)
const h = 12 + innerH // 上下 padding 各 6rpx
return Math.min(MAX_HEIGHT, Math.max(PILL_HEIGHT.value, h))
})
const dynamicRadius = computed(() => {
const containerH = containerHeightRpx.value
const pillH = PILL_HEIGHT.value
if (containerH <= pillH) return PILL_RADIUS
if (containerH >= MAX_HEIGHT) return RECT_RADIUS
return Math.round(PILL_RADIUS - (containerH - pillH) * (PILL_RADIUS - RECT_RADIUS) / (MAX_HEIGHT - pillH))
})
const dynamicBottom = computed(() => {
if (keyboardHeight.value > 0) {
// 键盘弹出时:直接使用键盘的物理像素高度 + 你自己的下边距
return `calc(${keyboardHeight.value}px + ${props.padBottom})`
} else {
// 键盘收起时:回归底部,兼容全面屏小黑条
return `calc(env(safe-area-inset-bottom) + ${props.padBottom})`
}
})
const onLineChange = (e) => {
let { height, heightRpx } = e.detail || {}
// 部分平台只有 height(px),需转换为 rpx
if ((!heightRpx || heightRpx <= 0) && height > 0) {
const winWidth = uni.getSystemInfoSync().windowWidth || 375
heightRpx = Math.round(height * 750 / winWidth)
}
// if (heightRpx > 0) textareaHeightRpx.value = heightRpx
if (heightRpx > 0) {
textareaHeightRpx.value = heightRpx;
// 触发高度变化事件,通知父组件滚动列表
nextTick(() => {
emit('heightChange');
});
}
}
// 清空时重置高度
watch(localInputText, (val) => {
if (!val.trim()) {
// 重置为 barHeight - padding (上下各6rpx = 12rpx)
textareaHeightRpx.value = PILL_HEIGHT.value - 12
}
})
// --- 事件处理 ---
const handleTouchStart = (e) => {
didTouchMove.value = false
if (focusLock.value || hasText.value || inputFocus.value) return
const touchY = e.touches[0].clientY
startY.value = touchY
lastMoveY.value = touchY // 初始化为起始位置
longPressTimer.value = setTimeout(() => {
isLongPressing.value = true
isCancelling.value = false
uni.vibrateShort();
emit('longPressStart');
}, props.longPressDelay)
}
const handleTouchMove = (e) => {
if (!isLongPressing.value) {
didTouchMove.value = true // 发生滑动touchend 时不要触发聚焦
return // 不拦截,让 textarea 能正常滚动
}
e.preventDefault()
const moveY = e.touches[0].clientY
lastMoveY.value = moveY
isCancelling.value = (startY.value - moveY > props.cancelThreshold)
}
const handleTouchEnd = () => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
if (isLongPressing.value) {
const shouldCancel = (startY.value - lastMoveY.value > props.cancelThreshold)
emit(shouldCancel ? 'longPressCancel' : 'longPressEnd')
isLongPressing.value = false
isCancelling.value = false
focusLock.value = true
setTimeout(() => { focusLock.value = false }, 200) // 减少锁定时间,提高响应速度
} else {
// 只有真正的点击(无滑动)才触发聚焦,滑动松手不触发
// 优化:如果外部已经设置了 focus或者没有滑动则触发聚焦
if (!focusLock.value && !didTouchMove.value) {
// 如果外部传入的 focus 为 true直接同步内部状态
if (props.focus) {
inputFocus.value = true
} else if (!inputFocus.value) {
// 只有在未聚焦时才触发聚焦
nextTick(() => {
inputFocus.value = true
emit('focus')
})
}
}
// 重置滑动标志
didTouchMove.value = false
}
}
const onFocus = () => {
inputFocus.value = true
emit('focus');
}
const onBlur = () => {
inputFocus.value = false
emit('blur'); // 触发 blur 事件,让父组件知道焦点已失去
}
const handleRightBtnTap = () => {
if (hasText.value) {
emit('send', { type: 'text', content: localInputText.value })
localInputText.value = ''
} else {
setPanelVisible(!innerShowPanel.value)
}
}
// 发送键
const handleConfirm = () => {
if (hasText.value) {
emit('send', { type: 'text', content: localInputText.value })
localInputText.value = ''
}
}
const onPanelSelect = (item) => {
emit('panelClick', item.button)//返回对应的button数据
setTimeout(() => setPanelVisible(false), 150)
}
const LeftButtonHandle = () => {
emit('leftButtonClick');
}
onMounted(() => {
uni.onKeyboardHeightChange(res => {
keyboardHeight.value = res.height;
emit('keyboardHeightChange', res.height);
});
});
onUnmounted(() => {
uni.offKeyboardHeightChange();
});
</script>
<style lang="scss" scoped>
$paddingBottom : var(--paddingBottom);
$MainColor: var(--main-color);
.input-wrapper {
width: 100%;
box-sizing: border-box;
position: relative;
flex-shrink: 0;
z-index: 999;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 12rpx;
transition: padding-bottom 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* 引用条 + 输入框的纵向容器 */
.input-bar-group {
display: flex;
flex-direction: column;
width: 100%;
align-items: stretch;
}
/* 引用回复条*/
.reply-bar {
width: 100%;
box-sizing: border-box;
background-color: rgba(38, 38, 38, 0.6);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border: 1px solid rgba(255, 255, 255, 0.05);
border-bottom: none;
border-radius: var(--dynamic-radius, 50rpx) var(--dynamic-radius, 50rpx) 0 0;
margin-bottom: -1rpx;
padding: 8rpx 10rpx 10rpx;
}
.reply-bar-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
}
.reply-text-wrap {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
}
/* Name */
.reply-name {
font-size: 20rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.42);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
text-align: center;
}
/* Content */
.reply-content {
font-size: 28rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.88);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
text-align: center;
max-width: 100%;
}
/* 引用内容渐变线 */
.reply-seam-line {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
to right,
transparent 0%,
transparent 28%,
rgba(255, 255, 255, 0.2) 48%,
rgba(255, 255, 255, 0.2) 52%,
transparent 72%,
transparent 100%
);
}
/* 接缝 + 「取消引用」文字 */
.reply-seam {
position: relative;
width: 100%;
box-sizing: border-box;
background-color: rgba(38, 38, 38, 0.6);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-left: 1px solid rgba(255, 255, 255, 0.05);
border-right: 1px solid rgba(255, 255, 255, 0.05);
padding: 5rpx 10rpx 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.reply-seam-text {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.4);
}
.input-container {
display: flex;
box-sizing: border-box;
align-items: center;
width: 100%;
min-height: var(--bar-height);
max-height: 360rpx;
background-color: rgba(38, 38, 38, 0.6);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-radius: var(--dynamic-radius, 50rpx);
padding-top: 6rpx;
padding-bottom: 6rpx;
padding-left: 6rpx;
padding-right: 6rpx;
border: 1px solid rgba(255, 255, 255, 0.05);
/* 高度、圆角、背景色 带缓动过渡 */
transition: height 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94),
border-radius 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94),
background-color 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
backdrop-filter 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
overflow: hidden;
&.has-reply {
border-radius: 0 0 var(--dynamic-radius, 36rpx) var(--dynamic-radius, 36rpx);
}
&.mode-longpress {
background-color: var(--main-color) !important;
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
&.mode-cancelling {
background-color: rgba(255, 77, 79, 0.8) !important;
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
}
/* 长按时两侧按钮的平滑缩小/隐藏(带过渡动效) */
.node-hidden {
width: 0 !important;
height: 0 !important;
opacity: 0 !important;
margin: 0 !important;
padding: 0 !important;
transform: scale(0);
pointer-events: none;
}
.toggle-wrapper {
height: calc(var(--bar-height) - 12rpx);
width: calc(var(--bar-height) - 12rpx);
aspect-ratio: 1;
border-radius: 999rpx;
background-color: $MainColor;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
/* 按下缩放 + 长按隐藏时的缩小动效 */
transition: width 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
margin 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
padding 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&.btn-press {
transform: scale(0.92);
}
.toggle-icon {
height: 70%;
width: 70%;
}
}
.input-area {
flex: 1;
margin: 0 8rpx;
display: flex;
align-items: center;
position: relative;
overflow: visible;
min-width: 0;
min-height: calc(var(--bar-height) - 12rpx);
max-height: 300rpx;
.input-placeholder {
color: #999999;
font-weight: 400;
}
.input-text {
width: 100%;
font-weight: 800;
color: #ffffff;
transition: opacity 0.2s;
line-height: 1.3;
box-sizing: border-box;
padding: 0;
&.input-hidden {
opacity: 0;
visibility: hidden;
}
/* 超过最大高度后变为可滚动 */
&.is-scrollable {
overflow-y: auto !important;
}
}
}
.longpress-overlay {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
.longpress-tip {
font-size: 30rpx;
color: #ffffff;
font-weight: bold;
}
}
.attachment-btn {
--offset: 6%;
width: calc(var(--bar-height) - 12rpx);
height: calc(var(--bar-height) - 12rpx);
border-radius: 999rpx;
background-color: rgba(255, 255, 255, 0.08);
display: flex;
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
flex-shrink: 0;
/* 按下缩放 + 长按隐藏时的缩小动效 + 主题色渐变 */
transition: width 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
margin 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
padding 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
background-color 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
filter 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&.btn-press {
transform: scale(0.92);
}
.icon {
position: absolute;
width: 50%;
height: 50%;
transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.attachment-icon {
opacity: 1;
transform: rotate(0deg) scale(1);
}
.send-icon {
opacity: 0;
transform: rotate(-90deg) scale(0) translateX(var(--offset));
}
&.is-send {
background-color: $MainColor;
filter: brightness(1.1); // 相对主要色变得亮一点
.attachment-icon {
opacity: 0;
transform: rotate(90deg) scale(0);
}
.send-icon {
opacity: 1;
transform: rotate(0deg) scale(1) translateX(var(--offset));
}
}
}
.panel-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 998;
}
</style>