Files
wecom_it_smart_desk/frontend-h5/src/components/chat/InputBox.vue
T
Simon 8c93cc9c9d fix(build): 修复 v0.5.0-beta 前端编译错误
跑 npm run build 验收时发现 2 个前端项目编译失败(vue-tsc 报错),修复 4 处:

frontend-h5:
- src/components/chat/InputBox.vue:185 多余右括号
  computed(() => inputText.value.length))  ->  computed(() => inputText.value.length)
- src/components/chat/MessageList.vue:134 pollMessages 调用签名错
  pollMessages(convId, afterMessageId)   ->  pollMessages(afterMessageId)
  (api/message.ts:71 签名只接 1 个 afterMessageId 参数,endpoint 走 current 不需要 convId)

frontend-agent:
- src/components/chat/InputBox.vue 4 处错
  L91/234/292 conversationStore.loading 不存在(store 暴露的是 loadingMessages)
                     -> conversationStore.loadingMessages
  L136 import onUnmounted 死引用,移除

components.d.ts: 触发 unplugin-vue-components 重新生成 6 行(新组件类型)

验证:
- frontend-h5: vue-tsc 0 错,417 modules transformed, dist/ 生成
- frontend-agent: vue-tsc 0 错,1750 modules transformed, dist/ 生成

不影响业务逻辑,纯 build fix。
2026-06-15 14:26:34 +08:00

