Add Ask Mento, customer mood, and Pipecat speech-to-text.
Ship assistant answers, contact moods, voice dictation, and the related CRM UI so these can be deployed to production. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type { AskMentoResponse, AskMentoSourceDto } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
personId?: string;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
|
||||
const question = ref('');
|
||||
const asking = ref(false);
|
||||
const error = ref('');
|
||||
const result = ref<AskMentoResponse | null>(null);
|
||||
|
||||
function sourceTo(source: AskMentoSourceDto) {
|
||||
if (source.type === 'person') return { name: 'person-detail', params: { id: source.id } };
|
||||
if (source.type === 'activity') return { name: 'activity-detail', params: { id: source.id } };
|
||||
if (source.type === 'deal') return { name: 'deal-detail', params: { id: source.id } };
|
||||
return { name: 'notes' };
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const q = question.value.trim();
|
||||
if (!q || asking.value) return;
|
||||
asking.value = true;
|
||||
error.value = '';
|
||||
result.value = null;
|
||||
try {
|
||||
const { data } = await api.post<AskMentoResponse>('/ai/ask', {
|
||||
question: q,
|
||||
personId: props.personId || undefined,
|
||||
});
|
||||
result.value = data;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'پرسش ناموفق بود');
|
||||
} else {
|
||||
error.value = 'پرسش ناموفق بود';
|
||||
}
|
||||
} finally {
|
||||
asking.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ask">
|
||||
<form @submit.prevent="submit">
|
||||
<UiIcon name="sparkle" :size="16" class="ico" />
|
||||
<input
|
||||
v-model="question"
|
||||
type="search"
|
||||
:placeholder="placeholder || 'از منتو بپرس...'"
|
||||
/>
|
||||
<button type="submit" :disabled="asking || question.trim().length < 2">
|
||||
{{ asking ? '…' : 'بپرس' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div v-else-if="result" class="answer">
|
||||
<p>{{ result.answer }}</p>
|
||||
<div v-if="result.sources.length" class="sources">
|
||||
<RouterLink
|
||||
v-for="source in result.sources"
|
||||
:key="`${source.type}-${source.id}`"
|
||||
:to="sourceTo(source)"
|
||||
>
|
||||
{{ source.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ask {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
padding: 0.35rem 0.4rem 0.35rem 0.7rem;
|
||||
}
|
||||
|
||||
.ico {
|
||||
color: #0f766e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 0.45rem 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.answer {
|
||||
background: #f0fdfa;
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
.answer p {
|
||||
margin: 0;
|
||||
color: #134e4a;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.sources a {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.55rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import type { PersonMood } from '@mento/shared';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
mood?: PersonMood | null;
|
||||
size?: number;
|
||||
}>(),
|
||||
{ mood: 'newcomer', size: 48 },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="face"
|
||||
:class="mood || 'newcomer'"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg viewBox="0 0 64 64" fill="none">
|
||||
<circle class="skin" cx="32" cy="34" r="22" />
|
||||
<g class="shine">
|
||||
<ellipse cx="24" cy="26" rx="6" ry="3.2" />
|
||||
</g>
|
||||
|
||||
<g class="eyes">
|
||||
<g class="eye left">
|
||||
<ellipse class="open" cx="24" cy="33" rx="3.1" ry="3.6" />
|
||||
<path class="happy" d="M21 33c1.4-2.2 4.6-2.2 6 0" />
|
||||
<path class="sad" d="M21.4 34.6c1.2-2 4-2 5.2 0" />
|
||||
</g>
|
||||
<g class="eye right">
|
||||
<ellipse class="open" cx="40" cy="33" rx="3.1" ry="3.6" />
|
||||
<path class="happy" d="M37 33c1.4-2.2 4.6-2.2 6 0" />
|
||||
<path class="sad" d="M37.4 34.6c1.2-2 4-2 5.2 0" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<path class="mouth smile" d="M24 42c2.6 4.4 13.4 4.4 16 0" />
|
||||
<path class="mouth grin" d="M22 40.5c3.4 7 16.6 7 20 0" />
|
||||
<path class="mouth frown" d="M25 46c2.8-3.6 11.2-3.6 14 0" />
|
||||
|
||||
<g class="tears">
|
||||
<ellipse class="tear t1" cx="18" cy="40" rx="2.1" ry="3.2" />
|
||||
<ellipse class="tear t2" cx="46" cy="42" rx="2.1" ry="3.2" />
|
||||
</g>
|
||||
|
||||
<g class="crown">
|
||||
<path d="M16 18.5 22 26l10-9 10 9 6-7.5-2 12H18l-2-12Z" />
|
||||
<rect x="18" y="26" width="28" height="3.4" rx="1" />
|
||||
<circle class="gem" cx="32" cy="20" r="2.1" />
|
||||
<circle class="gem side" cx="22.5" cy="23" r="1.5" />
|
||||
<circle class="gem side" cx="41.5" cy="23" r="1.5" />
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.face {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: #fff7d6;
|
||||
box-shadow: inset 0 -3px 0 rgba(180, 120, 20, 0.08);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.skin {
|
||||
fill: #f5c542;
|
||||
}
|
||||
|
||||
.shine ellipse {
|
||||
fill: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.eye .open {
|
||||
fill: #3b2415;
|
||||
}
|
||||
|
||||
.eye .happy,
|
||||
.eye .sad,
|
||||
.mouth,
|
||||
.tears,
|
||||
.crown {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.eye .happy,
|
||||
.eye .sad,
|
||||
.mouth {
|
||||
stroke: #3b2415;
|
||||
stroke-width: 2.4;
|
||||
stroke-linecap: round;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.face.newcomer .mouth.smile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.face.happy {
|
||||
animation: bounce 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.face.happy .eye .open {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.face.happy .eye .happy,
|
||||
.face.happy .mouth.grin {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.face.unhappy .eye .open {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.face.unhappy .eye .sad,
|
||||
.face.unhappy .mouth.frown,
|
||||
.face.unhappy .tears {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tear {
|
||||
fill: #5bb7ff;
|
||||
}
|
||||
|
||||
.t1 {
|
||||
animation: drop 1.4s ease-in infinite;
|
||||
}
|
||||
|
||||
.t2 {
|
||||
animation: drop 1.4s ease-in 0.35s infinite;
|
||||
}
|
||||
|
||||
.face.special .mouth.smile,
|
||||
.face.special .crown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.face.special .crown {
|
||||
transform-origin: 32px 22px;
|
||||
animation: bob 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.crown path,
|
||||
.crown rect {
|
||||
fill: #f0b429;
|
||||
stroke: #c48414;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.gem {
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.gem.side {
|
||||
fill: #22c55e;
|
||||
}
|
||||
|
||||
.face.newcomer .eye .open,
|
||||
.face.special .eye .open {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: blink 3.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
42%,
|
||||
48%,
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
45% {
|
||||
transform: scaleY(0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(-4deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-2px) rotate(4deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
import { appendSpokenText } from '@/lib/spoken-text';
|
||||
import type {
|
||||
ActivityDto,
|
||||
NoteDto,
|
||||
@@ -203,9 +205,12 @@ onMounted(load);
|
||||
placeholder="یادداشت سریع…"
|
||||
@keydown.ctrl.enter.prevent="addNote"
|
||||
/>
|
||||
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
|
||||
ثبت یادداشت
|
||||
</button>
|
||||
<div class="compose-actions">
|
||||
<VoiceToTextButton @transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))" />
|
||||
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
|
||||
ثبت یادداشت
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<input
|
||||
@@ -289,6 +294,14 @@ onMounted(load);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.compose-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -235,12 +235,15 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -282,6 +285,8 @@ textarea {
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -290,6 +295,10 @@ textarea {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions .btn {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const FAB_SIZE = 56;
|
||||
const FAB_MARGIN = 20;
|
||||
const POS_KEY = 'mento.quickCreateFab';
|
||||
|
||||
type FabPos = { left: number; top: number };
|
||||
|
||||
type ItemIcon = 'user' | 'building' | 'dollar' | 'calendar-plus' | 'document';
|
||||
|
||||
const items: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: ItemIcon;
|
||||
tone: string;
|
||||
to: RouteLocationRaw;
|
||||
}> = [
|
||||
{
|
||||
key: 'person',
|
||||
title: 'افزودن شخص',
|
||||
desc: 'مخاطب جدید را ثبت کنید',
|
||||
icon: 'user',
|
||||
tone: 'person',
|
||||
to: { name: 'person-new' },
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
title: 'افزودن شرکت',
|
||||
desc: 'شرکت یا سازمان جدید اضافه کنید',
|
||||
icon: 'building',
|
||||
tone: 'company',
|
||||
to: { name: 'company-new' },
|
||||
},
|
||||
{
|
||||
key: 'deal',
|
||||
title: 'افزودن معامله',
|
||||
desc: 'فرصت یا معامله جدید ایجاد کنید',
|
||||
icon: 'dollar',
|
||||
tone: 'deal',
|
||||
to: { name: 'deal-new' },
|
||||
},
|
||||
{
|
||||
key: 'activity',
|
||||
title: 'افزودن فعالیت',
|
||||
desc: 'تماس، جلسه، ایمیل و ... ثبت کنید',
|
||||
icon: 'calendar-plus',
|
||||
tone: 'activity',
|
||||
to: { name: 'activity-new' },
|
||||
},
|
||||
{
|
||||
key: 'note',
|
||||
title: 'افزودن کارت (یادداشت)',
|
||||
desc: 'یادداشت یا کارت جدید ایجاد کنید',
|
||||
icon: 'document',
|
||||
tone: 'note',
|
||||
to: { name: 'notes' },
|
||||
},
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const hidden = computed(() => route.name === 'person-detail');
|
||||
const isQuickCreateOpen = ref(false);
|
||||
const ready = ref(false);
|
||||
const fabPos = ref<FabPos>({ left: FAB_MARGIN, top: FAB_MARGIN });
|
||||
const viewport = ref({ w: 1280, h: 800 });
|
||||
const dragging = ref(false);
|
||||
let dragMoved = false;
|
||||
let dragOffsetX = 0;
|
||||
let dragOffsetY = 0;
|
||||
|
||||
function defaultPos(): FabPos {
|
||||
return {
|
||||
left: FAB_MARGIN,
|
||||
top: Math.max(FAB_MARGIN, viewport.value.h - FAB_MARGIN - FAB_SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
function clampPos(pos: FabPos): FabPos {
|
||||
const margin = 8;
|
||||
return {
|
||||
left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin),
|
||||
top: Math.min(Math.max(margin, pos.top), viewport.value.h - FAB_SIZE - margin),
|
||||
};
|
||||
}
|
||||
|
||||
function loadPos() {
|
||||
try {
|
||||
const raw = localStorage.getItem(POS_KEY);
|
||||
if (!raw) {
|
||||
fabPos.value = defaultPos();
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as FabPos;
|
||||
if (typeof parsed.left !== 'number' || typeof parsed.top !== 'number') {
|
||||
fabPos.value = defaultPos();
|
||||
return;
|
||||
}
|
||||
fabPos.value = clampPos(parsed);
|
||||
} catch {
|
||||
fabPos.value = defaultPos();
|
||||
}
|
||||
}
|
||||
|
||||
function savePos() {
|
||||
localStorage.setItem(POS_KEY, JSON.stringify(fabPos.value));
|
||||
}
|
||||
|
||||
function onFabPointerDown(ev: PointerEvent) {
|
||||
if (ev.button !== 0) return;
|
||||
dragMoved = false;
|
||||
dragOffsetX = ev.clientX - fabPos.value.left;
|
||||
dragOffsetY = ev.clientY - fabPos.value.top;
|
||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
}
|
||||
|
||||
function onFabPointerMove(ev: PointerEvent) {
|
||||
if (!((ev.currentTarget as HTMLElement).hasPointerCapture?.(ev.pointerId))) return;
|
||||
const next = clampPos({
|
||||
left: ev.clientX - dragOffsetX,
|
||||
top: ev.clientY - dragOffsetY,
|
||||
});
|
||||
if (
|
||||
Math.abs(next.left - fabPos.value.left) > 4 ||
|
||||
Math.abs(next.top - fabPos.value.top) > 4
|
||||
) {
|
||||
dragMoved = true;
|
||||
dragging.value = true;
|
||||
}
|
||||
if (dragMoved) fabPos.value = next;
|
||||
}
|
||||
|
||||
function onFabPointerUp(ev: PointerEvent) {
|
||||
(ev.currentTarget as HTMLElement).releasePointerCapture?.(ev.pointerId);
|
||||
if (dragMoved) {
|
||||
fabPos.value = clampPos(fabPos.value);
|
||||
savePos();
|
||||
dragging.value = false;
|
||||
return;
|
||||
}
|
||||
dragging.value = false;
|
||||
toggle();
|
||||
}
|
||||
|
||||
function syncViewport() {
|
||||
viewport.value = { w: window.innerWidth, h: window.innerHeight };
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
syncViewport();
|
||||
fabPos.value = clampPos(fabPos.value);
|
||||
}
|
||||
|
||||
const fabStyle = computed(() => ({
|
||||
left: `${fabPos.value.left}px`,
|
||||
top: `${fabPos.value.top}px`,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
}));
|
||||
|
||||
const panelStyle = computed(() => {
|
||||
const gap = 18;
|
||||
const width = Math.min(390, viewport.value.w - 32);
|
||||
let left = fabPos.value.left;
|
||||
left = Math.min(left, viewport.value.w - width - 16);
|
||||
left = Math.max(16, left);
|
||||
const spaceAbove = fabPos.value.top;
|
||||
const placeAbove = spaceAbove > 360;
|
||||
if (placeAbove) {
|
||||
return {
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
bottom: `${viewport.value.h - fabPos.value.top + gap}px`,
|
||||
top: 'auto',
|
||||
width: `${width}px`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
top: `${fabPos.value.top + FAB_SIZE + gap}px`,
|
||||
bottom: 'auto',
|
||||
width: `${width}px`,
|
||||
};
|
||||
});
|
||||
|
||||
function close() {
|
||||
isQuickCreateOpen.value = false;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isQuickCreateOpen.value = !isQuickCreateOpen.value;
|
||||
}
|
||||
|
||||
function go(to: RouteLocationRaw) {
|
||||
close();
|
||||
void router.push(to);
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape' && isQuickCreateOpen.value) {
|
||||
ev.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
watch(isQuickCreateOpen, (value) => {
|
||||
document.body.style.overflow = value ? 'hidden' : '';
|
||||
});
|
||||
|
||||
watch(hidden, (value) => {
|
||||
if (value) close();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
close();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
syncViewport();
|
||||
loadPos();
|
||||
ready.value = true;
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="!hidden" class="qc" dir="rtl">
|
||||
<button
|
||||
v-if="isQuickCreateOpen"
|
||||
class="overlay"
|
||||
type="button"
|
||||
aria-label="بستن ایجاد سریع"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<section
|
||||
v-if="isQuickCreateOpen"
|
||||
class="panel"
|
||||
aria-labelledby="qc-title"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<h2 id="qc-title">ایجاد سریع</h2>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="item"
|
||||
type="button"
|
||||
@click="go(item.to)"
|
||||
>
|
||||
<span class="ico" :class="item.tone">
|
||||
<UiIcon :name="item.icon" :size="26" />
|
||||
</span>
|
||||
<span class="copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.desc }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<button
|
||||
v-if="ready"
|
||||
class="fab"
|
||||
type="button"
|
||||
:class="{ dragging }"
|
||||
:style="fabStyle"
|
||||
:aria-expanded="isQuickCreateOpen"
|
||||
:aria-label="isQuickCreateOpen ? 'بستن ایجاد سریع' : 'ایجاد سریع'"
|
||||
@pointerdown="onFabPointerDown"
|
||||
@pointermove="onFabPointerMove"
|
||||
@pointerup="onFabPointerUp"
|
||||
@pointercancel="onFabPointerUp"
|
||||
>
|
||||
<UiIcon :name="isQuickCreateOpen ? 'x' : 'plus'" :size="22" />
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qc {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.68);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
z-index: 92;
|
||||
width: 390px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 16px;
|
||||
text-align: right;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
height: 82px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #eef1f2;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ico.person {
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
.ico.company {
|
||||
background: #22d3ee;
|
||||
}
|
||||
|
||||
.ico.deal {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.ico.activity {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.ico.note {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.copy strong {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.copy small {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
z-index: 93;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
line-height: 0;
|
||||
direction: ltr;
|
||||
box-shadow: 0 6px 16px rgba(13, 148, 136, 0.28);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fab :deep(.ico) {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
stroke-width: 1.75;
|
||||
}
|
||||
|
||||
.fab.dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.34);
|
||||
}
|
||||
</style>
|
||||
@@ -44,7 +44,11 @@ defineProps<{
|
||||
| 'check-plain'
|
||||
| 'calendar-sun'
|
||||
| 'calendar-edit'
|
||||
| 'minus';
|
||||
| 'minus'
|
||||
| 'dollar'
|
||||
| 'layers'
|
||||
| 'target'
|
||||
| 'pencil';
|
||||
size?: number;
|
||||
}>();
|
||||
</script>
|
||||
@@ -249,9 +253,28 @@ defineProps<{
|
||||
<template v-else-if="name === 'minus'">
|
||||
<path d="M6 12h12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'dollar'">
|
||||
<circle cx="12" cy="12" r="8.4" />
|
||||
<path d="M12 6.8v10.4" />
|
||||
<path d="M9.4 9.1c.7-1.1 1.8-1.6 2.6-1.6 1.3 0 2.3.7 2.3 1.9 0 2.6-5 1.5-5 4.1 0 1.3 1.1 2.2 2.7 2.2 1 0 2-.5 2.6-1.4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'check-plain'">
|
||||
<path d="m5.5 12.2 4.2 4.3 8.8-9" />
|
||||
</template>
|
||||
<template v-else-if="name === 'layers'">
|
||||
<path d="M12 4 4 8.2 12 12.4 20 8.2 12 4Z" />
|
||||
<path d="M4 12.2 12 16.4 20 12.2" />
|
||||
<path d="M4 16.2 12 20.4 20 16.2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'target'">
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<circle cx="12" cy="12" r="4.2" />
|
||||
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'pencil'">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
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 recording = ref(false);
|
||||
const transcribing = ref(false);
|
||||
const error = ref('');
|
||||
const seconds = ref(0);
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
let ctx: AudioContext | null = null;
|
||||
let source: MediaStreamAudioSourceNode | null = null;
|
||||
let processor: ScriptProcessorNode | null = null;
|
||||
let mute: GainNode | null = null;
|
||||
let chunks: Float32Array[] = [];
|
||||
let sampleRate = 16000;
|
||||
let timer: number | null = null;
|
||||
let limitTimer: number | null = null;
|
||||
|
||||
const MAX_SECONDS = 90;
|
||||
|
||||
const busy = computed(() => recording.value || transcribing.value);
|
||||
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(() => {
|
||||
if (transcribing.value) return 'در حال تبدیل صوت به متن';
|
||||
if (recording.value) return 'توقف ضبط و تبدیل به متن';
|
||||
return 'تبدیل صوت به متن';
|
||||
});
|
||||
|
||||
function setError(message: string) {
|
||||
error.value = message;
|
||||
emit('error', message);
|
||||
}
|
||||
|
||||
function setRecording(active: boolean) {
|
||||
recording.value = active;
|
||||
emit('recording', active);
|
||||
emit('busy', busy.value);
|
||||
}
|
||||
|
||||
function setTranscribing(active: boolean) {
|
||||
transcribing.value = active;
|
||||
emit('busy', recording.value || active);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer != null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (limitTimer != null) {
|
||||
window.clearTimeout(limitTimer);
|
||||
limitTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function teardownMic() {
|
||||
stopTimer();
|
||||
try {
|
||||
processor?.disconnect();
|
||||
source?.disconnect();
|
||||
mute?.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
processor = null;
|
||||
source = null;
|
||||
mute = null;
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
if (ctx) {
|
||||
void ctx.close().catch(() => undefined);
|
||||
ctx = null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeChunks(parts: Float32Array[]) {
|
||||
const length = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const out = new Float32Array(length);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeWav(samples: Float32Array, rate: number) {
|
||||
const bytes = samples.length * 2;
|
||||
const buffer = new ArrayBuffer(44 + bytes);
|
||||
const view = new DataView(buffer);
|
||||
const write = (offset: number, text: string) => {
|
||||
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
||||
};
|
||||
write(0, 'RIFF');
|
||||
view.setUint32(4, 36 + bytes, true);
|
||||
write(8, 'WAVE');
|
||||
write(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, rate, true);
|
||||
view.setUint32(28, rate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
write(36, 'data');
|
||||
view.setUint32(40, bytes, true);
|
||||
let offset = 44;
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, samples[i] ?? 0));
|
||||
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
||||
offset += 2;
|
||||
}
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
async function upload(blob: Blob) {
|
||||
const form = new FormData();
|
||||
form.append('audio', blob, 'dictation.wav');
|
||||
const { data } = await api.post<{ text: string }>('/ai/transcribe', form);
|
||||
return (data.text ?? '').trim();
|
||||
}
|
||||
|
||||
async function finish(keep: boolean) {
|
||||
const parts = chunks;
|
||||
chunks = [];
|
||||
const rate = sampleRate;
|
||||
teardownMic();
|
||||
setRecording(false);
|
||||
if (!keep) return;
|
||||
|
||||
const samples = mergeChunks(parts);
|
||||
if (samples.length < rate * 0.35) {
|
||||
setError('صدای کافی ضبط نشد. کمی بیشتر صحبت کنید.');
|
||||
return;
|
||||
}
|
||||
|
||||
setTranscribing(true);
|
||||
try {
|
||||
const text = await upload(encodeWav(samples, rate));
|
||||
if (!text) {
|
||||
setError('متنی از صدا تشخیص داده نشد.');
|
||||
return;
|
||||
}
|
||||
emit('transcript', text);
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const msg = (err.response?.data as { message?: string | string[] })?.message;
|
||||
setError(Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل صوت به متن ناموفق بود'));
|
||||
} else {
|
||||
setError('تبدیل صوت به متن ناموفق بود');
|
||||
}
|
||||
} finally {
|
||||
setTranscribing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
setError('');
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setError('این مرورگر به میکروفون دسترسی نمیدهد.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 },
|
||||
});
|
||||
const Ctor = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) {
|
||||
setError('ضبط صدا در این مرورگر پشتیبانی نمیشود.');
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
return;
|
||||
}
|
||||
ctx = new Ctor();
|
||||
await ctx.resume();
|
||||
sampleRate = ctx.sampleRate || 16000;
|
||||
source = ctx.createMediaStreamSource(stream);
|
||||
processor = ctx.createScriptProcessor(4096, 1, 1);
|
||||
mute = ctx.createGain();
|
||||
mute.gain.value = 0;
|
||||
chunks = [];
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (!recording.value) return;
|
||||
chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
|
||||
};
|
||||
source.connect(processor);
|
||||
processor.connect(mute);
|
||||
mute.connect(ctx.destination);
|
||||
seconds.value = 0;
|
||||
timer = window.setInterval(() => {
|
||||
seconds.value += 1;
|
||||
}, 1000);
|
||||
limitTimer = window.setTimeout(() => {
|
||||
void finish(true);
|
||||
}, MAX_SECONDS * 1000);
|
||||
setRecording(true);
|
||||
} catch {
|
||||
teardownMic();
|
||||
setError('دسترسی به میکروفون داده نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled || transcribing.value) return;
|
||||
if (recording.value) {
|
||||
void finish(true);
|
||||
return;
|
||||
}
|
||||
void start();
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
void finish(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vtt-wrap" :class="variant">
|
||||
<button
|
||||
class="vtt"
|
||||
type="button"
|
||||
:class="{ on: recording, busy: transcribing }"
|
||||
:disabled="disabled || transcribing"
|
||||
:aria-label="ariaLabel"
|
||||
:title="ariaLabel"
|
||||
@click="toggle"
|
||||
>
|
||||
<UiIcon :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
|
||||
<span v-if="variant === 'default' && recording" class="timer">{{ timerLabel }}</span>
|
||||
<span v-else-if="variant === 'default' && transcribing" class="timer">تبدیل…</span>
|
||||
</button>
|
||||
<p v-if="error && !hideError" class="err">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vtt-wrap {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.vtt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid #d7e3dc;
|
||||
background: #f4faf7;
|
||||
color: #0f766e;
|
||||
border-radius: 999px;
|
||||
min-height: 36px;
|
||||
padding: 0 0.75rem;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vtt:hover:not(:disabled) {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.vtt.on {
|
||||
border-color: #f87171;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.vtt.busy:not(.on) {
|
||||
opacity: 0.72;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.vtt:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar .vtt {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
border-color: transparent;
|
||||
background: #f8fafc;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar .vtt.on {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(248, 113, 113, 0.16);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
calendarDaysBetween,
|
||||
formatClockFa,
|
||||
formatJalaliDay,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import { appendSpokenText } from '@/lib/spoken-text';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
import type { ActivityDto, ActivityType, OrganizationDto, OrgUserDto } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -35,34 +36,7 @@ const mentionOpen = ref(false);
|
||||
const voiceTip = ref(localStorage.getItem('mento.voiceTipSeen') !== '1');
|
||||
const voiceError = ref('');
|
||||
const recording = ref(false);
|
||||
const seconds = ref(0);
|
||||
const pullY = ref(0);
|
||||
|
||||
let recognition: SpeechRecognitionLike | null = null;
|
||||
let timer: number | null = null;
|
||||
let snapshot = '';
|
||||
let finals = '';
|
||||
let keepOnEnd = true;
|
||||
let pendingSubmit = false;
|
||||
let pulling = false;
|
||||
let pullStart = 0;
|
||||
|
||||
type SpeechRecognitionLike = {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
onresult: ((ev: SpeechRecognitionEventLike) => void) | null;
|
||||
onerror: ((ev: { error?: string }) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
};
|
||||
|
||||
type SpeechRecognitionEventLike = {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }>;
|
||||
};
|
||||
const voiceBusy = ref(false);
|
||||
|
||||
const subtitle = computed(() => {
|
||||
const a = props.activity;
|
||||
@@ -81,152 +55,29 @@ const dueLabel = computed(() => {
|
||||
return `${formatJalaliDay(d)} ${clock}`;
|
||||
});
|
||||
|
||||
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)}:${toFaDigits(s)}`;
|
||||
});
|
||||
const canSubmit = computed(
|
||||
() => comment.value.trim().length > 0 && !props.saving && !voiceBusy.value,
|
||||
);
|
||||
|
||||
const canSubmit = computed(() => comment.value.trim().length > 0 && !props.saving);
|
||||
|
||||
function speechCtor(): (new () => SpeechRecognitionLike) | null {
|
||||
const w = window as Window & {
|
||||
SpeechRecognition?: new () => SpeechRecognitionLike;
|
||||
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
|
||||
};
|
||||
return w.SpeechRecognition || w.webkitSpeechRecognition || null;
|
||||
function onVoiceTranscript(text: string) {
|
||||
comment.value = appendSpokenText(comment.value, text);
|
||||
}
|
||||
|
||||
function applyTranscript(interim = '') {
|
||||
const extra = `${finals}${interim}`.trim();
|
||||
comment.value = extra ? `${snapshot}${snapshot && !snapshot.endsWith(' ') ? ' ' : ''}${extra}` : snapshot;
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
seconds.value = 0;
|
||||
timer = window.setInterval(() => {
|
||||
seconds.value += 1;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer != null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecognition(keep: boolean) {
|
||||
keepOnEnd = keep;
|
||||
if (!recognition) {
|
||||
finishRecording(keep);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (keep) recognition.stop();
|
||||
else recognition.abort();
|
||||
} catch {
|
||||
finishRecording(keep);
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (recording.value) finishRecording(keepOnEnd);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function finishRecording(keep: boolean) {
|
||||
if (!recording.value && !recognition) return;
|
||||
stopTimer();
|
||||
recording.value = false;
|
||||
pullY.value = 0;
|
||||
recognition = null;
|
||||
if (!keep) {
|
||||
comment.value = snapshot;
|
||||
pendingSubmit = false;
|
||||
} else {
|
||||
applyTranscript('');
|
||||
}
|
||||
finals = '';
|
||||
if (pendingSubmit) {
|
||||
pendingSubmit = false;
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
voiceError.value = '';
|
||||
mentionOpen.value = false;
|
||||
if (voiceTip.value) {
|
||||
function onVoiceBusy(active: boolean) {
|
||||
voiceBusy.value = active;
|
||||
if (active && voiceTip.value) {
|
||||
voiceTip.value = false;
|
||||
localStorage.setItem('mento.voiceTipSeen', '1');
|
||||
}
|
||||
const Ctor = speechCtor();
|
||||
if (!Ctor) {
|
||||
voiceError.value = 'تایپ صوتی در این مرورگر پشتیبانی نمیشود.';
|
||||
return;
|
||||
}
|
||||
snapshot = comment.value;
|
||||
finals = '';
|
||||
keepOnEnd = true;
|
||||
const rec = new Ctor();
|
||||
rec.lang = 'fa-IR';
|
||||
rec.continuous = true;
|
||||
rec.interimResults = true;
|
||||
rec.onresult = (ev) => {
|
||||
let interim = '';
|
||||
for (let i = ev.resultIndex; i < ev.results.length; i += 1) {
|
||||
const piece = ev.results[i];
|
||||
if (!piece) continue;
|
||||
const text = piece[0]?.transcript ?? '';
|
||||
if (piece.isFinal) finals += `${text} `;
|
||||
else interim += text;
|
||||
}
|
||||
applyTranscript(interim);
|
||||
};
|
||||
rec.onerror = (ev) => {
|
||||
if (ev.error === 'aborted' || ev.error === 'no-speech') return;
|
||||
voiceError.value = 'ضبط صدا ناموفق بود. دوباره تلاش کنید.';
|
||||
finishRecording(true);
|
||||
};
|
||||
rec.onend = () => {
|
||||
finishRecording(keepOnEnd);
|
||||
};
|
||||
recognition = rec;
|
||||
recording.value = true;
|
||||
startTimer();
|
||||
try {
|
||||
rec.start();
|
||||
} catch {
|
||||
voiceError.value = 'شروع ضبط صدا ممکن نشد.';
|
||||
finishRecording(false);
|
||||
}
|
||||
}
|
||||
|
||||
function discardRecording() {
|
||||
pendingSubmit = false;
|
||||
stopRecognition(false);
|
||||
}
|
||||
|
||||
function keepRecording() {
|
||||
pendingSubmit = false;
|
||||
stopRecognition(true);
|
||||
}
|
||||
|
||||
function sendRecording() {
|
||||
pendingSubmit = true;
|
||||
stopRecognition(true);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (recording.value) keepRecording();
|
||||
const text = comment.value.trim();
|
||||
if (!text || props.saving) return;
|
||||
if (!text || props.saving || voiceBusy.value) return;
|
||||
emit('submit', text);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (recording.value) discardRecording();
|
||||
emit('close');
|
||||
}
|
||||
|
||||
@@ -256,37 +107,11 @@ function onKeydown(ev: KeyboardEvent) {
|
||||
mentionOpen.value = false;
|
||||
return;
|
||||
}
|
||||
if (recording.value) {
|
||||
discardRecording();
|
||||
return;
|
||||
}
|
||||
if (recording.value) return;
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function onPullStart(ev: PointerEvent) {
|
||||
if (!recording.value) return;
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (target?.closest('button')) return;
|
||||
pulling = true;
|
||||
pullStart = ev.clientY;
|
||||
pullY.value = 0;
|
||||
(ev.currentTarget as HTMLElement | null)?.setPointerCapture?.(ev.pointerId);
|
||||
}
|
||||
|
||||
function onPullMove(ev: PointerEvent) {
|
||||
if (!pulling) return;
|
||||
pullY.value = Math.max(0, ev.clientY - pullStart);
|
||||
}
|
||||
|
||||
function onPullEnd() {
|
||||
if (!pulling) return;
|
||||
const shouldCancel = pullY.value > 72;
|
||||
pulling = false;
|
||||
pullY.value = 0;
|
||||
if (shouldCancel) discardRecording();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
@@ -318,17 +143,6 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
pendingSubmit = false;
|
||||
stopTimer();
|
||||
if (recognition) {
|
||||
try {
|
||||
recognition.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
recognition = null;
|
||||
}
|
||||
recording.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -409,15 +223,15 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="tool-wrap">
|
||||
<p v-if="voiceTip && !recording" class="tip">تایپ صوتی را امتحان کنید! 🎙️✨</p>
|
||||
<button
|
||||
class="tool mic"
|
||||
type="button"
|
||||
aria-label="تایپ صوتی"
|
||||
:class="{ on: recording }"
|
||||
@click="recording ? keepRecording() : startRecording()"
|
||||
>
|
||||
<UiIcon name="mic" :size="18" />
|
||||
</button>
|
||||
<VoiceToTextButton
|
||||
variant="toolbar"
|
||||
hide-error
|
||||
:disabled="saving"
|
||||
@transcript="onVoiceTranscript"
|
||||
@recording="recording = $event"
|
||||
@busy="onVoiceBusy"
|
||||
@error="voiceError = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,41 +243,11 @@ onBeforeUnmount(() => {
|
||||
یادداشت شما فقط برای اعضای تیم و مدیران {{ orgName }} قابل مشاهده خواهد بود.
|
||||
</p>
|
||||
|
||||
<div v-if="!recording" class="actions">
|
||||
<div class="actions">
|
||||
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||
ثبت نظر
|
||||
{{ recording ? 'در حال ضبط…' : 'ثبت نظر' }}
|
||||
</button>
|
||||
<button class="act ghost" type="button" :disabled="saving" @click="close">انصراف</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="recorder"
|
||||
:style="{ transform: pullY ? `translateY(${pullY}px)` : undefined }"
|
||||
@pointerdown="onPullStart"
|
||||
@pointermove="onPullMove"
|
||||
@pointerup="onPullEnd"
|
||||
@pointercancel="onPullEnd"
|
||||
>
|
||||
<div class="rec-top">
|
||||
<strong>در حال ضبط صدا...</strong>
|
||||
<span>{{ timerLabel }}</span>
|
||||
</div>
|
||||
<div class="rec-controls">
|
||||
<button class="rec-side" type="button" aria-label="ارسال نظر" @click="sendRecording">
|
||||
<UiIcon name="send" :size="18" />
|
||||
</button>
|
||||
<button class="rec-stop" type="button" aria-label="توقف ضبط" @click="keepRecording">
|
||||
<UiIcon name="stop" :size="22" />
|
||||
</button>
|
||||
<button class="rec-side" type="button" aria-label="حذف ضبط" @click="discardRecording">
|
||||
<UiIcon name="trash" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="pull">
|
||||
برای لغو، بیرون بکشید
|
||||
<UiIcon name="chevron" :size="14" />
|
||||
</p>
|
||||
<button class="act ghost" type="button" :disabled="saving || voiceBusy" @click="close">انصراف</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -674,15 +458,10 @@ onBeforeUnmount(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool.mic {
|
||||
.tool-wrap :deep(.vtt) {
|
||||
color: #0d9488;
|
||||
}
|
||||
|
||||
.tool.on,
|
||||
.tool.mic:hover {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.tip {
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
@@ -800,76 +579,6 @@ onBeforeUnmount(() => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.recorder {
|
||||
border: 1.5px dashed #cbd5e1;
|
||||
border-radius: 16px;
|
||||
padding: 0.95rem 0.85rem 0.7rem;
|
||||
text-align: center;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.rec-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #334155;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.rec-top strong {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.rec-top span {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.rec-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 1rem 1.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.rec-side {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rec-stop {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: 0 10px 24px rgba(20, 184, 166, 0.35);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pull {
|
||||
margin: 0.35rem 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pull :deep(.ico) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.layer {
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type { AskMentoSourceDto } from '@mento/shared';
|
||||
|
||||
defineProps<{
|
||||
askQuery: string;
|
||||
asking?: boolean;
|
||||
answer?: string;
|
||||
error?: string;
|
||||
sources?: AskMentoSourceDto[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:askQuery': [value: string];
|
||||
ask: [];
|
||||
ask: [preset?: string];
|
||||
}>();
|
||||
|
||||
const shortcuts = [
|
||||
{ label: 'خلاصه مشتری شرکت آریا را بده', to: '/contacts', icon: 'users' as const },
|
||||
{ label: 'متن پیگیری برای مشتری بفرست', to: '/notes', icon: 'send' as const },
|
||||
{ label: 'گزارش فروش این هفته را نشان بده', to: '/reports', icon: 'chart' as const },
|
||||
{ label: 'معاملات در خطر را لیست کن', to: '/pipeline', icon: 'warning' as const },
|
||||
{ label: 'معاملات باز و در خطر را بگو', icon: 'warning' as const },
|
||||
{ label: 'فعالیتهای عقبافتاده چیست؟', icon: 'calendar' as const },
|
||||
{ label: 'مهمترین پیگیری امروز چیست؟', icon: 'send' as const },
|
||||
{ label: 'خلاصه وضعیت فروش را بده', icon: 'chart' as const },
|
||||
];
|
||||
|
||||
function sourceTo(source: AskMentoSourceDto) {
|
||||
if (source.type === 'person') return { name: 'person-detail', params: { id: source.id } };
|
||||
if (source.type === 'activity') return { name: 'activity-detail', params: { id: source.id } };
|
||||
if (source.type === 'deal') return { name: 'deal-detail', params: { id: source.id } };
|
||||
return { name: 'notes' };
|
||||
}
|
||||
|
||||
function pick(label: string) {
|
||||
emit('update:askQuery', label);
|
||||
emit('ask', label);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,10 +48,31 @@ const shortcuts = [
|
||||
<p class="ai-lead">چه کمکی میتونم بکنم؟</p>
|
||||
|
||||
<div class="ai-shortcuts">
|
||||
<RouterLink v-for="item in shortcuts" :key="item.label" class="ai-chip" :to="item.to">
|
||||
<button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.label"
|
||||
class="ai-chip"
|
||||
type="button"
|
||||
:disabled="asking"
|
||||
@click="pick(item.label)"
|
||||
>
|
||||
<span class="chip-ico"><UiIcon :name="item.icon" :size="14" /></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="ai-error">{{ error }}</p>
|
||||
<div v-else-if="answer" class="ai-answer">
|
||||
<p>{{ answer }}</p>
|
||||
<div v-if="sources?.length" class="sources">
|
||||
<RouterLink
|
||||
v-for="source in sources"
|
||||
:key="`${source.type}-${source.id}`"
|
||||
:to="sourceTo(source)"
|
||||
>
|
||||
{{ source.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="ai-input" @submit.prevent="emit('ask')">
|
||||
@@ -42,9 +80,10 @@ const shortcuts = [
|
||||
:value="askQuery"
|
||||
type="search"
|
||||
placeholder="از منتو بپرس..."
|
||||
:disabled="asking"
|
||||
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button type="submit" aria-label="ارسال">
|
||||
<button type="submit" aria-label="ارسال" :disabled="asking">
|
||||
<UiIcon name="send" :size="15" />
|
||||
</button>
|
||||
</form>
|
||||
@@ -110,15 +149,22 @@ h2 {
|
||||
border: 1px solid #eef2f7;
|
||||
color: #334155;
|
||||
font-size: 0.76rem;
|
||||
text-decoration: none;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.ai-chip:hover {
|
||||
.ai-chip:hover:not(:disabled) {
|
||||
background: #f0fdfa;
|
||||
border-color: #99f6e4;
|
||||
}
|
||||
|
||||
.ai-chip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.chip-ico {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
@@ -155,4 +201,44 @@ h2 {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.ai-input button:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ai-answer {
|
||||
background: #f0fdfa;
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ai-answer p {
|
||||
margin: 0;
|
||||
color: #134e4a;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sources a {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
|
||||
.ai-error {
|
||||
margin: 0 0 0.7rem;
|
||||
color: #b91c1c;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user