Files
CRM_Montzar/apps/web/src/components/VoiceToTextButton.vue
T
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:46:35 +03:30

212 lines
4.9 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { Button as AButton, Typography } from 'ant-design-vue';
import { AudioOutlined, PauseCircleOutlined } from '@/icons/ant-bridge';
import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography;
type SpeechResult = {
isFinal: boolean;
0: { transcript: string };
};
type SpeechResultEvent = {
resultIndex: number;
results: ArrayLike<SpeechResult>;
};
type SpeechRec = {
lang: string;
continuous: boolean;
interimResults: boolean;
start: () => void;
stop: () => void;
abort: () => void;
onresult: ((ev: SpeechResultEvent) => void) | null;
onerror: ((ev: { error: string }) => void) | null;
onend: (() => void) | null;
};
const props = withDefaults(
defineProps<{
disabled?: boolean;
variant?: 'default' | 'toolbar';
hideError?: boolean;
}>(),
{ variant: 'default', hideError: false },
);
const emit = defineEmits<{
transcript: [text: string];
recording: [active: boolean];
busy: [active: boolean];
error: [message: string];
}>();
const UNSUPPORTED = 'تبدیل صوت به متن در این مرورگر پشتیبانی نمی‌شود. از Chrome استفاده کنید.';
const MAX_SECONDS = 90;
const recording = ref(false);
const error = ref('');
const seconds = ref(0);
let recognition: SpeechRec | null = null;
let timer: number | null = null;
let limitTimer: number | null = null;
let stopping = false;
let failed = false;
const timerLabel = computed(() => {
const m = String(Math.floor(seconds.value / 60)).padStart(2, '0');
const s = String(seconds.value % 60).padStart(2, '0');
return toFaDigits(`${m}:${s}`);
});
const ariaLabel = computed(() =>
recording.value ? 'توقف تبدیل صوت به متن' : 'تبدیل صوت به متن',
);
function speechCtor(): (new () => SpeechRec) | null {
const w = window as Window & {
SpeechRecognition?: new () => SpeechRec;
webkitSpeechRecognition?: new () => SpeechRec;
};
return w.SpeechRecognition || w.webkitSpeechRecognition || null;
}
function setError(message: string) {
error.value = message;
emit('error', message);
}
function setRecording(active: boolean) {
recording.value = active;
emit('recording', active);
emit('busy', active);
}
function stopTimer() {
if (timer != null) {
window.clearInterval(timer);
timer = null;
}
if (limitTimer != null) {
window.clearTimeout(limitTimer);
limitTimer = null;
}
}
function stop() {
stopping = true;
stopTimer();
try {
recognition?.stop();
} catch {
/* already stopped */
}
recognition = null;
setRecording(false);
}
function start() {
setError('');
const Ctor = speechCtor();
if (!Ctor) {
setError(UNSUPPORTED);
return;
}
const rec = new Ctor();
rec.lang = 'fa-IR';
rec.continuous = true;
rec.interimResults = false;
rec.onresult = (event) => {
let text = '';
for (let i = event.resultIndex; i < event.results.length; i += 1) {
const row = event.results[i];
if (row?.isFinal) text += row[0]?.transcript ?? '';
}
const next = text.replace(/\s+/g, ' ').trim();
if (next) emit('transcript', next);
};
rec.onerror = (event) => {
if (event.error === 'aborted' || event.error === 'no-speech') return;
failed = true;
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
setError('دسترسی به میکروفون داده نشد.');
return;
}
if (event.error === 'network') {
setError('اتصال به تشخیص گفتار Chrome برقرار نشد.');
return;
}
setError('تبدیل صوت به متن ناموفق بود.');
};
rec.onend = () => {
if (!stopping && !failed && recording.value) {
try {
rec.start();
return;
} catch {
/* fall through */
}
}
stopTimer();
recognition = null;
setRecording(false);
};
recognition = rec;
stopping = false;
failed = false;
seconds.value = 0;
try {
rec.start();
} catch {
setError(UNSUPPORTED);
recognition = null;
return;
}
setRecording(true);
timer = window.setInterval(() => {
seconds.value += 1;
}, 1000);
limitTimer = window.setTimeout(() => stop(), MAX_SECONDS * 1000);
}
function toggle() {
if (props.disabled) return;
if (recording.value) {
stop();
return;
}
start();
}
onBeforeUnmount(() => {
stop();
});
</script>
<template>
<div>
<a-button
:type="recording ? 'primary' : 'default'"
:danger="recording"
:disabled="disabled"
:aria-label="ariaLabel"
:title="ariaLabel"
@click="toggle"
>
<template #icon>
<PauseCircleOutlined v-if="recording" />
<AudioOutlined v-else />
</template>
<span v-if="recording">{{ timerLabel }}</span>
</a-button>
<div v-if="error && !hideError">
<a-typography-text type="danger">{{ error }}</a-typography-text>
</div>
</div>
</template>