ai-courseware/vue3-frontend/src/components/ChatInput.vue

315 lines
6.7 KiB
Vue
Raw 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>
<div class="chat-input-container">
<div class="input-wrapper">
<div class="input-area">
<textarea
ref="textareaRef"
v-model="inputText"
:placeholder="placeholder"
:disabled="disabled"
class="chat-textarea"
rows="1"
@keydown="handleKeydown"
@input="adjustHeight"
/>
<button
:disabled="!canSend"
class="send-button"
@click="handleSend"
>
<svg
v-if="!isLoading"
class="send-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
<div v-else class="loading-spinner">
<div class="spinner"></div>
</div>
</button>
</div>
<!-- 快捷操作按钮 -->
<div class="quick-actions" v-if="showQuickActions">
<button
v-for="action in quickActions"
:key="action.text"
class="quick-action-btn"
@click="selectQuickAction(action)"
>
{{ action.text }}
</button>
</div>
</div>
<!-- 输入提示 -->
<div class="input-hint" v-if="showHint">
<span class="hint-text">{{ hintText }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue';
interface QuickAction {
text: string;
value?: string;
tag?: 'order' | 'product';
}
interface Props {
placeholder?: string;
disabled?: boolean;
isLoading?: boolean;
showQuickActions?: boolean;
quickActions?: QuickAction[];
maxLength?: number;
}
interface Emits {
(e: 'send', message: string, options?: { tag?: 'order' | 'product' }): void;
(e: 'input', value: string): void;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请输入您的问题...',
disabled: false,
isLoading: false,
showQuickActions: true,
quickActions: () => [
{ text: '订单诊断', tag: 'order' },
{ text: '商品查询', tag: 'product' }
],
maxLength: 1000
});
const emit = defineEmits<Emits>();
// 响应式数据
const inputText = ref('');
const textareaRef = ref<HTMLTextAreaElement>();
// 计算属性
const canSend = computed(() => {
return inputText.value.trim().length > 0 && !props.disabled && !props.isLoading;
});
const showHint = computed(() => {
return inputText.value.length > props.maxLength * 0.8;
});
const hintText = computed(() => {
const remaining = props.maxLength - inputText.value.length;
if (remaining < 0) {
return `超出字符限制 ${Math.abs(remaining)} 个字符`;
}
return `还可输入 ${remaining} 个字符`;
});
// 自动调整高度
const adjustHeight = () => {
nextTick(() => {
if (textareaRef.value) {
textareaRef.value.style.height = 'auto';
const scrollHeight = textareaRef.value.scrollHeight;
const maxHeight = 120; // 最大高度约5行
textareaRef.value.style.height = Math.min(scrollHeight, maxHeight) + 'px';
}
});
// 触发输入事件
emit('input', inputText.value);
};
// 处理键盘事件
const handleKeydown = (event: KeyboardEvent) => {
// Ctrl/Cmd + Enter 发送消息
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault();
handleSend();
return;
}
// Enter 发送消息(非 Shift + Enter
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSend();
return;
}
// 字符限制检查
if (inputText.value.length >= props.maxLength &&
!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
event.preventDefault();
}
};
// 发送消息
const handleSend = () => {
if (!canSend.value) return;
const message = inputText.value.trim();
if (message) {
emit('send', message, currentTag.value ? { tag: currentTag.value } : undefined);
inputText.value = '';
currentTag.value = undefined;
adjustHeight();
}
};
// 选择快捷操作
const currentTag = ref<'order' | 'product' | undefined>(undefined);
const selectQuickAction = (action: QuickAction) => {
currentTag.value = action.tag;
};
// 聚焦输入框
const focus = () => {
nextTick(() => {
textareaRef.value?.focus();
});
};
// 清空输入
const clear = () => {
inputText.value = '';
adjustHeight();
};
// 暴露方法给父组件
defineExpose({
focus,
clear
});
onMounted(() => {
adjustHeight();
});
</script>
<style scoped>
.chat-input-container {
@apply w-full;
}
.input-wrapper {
@apply bg-white border border-gray-300 rounded-lg shadow-sm overflow-hidden;
}
.input-area {
@apply flex items-end p-3;
}
.chat-textarea {
@apply flex-1 resize-none border-0 outline-none bg-transparent text-gray-900 placeholder-gray-500 min-h-[24px] max-h-[120px];
line-height: 1.5;
}
.chat-textarea:disabled {
@apply text-gray-400 cursor-not-allowed;
}
.send-button {
@apply ml-3 p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors duration-200 flex-shrink-0;
}
.send-icon {
@apply w-5 h-5;
}
.loading-spinner {
@apply w-5 h-5 flex items-center justify-center;
}
.spinner {
@apply w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin;
}
/* 快捷操作 */
.quick-actions {
@apply flex flex-wrap gap-2 p-3 pt-0;
}
.quick-action-btn {
@apply px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors duration-200;
}
/* 输入提示 */
.input-hint {
@apply px-3 py-2 text-xs;
}
.hint-text {
@apply text-gray-500;
}
/* 字符超限时的提示样式 */
.input-wrapper:has(.chat-textarea:invalid) .hint-text,
.hint-text:has-text("超出") {
@apply text-red-500;
}
/* 暗色主题支持 */
.dark .input-wrapper {
@apply bg-gray-800 border-gray-600;
}
.dark .chat-textarea {
@apply text-gray-100 placeholder-gray-400;
}
.dark .chat-textarea:disabled {
@apply text-gray-500;
}
.dark .quick-action-btn {
@apply bg-gray-700 text-gray-300 hover:bg-gray-600;
}
.dark .hint-text {
@apply text-gray-400;
}
/* 响应式设计 */
@media (max-width: 640px) {
.input-area {
@apply p-2;
}
.send-button {
@apply ml-2 p-1.5;
}
.send-icon {
@apply w-4 h-4;
}
.quick-actions {
@apply p-2 pt-0;
}
.quick-action-btn {
@apply px-2 py-1 text-xs;
}
}
/* 焦点状态 */
.input-wrapper:focus-within {
@apply border-blue-500 ring-1 ring-blue-500;
}
.dark .input-wrapper:focus-within {
@apply border-blue-400 ring-blue-400;
}
</style>