678 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<!-- =============================================================================
// 企微IT智能服务台 — H5用户端输入框组件
// =============================================================================
// 说明:底部输入框组件,包含:
// - 输入框默认3行高度,自动扩展(max-height: 150px
// - 底部显示字数统计(当前/最大,如:120/500)
// - 右下角发送按钮(icon)
// - Enter键发送,Shift+Enter换行
// - 空内容时禁用发送按钮
// - 支持粘贴图片上传
// - 支持图片/文件选择上传
// ============================================================================= -->
<template>
<div class="input-box">
<!-- 工具栏表情/图片/文件/拍照/截图 -->
<div class="input-box__toolbar">
<button class="input-box__tool-btn" title="表情" @click="handleEmoji">
<span>😊</span>
</button>
<button class="input-box__tool-btn" title="图片" @click="handleImage">
<span>🖼</span>
</button>
<button class="input-box__tool-btn" title="文件" @click="handleFile">
<span>📎</span>
</button>
<button class="input-box__tool-btn" title="拍照" @click="handleCamera">
<span>📷</span>
</button>
<button class="input-box__tool-btn" title="截图" @click="handleScreenshot">
<span></span>
</button>
<button class="input-box__tool-btn input-box__tool-btn--accent" title="快捷申请" @click="handleQuickApply">
<span>📝</span>
</button>
</div>
<!-- 表情选择面板简易版常用 Emoji 网格 -->
<div v-if="showEmojiPanel" class="emoji-panel">
<div class="emoji-panel__grid">
<button
v-for="emoji in commonEmojis"
:key="emoji"
class="emoji-panel__item"
@click="onEmojiClick(emoji)"
>{{ emoji }}</button>
</div>
</div>
<!-- 输入区域 -->
<div class="input-box__area">
<!-- 文本输入框 默认3行自适应内容高度 -->
<textarea
ref="inputRef"
v-model="inputText"
class="input-box__textarea"
placeholder="请输入消息..."
:rows="3"
:style="{ height: textareaHeight + 'px' }"
:disabled="!store.isLoggedIn"
@keydown="handleEnterKey"
@input="handleInput"
@paste="handlePaste"
></textarea>
<!-- 发送按钮 右下角icon样式 -->
<button
class="input-box__send-btn"
:class="{ 'input-box__send-btn--active': canSend }"
:disabled="!canSend"
:loading="store.loading"
@click="handleSend"
>
<svg v-if="!store.loading" class="send-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15-2-15-2z"/>
</svg>
</button>
</div>
<!-- 字数统计 -->
<div class="input-box__counter">
{{ charCount }}/{{ maxChars }}
</div>
<!-- 底部引导条 -->
<div v-if="store.canCallAgent" class="input-box__guide input-box__guide--active">
🔔 摇铃通道已开启点击标题栏传菜铃呼叫 IT 坐席
</div>
<div v-else class="input-box__guide">
请描述你遇到的问题AI 助手会帮你分析 💡
</div>
<!-- 表情面板打开时的半透明遮罩点击关闭表情面板 -->
<div v-if="showEmojiPanel" class="emoji-panel__overlay" @click="showEmojiPanel = false"></div>
<!-- 隐藏的文件输入框图片/文件上传用由工具栏按钮触发 -->
<input
ref="fileInputRef"
type="file"
style="display: none"
@change="handleFileSelect"
/>
<!-- 隐藏的拍照输入框移动端直接调用摄像头 -->
<input
ref="cameraInputRef"
type="file"
accept="image/*"
capture="environment"
style="display: none"
@change="handleCameraCapture"
/>
<!-- 截图区域选择编辑器 -->
<ScreenshotEditor
v-if="showScreenshotEditor"
:screenshot-canvas="screenshotCanvas"
@confirm="onScreenshotConfirm"
@cancel="onScreenshotCancel"
/>
</div>
</template>
<script setup lang="ts">
/**
* InputBox 输入框组件
* 输入框默认3行高度,自动扩展(max-height: 150px
* 底部显示字数统计,右下角发送按钮
* Enter发送,Shift+Enter换行
*/
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { showToast } from 'vant'
import html2canvas from 'html2canvas-pro'
import { useConversationStore } from '@/stores/conversation'
import { uploadFile } from '@/api/upload'
import ScreenshotEditor from './ScreenshotEditor.vue'
// ============================================================================
// 工具函数:安全提取错误详情
// ============================================================================
function formatErrorDetail(detail: any): string {
if (!detail) return ''
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail.map((d: any) => {
if (d.loc && d.msg) return `${d.loc.slice(1).join('.')}: ${d.msg}`
if (d.msg) return d.msg
return JSON.stringify(d)
}).join('; ')
}
if (typeof detail === 'object') return JSON.stringify(detail)
return String(detail)
}
const store = useConversationStore()
/** 输入框文本 */
const inputText = ref<string>('')
/** 输入框 DOM 引用 */
const inputRef = ref<HTMLTextAreaElement | null>(null)
/** 隐藏文件输入框 DOM 引用 */
const fileInputRef = ref<HTMLInputElement | null>(null)
/** 隐藏拍照输入框 DOM 引用 */
const cameraInputRef = ref<HTMLInputElement | null>(null)
/** 最大字符数 */
const maxChars = 500
/** textarea 高度 */
const textareaHeight = ref(60)
/** 是否显示表情面板 */
const showEmojiPanel = ref(false)
/** 截图编辑器是否可见 */
const showScreenshotEditor = ref(false)
/** html2canvas 生成的截图 Canvas */
let screenshotCanvas: HTMLCanvasElement | null = null
/** 当前字符数 */
const charCount = computed(() => inputText.value.length)
/** 是否可以发送消息 */
const canSend = computed(() => {
return inputText.value.trim().length > 0 && !store.loading && store.isLoggedIn && charCount.value <= maxChars
})
// ============================================================================
// 常用表情列表
// ============================================================================
const commonEmojis = [
'😀','😃','😄','😁','😆','😅','🤣','😂',
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
'😚','😙','😋','😛','😜','🤪','😝','🤑',
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
'😶','😏','😒','🙄','😬','😮','🤯','😲',
'😳','🥺','😢','😭','😤','😠','😡','🤬',
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
'❤️','🧡','💛','💚','💙','💜','💯','✅',
]
// ============================================================================
// 生命周期
// ============================================================================
onMounted(() => {
document.addEventListener('paste', handleDocPaste)
})
onUnmounted(() => {
document.removeEventListener('paste', handleDocPaste)
})
// ============================================================================
// 输入框高度自适应
// ============================================================================
function handleInput(): void {
// 计算内容高度
nextTick(() => {
if (inputRef.value) {
const scrollHeight = inputRef.value.scrollHeight
const newHeight = Math.min(Math.max(scrollHeight, 60), 150)
textareaHeight.value = newHeight
}
})
}
// ============================================================================
// Document 级别粘贴监听
// ============================================================================
async function handleDocPaste(event: ClipboardEvent): Promise<void> {
const target = event.target as HTMLElement
if (target.tagName === 'TEXTAREA' || target.isContentEditable) return
const items = event.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.kind === 'file') {
event.preventDefault()
const file = item.getAsFile()
if (!file) continue
if (file.type.startsWith('image/')) {
await handleImageUpload(file)
} else {
await handleFileUpload(file)
}
return
}
}
}
// ============================================================================
// 表情处理
// ============================================================================
function onEmojiClick(emoji: string): void {
inputText.value += emoji
showEmojiPanel.value = false
nextTick(() => {
inputRef.value?.focus()
})
}
function handleEmoji(): void {
showEmojiPanel.value = !showEmojiPanel.value
}
// ============================================================================
// 键盘事件
// ============================================================================
function handleEnterKey(event: KeyboardEvent): void {
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
if (isMobile) return
// 桌面端:Shift+Enter 换行,Enter 发送
if (!event.shiftKey) {
event.preventDefault()
handleSend()
}
}
// ============================================================================
// 发送消息
// ============================================================================
async function handleSend(): Promise<void> {
const content = inputText.value.trim()
if (!content || store.loading) return
inputText.value = ''
textareaHeight.value = 60
await store.sendNewMessage(content)
}
// ============================================================================
// 文件上传
// ============================================================================
async function handlePaste(event: ClipboardEvent): Promise<void> {
const items = event.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.kind === 'file') {
event.preventDefault()
const file = item.getAsFile()
if (!file) continue
if (file.type.startsWith('image/')) {
await handleImageUpload(file)
} else {
await handleFileUpload(file)
}
return
}
}
}
async function handleImageUpload(file: File | Blob): Promise<void> {
try {
showToast('图片上传中...')
const result = await uploadFile(file)
const fileName = file instanceof File ? file.name : '截图'
await store.sendNewMessage(`[图片] ${fileName}`, {
msg_type: 'image',
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
showToast('图片发送成功')
} catch (error: any) {
console.error('图片上传失败:', error)
showToast(
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'图片上传失败,请重试'
)
}
}
async function handleFileUpload(file: File | Blob): Promise<void> {
try {
const fileName = file instanceof File ? file.name : '文件'
showToast(`文件上传中: ${fileName}`)
const result = await uploadFile(file)
await store.sendNewMessage(`[文件] ${result.filename}`, {
msg_type: 'file',
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
showToast('文件发送成功')
} catch (error: any) {
console.error('文件上传失败:', error)
showToast(
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'文件上传失败,请重试'
)
}
}
async function handleFileSelect(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
for (const file of Array.from(files)) {
try {
showToast(`正在上传: ${file.name}`)
const result = await uploadFile(file)
showToast(`${file.name} 上传成功`)
console.log('[InputBox] 文件上传成功:', result.url)
} catch (error: any) {
console.error('文件上传失败:', error)
showToast(`${file.name} 上传失败`)
}
}
input.value = ''
}
function handleImage(): void {
if (fileInputRef.value) {
fileInputRef.value.accept = 'image/*'
fileInputRef.value.multiple = true
fileInputRef.value.click()
}
}
function handleFile(): void {
if (fileInputRef.value) {
fileInputRef.value.accept = ''
fileInputRef.value.multiple = true
fileInputRef.value.click()
}
}
function handleCamera(): void {
if (cameraInputRef.value) {
cameraInputRef.value.value = ''
cameraInputRef.value.click()
}
}
async function handleCameraCapture(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
const file = files[0]
if (file.type.startsWith('image/')) {
await handleImageUpload(file)
}
input.value = ''
}
async function handleScreenshot(): Promise<void> {
try {
showToast('正在截取页面...')
const canvas = await html2canvas(document.body, {
useCORS: true,
allowTaint: true,
scale: window.devicePixelRatio || 1,
logging: false,
backgroundColor: '#ffffff',
foreignObjectRendering: false,
removeContainer: true,
})
screenshotCanvas = canvas
showScreenshotEditor.value = true
} catch (error) {
console.error('截图失败:', error)
showToast('截图失败,请重试')
}
}
async function onScreenshotConfirm(blob: Blob): Promise<void> {
try {
showToast('截图上传中...')
const result = await uploadFile(blob, 'screenshot')
await store.sendNewMessage('[截图]', {
msg_type: 'image',
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
showToast('截图发送成功')
} catch (error: any) {
console.error('[InputBox] 截图发送失败:', error)
showToast(
`截图发送失败:${
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'未知错误'
}`
)
} finally {
showScreenshotEditor.value = false
screenshotCanvas = null
}
}
function onScreenshotCancel(): void {
showScreenshotEditor.value = false
screenshotCanvas = null
}
// ============================================================================
// 快捷申请按钮
// ============================================================================
function handleQuickApply(): void {
// 触发审批卡片弹窗
store.showApprovalCard('')
}
</script>
<style scoped>
/* ==========================================================================
输入框容器
========================================================================== */
.input-box {
background-color: var(--bg-tertiary);
border-top: 1px solid var(--border-color);
padding: 8px 12px;
padding-bottom: calc(8px + env(safe-area-inset-bottom));
flex-shrink: 0;
position: relative;
}
/* 工具栏 */
.input-box__toolbar {
display: flex;
gap: 4px;
padding-bottom: 8px;
}
.input-box__tool-btn {
width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s;
outline: none;
}
.input-box__tool-btn:hover {
background: var(--bg-tertiary);
border-color: var(--accent);
}
/* 快捷申请按钮 - 强调样式 */
.input-box__tool-btn--accent {
background: var(--accent);
border-color: var(--accent);
}
.input-box__tool-btn--accent:hover {
background: var(--accent-hover, #06ad56);
border-color: var(--accent-hover, #06ad56);
}
/* 输入区域 */
.input-box__area {
display: flex;
align-items: flex-end;
gap: 8px;
position: relative;
}
.input-box__textarea {
flex: 1;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 12px;
font-family: inherit;
font-size: 14px;
color: var(--text-primary);
resize: none;
line-height: 1.5;
min-height: 60px;
max-height: 150px;
overflow-y: auto;
outline: none;
transition: border-color 0.2s;
}
.input-box__textarea:focus {
border-color: var(--accent);
}
.input-box__textarea:disabled {
background-color: var(--bg-tertiary);
color: var(--text-placeholder);
}
/* 发送按钮 */
.input-box__send-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--bg-tertiary);
cursor: not-allowed;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
padding: 0;
}
.input-box__send-btn--active {
background: var(--accent);
cursor: pointer;
}
.input-box__send-btn--active:hover {
opacity: 0.9;
}
.send-icon {
width: 18px;
height: 18px;
color: var(--text-placeholder);
}
.input-box__send-btn--active .send-icon {
color: white;
}
/* 字数统计 */
.input-box__counter {
text-align: right;
font-size: 11px;
color: var(--text-placeholder);
margin-top: 4px;
}
/* 底部引导条 */
.input-box__guide {
text-align: center;
font-size: 11px;
color: var(--text-placeholder);
margin-top: 6px;
}
.input-box__guide--active {
color: var(--color-warning);
font-weight: 500;
}
/* 表情选择面板 */
.emoji-panel {
position: relative;
z-index: 200;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px;
max-height: 200px;
overflow-y: auto;
width: fit-content;
max-width: 100%;
margin: 0 auto;
}
.emoji-panel__grid {
display: grid;
grid-template-columns: repeat(8, 36px);
gap: 2px;
justify-content: center;
}
.emoji-panel__item {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
outline: none;
}
.emoji-panel__item:hover {
background: var(--bg-tertiary);
}
.emoji-panel__item:active {
background: var(--accent-soft, rgba(59,130,246,0.15));
transform: scale(1.2);
}
.emoji-panel__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
</style>