441 lines
10 KiB
Vue
441 lines
10 KiB
Vue
<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>
|