Files
prop-ai-hr/mobile-uni/src/components/chat/ChatComposer.vue
T

441 lines
10 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>
<view class="chat-composer-wrap">
<scroll-view v-if="quickQuestions.length" scroll-x class="chip-scroll" :show-scrollbar="false">
<view class="chip-row">
<button
v-for="item in quickQuestions"
:key="item"
class="chip"
:disabled="disabled"
@click="emit('quick', item)"
>
{{ item }}
</button>
</view>
</scroll-view>
<view v-if="mediaName" class="media-chip">
<text class="media-chip-kind">{{ mediaKind === 'video' ? '现场视频' : '现场图片' }}</text>
<text class="media-chip-name">{{ mediaName }}</text>
<button class="media-chip-remove" :disabled="disabled" aria-label="移除附件" @click="emit('clear-media')">×</button>
</view>
<view class="composer-bar">
<button class="composer-icon" :disabled="disabled || recording" aria-label="更多功能" @click="panelOpen = !panelOpen">
<text class="composer-icon-text">{{ panelOpen ? '×' : '+' }}</text>
</button>
<button
v-if="voiceMode"
class="hold-talk"
:class="{ recording }"
:disabled="disabled || transcribing"
@touchstart.prevent="startHold"
@touchend.prevent="endHold"
@touchcancel.prevent="cancelHold"
@mousedown.prevent="startHold"
@mouseup.prevent="endHold"
@mouseleave="cancelHold"
>
{{ recording ? '松开 发送' : transcribing ? '转写中…' : '按住 说话' }}
</button>
<textarea
v-else
v-model="draft"
class="composer-input"
:maxlength="maxlength"
:placeholder="placeholder"
:disabled="disabled"
auto-height
/>
<button
class="composer-icon"
:disabled="disabled || recording || transcribing"
:aria-label="voiceMode ? '切换键盘输入' : '切换语音输入'"
@click="toggleVoiceMode"
>
<text class="composer-icon-text">{{ voiceMode ? '⌨' : '🎤' }}</text>
</button>
<button
v-if="!voiceMode && (draft.trim() || mediaName)"
class="composer-send"
:disabled="disabled"
@click="sendDraft"
>
发送
</button>
</view>
<view v-if="panelOpen" class="composer-panel">
<template v-if="panelItems.length">
<button
v-for="item in panelItems"
:key="item.key"
class="panel-item"
:disabled="disabled || item.disabled"
@click="panelAction(item.key)"
>
<text class="panel-item-icon">{{ item.icon }}</text>
<text class="panel-item-label">{{ item.label }}</text>
</button>
</template>
<template v-else>
<button class="panel-item" :disabled="disabled" @click="panelEmit('pick-media')">
<text class="panel-item-icon">🖼</text>
<text class="panel-item-label">图片/视频</text>
</button>
<button class="panel-item" :disabled="disabled || transcribing" @click="panelEmit('pick-audio')">
<text class="panel-item-icon">🎵</text>
<text class="panel-item-label">选音频转文字</text>
</button>
<button v-if="toolLabel" class="panel-item" :disabled="disabled" @click="panelEmit('tool')">
<text class="panel-item-icon">📊</text>
<text class="panel-item-label">{{ toolLabel }}</text>
</button>
</template>
</view>
<text v-if="panelOpen && !panelItems.length" class="panel-hint">图片≤10MB,视频≤100MB;仅用于本次现场分析</text>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = withDefaults(defineProps<{
disabled?: boolean;
transcribing?: boolean;
placeholder?: string;
maxlength?: number;
quickQuestions?: string[];
mediaName?: string;
mediaKind?: string;
toolLabel?: string;
panelItems?: Array<{ key: string; icon: string; label: string; disabled?: boolean }>;
}>(), {
disabled: false,
transcribing: false,
placeholder: '输入问题…',
maxlength: 200,
quickQuestions: () => [],
mediaName: '',
mediaKind: '',
toolLabel: '',
panelItems: () => []
});
const emit = defineEmits<{
(e: 'send', text: string): void;
(e: 'voice-blob', blob: Blob): void;
(e: 'pick-audio'): void;
(e: 'pick-media'): void;
(e: 'clear-media'): void;
(e: 'quick', question: string): void;
(e: 'tool'): void;
(e: 'panel', key: string): void;
}>();
const draft = ref('');
const voiceMode = ref(true);
const panelOpen = ref(false);
const recording = ref(false);
let recorder: MediaRecorder | null = null;
let stream: MediaStream | null = null;
let holdGeneration = 0;
let discardRecording = false;
const supportsRecording = () =>
typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia) && typeof MediaRecorder !== 'undefined';
const toggleVoiceMode = () => {
voiceMode.value = !voiceMode.value;
panelOpen.value = false;
};
const sendDraft = () => {
const text = draft.value.trim();
if (!text && !props.mediaName) return;
draft.value = '';
panelOpen.value = false;
emit('send', text);
};
const panelEmit = (event: 'pick-media' | 'pick-audio' | 'tool') => {
panelOpen.value = false;
if (event === 'pick-media') emit('pick-media');
else if (event === 'pick-audio') emit('pick-audio');
else emit('tool');
};
const panelAction = (key: string) => {
panelOpen.value = false;
emit('panel', key);
};
const stopTracks = () => {
stream?.getTracks().forEach((track) => track.stop());
stream = null;
};
const startHold = async () => {
if (props.disabled || props.transcribing || recording.value) return;
if (!supportsRecording()) {
emit('pick-audio');
return;
}
const generation = ++holdGeneration;
discardRecording = false;
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== holdGeneration) {
mediaStream.getTracks().forEach((track) => track.stop());
return;
}
stream = mediaStream;
const mediaRecorder = new MediaRecorder(mediaStream);
const chunks: Blob[] = [];
recorder = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data?.size) chunks.push(event.data);
};
mediaRecorder.onstop = () => {
recording.value = false;
recorder = null;
stopTracks();
if (discardRecording) return;
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
if (blob.size) emit('voice-blob', blob);
};
mediaRecorder.start();
recording.value = true;
} catch {
stopTracks();
if (generation === holdGeneration) emit('pick-audio');
}
};
const endHold = () => {
if (!recording.value) return;
if (recorder && recorder.state !== 'inactive') recorder.stop();
};
const cancelHold = () => {
if (!recording.value) return;
discardRecording = true;
if (recorder && recorder.state !== 'inactive') recorder.stop();
};
const teardown = () => {
holdGeneration += 1;
discardRecording = true;
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null;
recorder.onstop = null;
recorder.stop();
}
recorder = null;
stopTracks();
recording.value = false;
};
/** Fill the text input from outside (e.g. adopt a coach suggestion) and
* switch to keyboard mode so the user can edit before sending. */
const setDraft = (text: string) => {
voiceMode.value = false;
panelOpen.value = false;
draft.value = text;
};
defineExpose({ teardown, setDraft });
</script>
<style scoped>
.chat-composer-wrap {
border-top: 1px solid #ebedf0;
background: #f7f8fa;
padding: 8px 10px 10px;
}
.chip-scroll {
width: 100%;
margin-bottom: 8px;
white-space: nowrap;
}
.chip-row {
display: inline-flex;
gap: 8px;
}
.chip {
flex-shrink: 0;
margin: 0;
padding: 5px 12px;
border: 1px solid #f0c0c5;
border-radius: 999px;
background: #ffffff;
color: #c8001a;
font-size: 13px;
line-height: 1.4;
}
.media-chip {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 6px 10px;
border: 1px solid #e3e6ea;
border-radius: 10px;
background: #ffffff;
}
.media-chip-kind {
flex-shrink: 0;
color: #c8001a;
font-size: 12px;
font-weight: 600;
}
.media-chip-name {
flex: 1;
overflow: hidden;
color: #4d5560;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-chip-remove {
flex-shrink: 0;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border-radius: 50%;
background: #eef0f3;
color: #6b7480;
font-size: 15px;
line-height: 24px;
}
.composer-bar {
display: flex;
align-items: flex-end;
gap: 8px;
}
.composer-icon {
flex-shrink: 0;
width: 38px;
height: 38px;
margin: 0;
padding: 0;
border: 1px solid #dfe3e8;
border-radius: 50%;
background: #ffffff;
line-height: 36px;
}
.composer-icon-text {
color: #3a4350;
font-size: 19px;
}
.hold-talk {
flex: 1;
min-height: 38px;
margin: 0;
border: 1px solid #dfe3e8;
border-radius: 10px;
background: #ffffff;
color: #232a33;
font-size: 16px;
font-weight: 600;
user-select: none;
-webkit-user-select: none;
}
.hold-talk.recording {
background: #e70014;
border-color: #e70014;
color: #ffffff;
}
.composer-input {
flex: 1;
box-sizing: border-box;
width: auto;
min-height: 38px;
max-height: 110px;
padding: 9px 12px;
border: 1px solid #dfe3e8;
border-radius: 10px;
background: #ffffff;
font-size: 15px;
line-height: 1.4;
}
.composer-send {
flex-shrink: 0;
min-height: 38px;
margin: 0;
padding: 0 16px;
border-radius: 10px;
background: #e70014;
color: #ffffff;
font-size: 15px;
font-weight: 600;
}
.composer-panel {
display: flex;
gap: 18px;
margin-top: 12px;
padding: 4px 6px 0;
}
.panel-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin: 0;
padding: 0;
background: transparent;
border: none;
line-height: 1.2;
}
.panel-item::after {
border: none;
}
.panel-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border: 1px solid #e6e9ed;
border-radius: 14px;
background: #ffffff;
font-size: 24px;
}
.panel-item-label {
color: #5a6270;
font-size: 12px;
}
.panel-hint {
display: block;
margin-top: 8px;
padding-left: 6px;
color: #98a1ad;
font-size: 11px;
}
</style>