Refining the speech-to-text conversion structure

This commit is contained in:
2026-09-22 11:02:08 +03:30
parent 17fb33b3e7
commit 4b335aa7ff
16 changed files with 634 additions and 333 deletions
+6 -3
View File
@@ -66,9 +66,12 @@ export class AiSttService {
const detail = Array.isArray(payload.detail) const detail = Array.isArray(payload.detail)
? payload.detail.map((item) => item.msg).filter(Boolean).join('، ') ? payload.detail.map((item) => item.msg).filter(Boolean).join('، ')
: payload.detail; : payload.detail;
throw new ServiceUnavailableException( const raw = String(detail || '').trim();
detail || 'تبدیل صوت به متن ناموفق بود', let message = raw || 'تبدیل صوت به متن ناموفق بود';
); if (raw.length > 220) {
message = 'تبدیل صوت به متن ناموفق بود. سرویس STT محلی را بررسی کنید.';
}
throw new ServiceUnavailableException(message);
} }
return { text: (payload.text ?? '').trim() }; return { text: (payload.text ?? '').trim() };
+4 -4
View File
@@ -1,7 +1,7 @@
# Pipecat STT only — same OpenAI-compatible key as the Nest AI chat (GapGPT). # Local Pipecat Whisper — no GapGPT / OpenAI cloud key required for STT.
OPENAI_API_KEY= STT_MODEL=small
OPENAI_BASE_URL=https://api.gapgpt.app/v1 STT_DEVICE=cpu
STT_MODEL=whisper-1 STT_COMPUTE_TYPE=int8
STT_PROMPT=یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس. STT_PROMPT=یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس.
STT_HOST=127.0.0.1 STT_HOST=127.0.0.1
+29 -7
View File
@@ -1,12 +1,12 @@
# Montact STT (Pipecat) # Montact STT (Pipecat — Whisper محلی)
تبدیل صوت به متن — فقط STT. بدون TTS، بدون مکالمهٔ صوتی. تبدیل صوت به متن — فقط STT. بدون TTS، بدون مکالمهٔ صوتی، **بدون GapGPT**.
Pipecat اینجا فقط `OpenAISTTService` را اجرا می‌کند (HTTP Whisper). مدل از API سازگار با OpenAI می‌آید (مثلاً GapGPT). از [Pipecat](https://www.pipecat.ai/) سرویس `WhisperSTTService` استفاده می‌شود؛ مدل Faster-Whisper روی **همین سرور** اجرا می‌شود و کلید ابری لازم نیست.
## لوکال (ویندوز) ## لوکال (ویندوز)
Python 3.11+ لازم است. Python 3.11+ و ترجیحاً `ffmpeg` در PATH.
```powershell ```powershell
cd apps/stt cd apps/stt
@@ -16,8 +16,6 @@ pip install -r requirements.txt
copy .env.example .env copy .env.example .env
``` ```
در `.env` همان `AI_API_KEY` / `AI_BASE_URL` پنل را بگذارید (`OPENAI_API_KEY` و `OPENAI_BASE_URL`).
```powershell ```powershell
uvicorn main:app --host 127.0.0.1 --port 3040 uvicorn main:app --host 127.0.0.1 --port 3040
``` ```
@@ -30,4 +28,28 @@ STT_URL=http://127.0.0.1:3040
STT_INTERNAL_TOKEN= STT_INTERNAL_TOKEN=
``` ```
اگر `STT_INTERNAL_TOKEN` را پر کردید، همان مقدار را در هر دو `.env` بگذارید. ## تنظیمات `apps/stt/.env`
| کلید | پیش‌فرض | توضیح |
|------|---------|--------|
| `STT_MODEL` | `small` | `tiny` / `base` / `small` / `medium` (فارسی: حداقل `small`) |
| `STT_DEVICE` | `cpu` | یا `cuda` اگر GPU دارید |
| `STT_COMPUTE_TYPE` | `int8` | برای CPU؛ روی GPU معمولاً `float16` |
| `STT_PROMPT` | متن فارسی فروش | راهنمای املا/اصطلاحات |
| `STT_INTERNAL_TOKEN` | خالی | اگر پر شود باید با API یکی باشد |
**دیگر به `OPENAI_API_KEY` / GapGPT برای دیکته نیاز نیست.**
اولین اجرا مدل را از Hugging Face دانلود می‌کند (چند صد مگابایت تا چند گیگ و بسته به مدل).
## سرور
```bash
cd /var/www/crm/apps/stt
source .venv/bin/activate # یا مسیر venv فعلی
pip install -r requirements.txt
# OPENAI_* را از .env بردارید یا نادیده بگیرید
pm2 restart mento-stt
```
اگر RAM کم است، `STT_MODEL=base` یا `tiny` بگذارید.
+129 -28
View File
@@ -1,17 +1,20 @@
"""Speech-to-text sidecar. Uses Pipecat OpenAISTTService only — no TTS, no voice agent.""" """Speech-to-text sidecar — Pipecat local Whisper only (no GapGPT / cloud STT)."""
from __future__ import annotations from __future__ import annotations
import io
import os import os
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
import wave
from pathlib import Path from pathlib import Path
import numpy as np
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, File, Header, HTTPException, UploadFile from fastapi import FastAPI, File, Header, HTTPException, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pipecat.services.openai.stt import OpenAISTTService from pipecat.services.whisper.stt import WhisperSTTService
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
load_dotenv(Path(__file__).resolve().parent / ".env") load_dotenv(Path(__file__).resolve().parent / ".env")
@@ -20,7 +23,7 @@ MAX_BYTES = 8 * 1024 * 1024
WAV_MAGIC = (b"RIFF", b"WAVE") WAV_MAGIC = (b"RIFF", b"WAVE")
app = FastAPI(title="Mento STT", docs_url=None, redoc_url=None) app = FastAPI(title="Mento STT", docs_url=None, redoc_url=None)
_stt: OpenAISTTService | None = None _stt: WhisperSTTService | None = None
def _env(name: str, default: str = "") -> str: def _env(name: str, default: str = "") -> str:
@@ -35,31 +38,53 @@ def _require_token(header_token: str | None) -> None:
raise HTTPException(status_code=401, detail="توکن سرویس تبدیل صوت نامعتبر است") raise HTTPException(status_code=401, detail="توکن سرویس تبدیل صوت نامعتبر است")
def _stt_service() -> OpenAISTTService: def _model_name() -> str:
# faster-whisper / Pipecat: tiny | base | small | medium | large-v3 | ...
return _env("STT_MODEL", "small") or "small"
def _stt_service() -> WhisperSTTService:
"""Local Faster-Whisper via Pipecat — no OPENAI_API_KEY / GapGPT."""
global _stt global _stt
if _stt is not None: if _stt is not None:
return _stt return _stt
api_key = _env("OPENAI_API_KEY") device = _env("STT_DEVICE", "cpu") or "cpu"
if not api_key: compute = _env("STT_COMPUTE_TYPE", "int8") or "int8"
raise HTTPException(status_code=503, detail="کلید تبدیل صوت تنظیم نشده است")
model = _env("STT_MODEL", "whisper-1")
prompt = _env( prompt = _env(
"STT_PROMPT", "STT_PROMPT",
"یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس.", "یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس.",
) )
base_url = _env("OPENAI_BASE_URL") or None
_stt = OpenAISTTService( try:
api_key=api_key, _stt = WhisperSTTService(
base_url=base_url, device=device,
settings=OpenAISTTService.Settings( compute_type=compute,
model=model, settings=WhisperSTTService.Settings(
language=Language.FA, model=_model_name(),
prompt=prompt, language=Language.FA,
), initial_prompt=prompt or None,
) ),
)
except Exception as exc:
raise HTTPException(
status_code=503,
detail=f"بارگذاری مدل Whisper محلی ناموفق بود: {exc}",
) from exc
# force model load at startup path (first request otherwise downloads silently)
if getattr(_stt, "_model", None) is None and hasattr(_stt, "_load"):
try:
_stt._load()
except Exception as exc:
raise HTTPException(
status_code=503,
detail=(
"مدل Whisper دانلود/بارگذاری نشد. "
f"اینترنت سرور و فضای دیسک را چک کنید. جزئیات: {exc}"
),
) from exc
return _stt return _stt
@@ -105,13 +130,93 @@ def _to_wav(data: bytes, filename: str) -> bytes:
return dst.read_bytes() return dst.read_bytes()
def _wav_to_float32(wav_bytes: bytes) -> tuple[np.ndarray, int]:
with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
channels = wf.getnchannels()
sample_width = wf.getsampwidth()
rate = wf.getframerate()
frames = wf.readframes(wf.getnframes())
if sample_width == 2:
audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
elif sample_width == 4:
audio = np.frombuffer(frames, dtype=np.int32).astype(np.float32) / 2147483648.0
else:
raise HTTPException(status_code=400, detail="فرمت نمونهٔ صوتی پشتیبانی نمی‌شود")
if channels > 1:
audio = audio.reshape(-1, channels).mean(axis=1)
return audio, rate
def _transcribe_local(wav_bytes: bytes) -> str:
service = _stt_service()
model = getattr(service, "_model", None)
if model is None:
raise HTTPException(status_code=503, detail="مدل Whisper محلی آماده نیست")
audio, rate = _wav_to_float32(wav_bytes)
# faster-whisper expects 16k mono float32 ideally
if rate != 16000:
# simple resample via ffmpeg path already targets 16k for non-wav;
# for wav at other rates, re-encode quickly
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
raise HTTPException(status_code=400, detail="نرخ نمونه‌برداری صوت پشتیبانی نشد")
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in.wav"
dst = Path(tmp) / "out.wav"
src.write_bytes(wav_bytes)
result = subprocess.run(
[ffmpeg, "-y", "-i", str(src), "-ac", "1", "-ar", "16000", "-f", "wav", str(dst)],
capture_output=True,
check=False,
)
if result.returncode != 0 or not dst.exists():
raise HTTPException(status_code=400, detail="نرمال‌سازی صوت ناموفق بود")
audio, rate = _wav_to_float32(dst.read_bytes())
settings = getattr(service, "_settings", None)
language = getattr(settings, "language", Language.FA)
lang_code = getattr(language, "value", language) if language is not None else "fa"
if isinstance(lang_code, str) and "-" in lang_code:
lang_code = lang_code.split("-")[0]
initial_prompt = getattr(settings, "initial_prompt", None)
segments, _info = model.transcribe(
audio,
language=lang_code or "fa",
initial_prompt=initial_prompt,
vad_filter=True,
)
parts = [seg.text.strip() for seg in segments if getattr(seg, "text", None)]
return " ".join(p for p in parts if p).strip()
def _friendly_stt_error(exc: BaseException) -> tuple[int, str]:
text = str(exc)
lowered = text.lower()
if "timeout" in lowered or "timed out" in lowered:
return 504, "زمان تبدیل صوت به پایان رسید. دوباره با صدای کوتاه‌تر تلاش کنید."
if "memory" in lowered or "oom" in lowered:
return 503, "حافظهٔ سرور برای مدل Whisper کافی نیست. مدل کوچک‌تر (tiny/base) را امتحان کنید."
if len(text) > 180:
return 502, "تبدیل صوت به متن محلی ناموفق بود. لاگ mento-stt را بررسی کنید."
return 502, f"تبدیل صوت به متن ناموفق بود: {text}"
@app.get("/health") @app.get("/health")
async def health() -> dict[str, object]: async def health() -> dict[str, object]:
return { return {
"ok": True, "ok": True,
"engine": "pipecat", "engine": "pipecat",
"service": "OpenAISTTService", "service": "WhisperSTTService",
"configured": bool(_env("OPENAI_API_KEY")), "mode": "local",
"model": _env("STT_MODEL", "small"),
"device": _env("STT_DEVICE", "cpu") or "cpu",
"cloud": False,
"gapgpt": False,
} }
@@ -129,17 +234,13 @@ async def transcribe(
raise HTTPException(status_code=400, detail="حجم فایل صوتی بیش از حد مجاز است") raise HTTPException(status_code=400, detail="حجم فایل صوتی بیش از حد مجاز است")
wav = _to_wav(data, audio.filename or "audio.wav") wav = _to_wav(data, audio.filename or "audio.wav")
service = _stt_service()
try: try:
result = await service._transcribe(wav) text = _transcribe_local(wav)
except HTTPException: except HTTPException:
raise raise
except Exception as exc: except Exception as exc:
raise HTTPException( status, detail = _friendly_stt_error(exc)
status_code=502, raise HTTPException(status_code=status, detail=detail) from exc
detail=f"تبدیل صوت به متن ناموفق بود: {exc}",
) from exc
text = (getattr(result, "text", None) or "").strip()
return JSONResponse({"text": text}) return JSONResponse({"text": text})
+3 -1
View File
@@ -2,4 +2,6 @@ fastapi==0.116.1
uvicorn[standard]==0.35.0 uvicorn[standard]==0.35.0
python-multipart==0.0.20 python-multipart==0.0.20
python-dotenv==1.1.1 python-dotenv==1.1.1
pipecat-ai[openai]==1.8.1 numpy>=1.26,<3
# Local Whisper via Pipecat (Faster-Whisper). No OpenAI/GapGPT cloud STT.
pipecat-ai[whisper]==1.8.1
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
open: boolean;
/** start = لبه راست تریگر در RTL؛ end = لبه چپ */
align?: 'start' | 'end';
}>(),
{ align: 'end' },
);
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const anchorRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const panelStyle = ref<Record<string, string>>({});
function toggle() {
emit('update:open', !props.open);
}
function close() {
if (props.open) emit('update:open', false);
}
function onPanelClickCapture() {
// بعد از handler آیتم‌ها ببند (حتی اگر stopPropagation زده باشند)
queueMicrotask(() => close());
}
function place() {
const anchor = anchorRef.value;
const panel = panelRef.value;
if (!anchor || !panel) return;
const r = anchor.getBoundingClientRect();
const menuH = panel.offsetHeight || 140;
const menuW = panel.offsetWidth || 160;
const gap = 6;
const pad = 8;
const spaceBelow = window.innerHeight - r.bottom;
const openUp = spaceBelow < menuH + gap + pad && r.top > menuH + gap + pad;
let left =
props.align === 'start' ? r.left : r.right - menuW;
left = Math.max(pad, Math.min(left, window.innerWidth - menuW - pad));
let top = openUp ? r.top - menuH - gap : r.bottom + gap;
top = Math.max(pad, Math.min(top, window.innerHeight - menuH - pad));
panelStyle.value = {
position: 'fixed',
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
zIndex: 'var(--z-dropdown, 50)',
};
}
async function placeSoon() {
await nextTick();
place();
// بعد از رندر محتوا ارتفاع دقیق‌تر می‌شود
requestAnimationFrame(place);
}
watch(
() => props.open,
(open) => {
if (open) void placeSoon();
},
);
function onPointerDown(ev: PointerEvent) {
if (!props.open) return;
const t = ev.target as Node;
if (anchorRef.value?.contains(t) || panelRef.value?.contains(t)) return;
close();
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') close();
}
function onReposition() {
if (props.open) place();
}
onMounted(() => {
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKey);
window.addEventListener('resize', onReposition);
window.addEventListener('scroll', onReposition, true);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', onReposition);
window.removeEventListener('scroll', onReposition, true);
});
defineExpose({ toggle, close });
</script>
<template>
<span ref="anchorRef" class="ui-dd-anchor">
<slot name="trigger" :open="open" :toggle="toggle" :close="close" />
</span>
<Teleport to="body">
<div
v-if="open"
ref="panelRef"
class="ui-dd-panel"
role="menu"
:style="panelStyle"
@click.capture="onPanelClickCapture"
>
<slot :close="close" />
</div>
</Teleport>
</template>
<style scoped>
.ui-dd-anchor {
display: inline-flex;
vertical-align: middle;
}
.ui-dd-panel {
min-width: 10rem;
max-width: min(20rem, calc(100vw - 16px));
background: var(--surface, #fff);
border: 1px solid var(--line, #e2e8f0);
border-radius: var(--radius, 10px);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
gap: 1px;
}
.ui-dd-panel :deep(button),
.ui-dd-panel :deep(a) {
display: block;
width: 100%;
box-sizing: border-box;
border: 0;
background: transparent;
text-align: right;
padding: 0.55rem 0.7rem;
border-radius: var(--radius, 10px);
font: inherit;
font-size: 0.875rem;
color: var(--ink, #0f172a);
cursor: pointer;
text-decoration: none;
line-height: 1.4;
}
.ui-dd-panel :deep(button:hover),
.ui-dd-panel :deep(a:hover),
.ui-dd-panel :deep(button.on),
.ui-dd-panel :deep(a.on) {
background: var(--accent-soft, #ecfdf8);
color: var(--accent-deep, #0f766e);
}
.ui-dd-panel :deep(button.danger),
.ui-dd-panel :deep(a.danger) {
color: #b91c1c;
}
.ui-dd-panel :deep(button.danger:hover),
.ui-dd-panel :deep(a.danger:hover) {
background: #fef2f2;
color: #991b1b;
}
.ui-dd-panel :deep(li) {
list-style: none;
margin: 0;
padding: 0;
}
.ui-dd-panel :deep(ul) {
list-style: none;
margin: 0;
padding: 0;
display: grid;
}
</style>
+1
View File
@@ -13,3 +13,4 @@ export { default as UiSearch } from './UiSearch.vue';
export { default as UiFilterBar } from './UiFilterBar.vue'; export { default as UiFilterBar } from './UiFilterBar.vue';
export { default as UiSegmented } from './UiSegmented.vue'; export { default as UiSegmented } from './UiSegmented.vue';
export { default as UiChart, CHART_PALETTE, type UiChartOption } from './UiChart.vue'; export { default as UiChart, CHART_PALETTE, type UiChartOption } from './UiChart.vue';
export { default as UiDropdownMenu } from './UiDropdownMenu.vue';
+38 -49
View File
@@ -12,6 +12,7 @@ import {
} from '@/lib/jalali'; } from '@/lib/jalali';
import { import {
UiButton, UiButton,
UiDropdownMenu,
UiModal, UiModal,
UiPageHeader, UiPageHeader,
UiPagination, UiPagination,
@@ -129,11 +130,6 @@ const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.lab
const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim()); const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim());
const listEmpty = computed(() => !loading.value && !items.value.length); const listEmpty = computed(() => !loading.value && !items.value.length);
function isLastItem(id: string) {
const rows = items.value;
return rows.length > 0 && rows[rows.length - 1]?.id === id;
}
function isOverdue(item: ActivityDto) { function isOverdue(item: ActivityDto) {
if (item.status !== 'open' || !item.dueAt) return false; if (item.status !== 'open' || !item.dueAt) return false;
return startOfLocalDay(new Date(item.dueAt)).getTime() < startOfLocalDay().getTime(); return startOfLocalDay(new Date(item.dueAt)).getTime() < startOfLocalDay().getTime();
@@ -480,7 +476,6 @@ onMounted(() => {
v-for="item in items" v-for="item in items"
:key="item.id" :key="item.id"
class="trow" class="trow"
:class="{ 'menu-open': menuId === item.id }"
role="row" role="row"
> >
<div class="title-cell"> <div class="title-cell">
@@ -516,29 +511,31 @@ onMounted(() => {
</span> </span>
</div> </div>
<div class="row-more" @click.stop> <div class="row-more" @click.stop>
<button <UiDropdownMenu
class="icon-more" :open="menuId === item.id"
type="button" align="end"
aria-label="عملیات" @update:open="(v) => (menuId = v ? item.id : null)"
@click="menuId = menuId === item.id ? null : item.id"
> >
<UiIcon name="dots-v" :size="16" /> <template #trigger="{ toggle }">
</button> <button
<div class="icon-more"
v-if="menuId === item.id" type="button"
class="popover" aria-label="عملیات"
:class="{ 'drop-up': isLastItem(item.id) }" @click.stop="toggle"
> >
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }" @click="menuId = null"> <UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }">
مشاهده مشاهده
</RouterLink> </RouterLink>
<RouterLink :to="{ name: 'activity-edit', params: { id: item.id } }" @click="menuId = null"> <RouterLink :to="{ name: 'activity-edit', params: { id: item.id } }">
ویرایش ویرایش
</RouterLink> </RouterLink>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)"> <button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
انجام شد انجام شد
</button> </button>
</div> </UiDropdownMenu>
</div> </div>
</article> </article>
</div> </div>
@@ -565,31 +562,32 @@ onMounted(() => {
</p> </p>
</div> </div>
<div class="card-side"> <div class="card-side">
<button <UiDropdownMenu
class="icon-more" :open="menuId === item.id"
type="button" align="end"
aria-label="عملیات" @update:open="(v) => (menuId = v ? item.id : null)"
@click.stop="menuId = menuId === item.id ? null : item.id"
> >
<UiIcon name="dots-v" :size="16" /> <template #trigger="{ toggle }">
</button> <button
<span class="type-ico" :class="item.type"> class="icon-more"
<UiIcon :name="typeIcon[item.type]" :size="18" /> type="button"
</span> aria-label="عملیات"
<span class="type-pill" :class="item.type">{{ typeLabel[item.type] }}</span> @click.stop="toggle"
<div >
v-if="menuId === item.id" <UiIcon name="dots-v" :size="16" />
class="popover" </button>
:class="{ 'drop-up': isLastItem(item.id) }" </template>
@click.stop <RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }">
>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }" @click="menuId = null">
مشاهده مشاهده
</RouterLink> </RouterLink>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)"> <button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
انجام شد انجام شد
</button> </button>
</div> </UiDropdownMenu>
<span class="type-ico" :class="item.type">
<UiIcon :name="typeIcon[item.type]" :size="18" />
</span>
<span class="type-pill" :class="item.type">{{ typeLabel[item.type] }}</span>
</div> </div>
</div> </div>
<footer class="card-foot"> <footer class="card-foot">
@@ -849,11 +847,6 @@ h1 {
z-index: 20; z-index: 20;
} }
.popover.drop-up {
top: auto;
bottom: calc(100% + 6px);
}
.popover a, .popover a,
.popover button { .popover button {
display: block; display: block;
@@ -968,10 +961,6 @@ h1 {
border-radius: 0 0 var(--radius) var(--radius); border-radius: 0 0 var(--radius) var(--radius);
} }
.trow.menu-open {
z-index: 12;
}
.title-cell { .title-cell {
display: flex; display: flex;
gap: 0.55rem; gap: 0.55rem;
+44 -35
View File
@@ -9,6 +9,7 @@ import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import PhoneContactsImport from '@/components/PhoneContactsImport.vue'; import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
import { import {
UiButton, UiButton,
UiDropdownMenu,
UiModal, UiModal,
UiPageHeader, UiPageHeader,
UiPagination, UiPagination,
@@ -372,9 +373,27 @@ onMounted(load);
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }"> <RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }} {{ p.fullName }}
</RouterLink> </RouterLink>
<button class="more" type="button" @click.stop="menuId = menuId === p.id ? null : p.id"> <UiDropdownMenu
<UiIcon name="dots-v" :size="16" /> :open="menuId === p.id"
</button> align="end"
@update:open="(v) => (menuId = v ? p.id : null)"
>
<template #trigger="{ toggle }">
<button class="more" type="button" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
<button
v-for="mood in PERSON_MOODS"
:key="mood"
type="button"
@click.stop="setMoodFor(p, mood)"
>
{{ moodLabel(mood) }}
</button>
</UiDropdownMenu>
</div> </div>
<p class="role"> <p class="role">
{{ [p.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }} {{ [p.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}
@@ -389,17 +408,6 @@ onMounted(load);
</p> </p>
<span class="badge" :class="moodOf(p)">{{ moodLabel(moodOf(p)) }}</span> <span class="badge" :class="moodOf(p)">{{ moodLabel(moodOf(p)) }}</span>
</div> </div>
<ul v-if="menuId === p.id" class="menu row-menu">
<li>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
</li>
<li>
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
</li>
<li v-for="mood in PERSON_MOODS" :key="mood">
<button type="button" @click.stop="setMoodFor(p, mood)">{{ moodLabel(mood) }}</button>
</li>
</ul>
</article> </article>
</div> </div>
@@ -449,21 +457,28 @@ onMounted(load);
</span> </span>
</td> </td>
<td>{{ formatDate(p.createdAt) }}</td> <td>{{ formatDate(p.createdAt) }}</td>
<td class="ops"> <td class="ops" @click.stop>
<button type="button" class="more" @click.stop="menuId = menuId === p.id ? null : p.id"> <UiDropdownMenu
<UiIcon name="dots-v" :size="16" /> :open="menuId === p.id"
</button> align="end"
<ul v-if="menuId === p.id" class="menu row-menu"> @update:open="(v) => (menuId = v ? p.id : null)"
<li> >
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink> <template #trigger="{ toggle }">
</li> <button type="button" class="more" @click.stop="toggle">
<li> <UiIcon name="dots-v" :size="16" />
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink> </button>
</li> </template>
<li v-for="mood in PERSON_MOODS" :key="mood"> <RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
<button type="button" @click.stop="setMoodFor(p, mood)">{{ moodLabel(mood) }}</button> <RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
</li> <button
</ul> v-for="mood in PERSON_MOODS"
:key="mood"
type="button"
@click.stop="setMoodFor(p, mood)"
>
{{ moodLabel(mood) }}
</button>
</UiDropdownMenu>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -1005,12 +1020,6 @@ strong.newcomer {
flex-shrink: 0; flex-shrink: 0;
} }
.row-menu {
inset-inline-start: auto;
inset-inline-end: 0;
top: 2.4rem;
}
.empty { .empty {
margin: 0; margin: 0;
padding: 1.4rem; padding: 1.4rem;
+24 -12
View File
@@ -11,6 +11,7 @@ import {
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { import {
UiButton, UiButton,
UiDropdownMenu,
UiModal, UiModal,
UiPageHeader, UiPageHeader,
UiPagination, UiPagination,
@@ -522,12 +523,18 @@ onMounted(load);
<RouterLink class="icon-btn sm" :to="r.href" title="ویرایش"> <RouterLink class="icon-btn sm" :to="r.href" title="ویرایش">
<UiIcon name="pencil" :size="15" /> <UiIcon name="pencil" :size="15" />
</RouterLink> </RouterLink>
<button type="button" class="icon-btn sm" @click="menuId = menuId === r.id ? null : r.id"> <UiDropdownMenu
<UiIcon name="dots-v" :size="15" /> :open="menuId === r.id"
</button> align="end"
<div v-if="menuId === r.id" class="menu left"> @update:open="(v) => (menuId = v ? r.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="icon-btn sm" @click.stop="toggle">
<UiIcon name="dots-v" :size="15" />
</button>
</template>
<RouterLink :to="r.href">باز کردن</RouterLink> <RouterLink :to="r.href">باز کردن</RouterLink>
</div> </UiDropdownMenu>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -548,14 +555,20 @@ onMounted(load);
{{ r.statusLabel }} {{ r.statusLabel }}
</span> </span>
<div class="mob-aside"> <div class="mob-aside">
<button type="button" class="icon-btn sm ghost" @click="menuId = menuId === r.id ? null : r.id"> <UiDropdownMenu
<UiIcon name="dots-v" :size="16" /> :open="menuId === r.id"
</button> align="end"
@update:open="(v) => (menuId = v ? r.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="icon-btn sm ghost" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<RouterLink :to="r.href">باز کردن</RouterLink>
</UiDropdownMenu>
<span class="avatar">{{ r.customerName.slice(0, 1) }}</span> <span class="avatar">{{ r.customerName.slice(0, 1) }}</span>
</div> </div>
<div v-if="menuId === r.id" class="menu left mob-menu">
<RouterLink :to="r.href">باز کردن</RouterLink>
</div>
</div> </div>
<div class="mob-card-bottom"> <div class="mob-card-bottom">
<em :class="{ neg: r.amount < 0 }">{{ money(r.amount, r.currency) }}</em> <em :class="{ neg: r.amount < 0 }">{{ money(r.amount, r.currency) }}</em>
@@ -835,7 +848,6 @@ tbody tr:nth-child(even) { background: #fcfdff; }
.mob-info { text-decoration: none; color: inherit; display: grid; gap: 0.12rem; min-width: 0; } .mob-info { text-decoration: none; color: inherit; display: grid; gap: 0.12rem; min-width: 0; }
.mob-info .mono, .mob-info .muted { font-size: 0.8rem; color: var(--muted); } .mob-info .mono, .mob-info .muted { font-size: 0.8rem; color: var(--muted); }
.mob-aside { display: grid; justify-items: center; gap: 0.35rem; } .mob-aside { display: grid; justify-items: center; gap: 0.35rem; }
.mob-menu { top: 2.2rem; }
.mob-card-bottom { display: flex; justify-content: flex-start; } .mob-card-bottom { display: flex; justify-content: flex-start; }
.mob-card-bottom em { font-style: normal; font-weight: 600; font-size: 0.92rem; } .mob-card-bottom em { font-style: normal; font-weight: 600; font-size: 0.92rem; }
+26 -24
View File
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles'; import { canManageSettings } from '@/lib/roles';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu } from '@/components/ui';
import { formatRelativeContact, toFaDigits } from '@/lib/jalali'; import { formatRelativeContact, toFaDigits } from '@/lib/jalali';
import type { import type {
CreateLoyaltyRewardRequest, CreateLoyaltyRewardRequest,
@@ -785,14 +786,20 @@ onBeforeUnmount(() => {
<button class="act minus" type="button" title="کسر امتیاز" @click="openAdjust(m, -1)"> <button class="act minus" type="button" title="کسر امتیاز" @click="openAdjust(m, -1)">
<UiIcon name="minus" :size="14" /> <UiIcon name="minus" :size="14" />
</button> </button>
<button class="act more" type="button" @click="menuId = menuId === m.id ? null : m.id"> <UiDropdownMenu
<UiIcon name="dots" :size="14" /> :open="menuId === m.id"
</button> align="end"
<div v-if="menuId === m.id" class="menu"> @update:open="(v) => (menuId = v ? m.id : null)"
>
<template #trigger="{ toggle }">
<button class="act more" type="button" @click.stop="toggle">
<UiIcon name="dots" :size="14" />
</button>
</template>
<RouterLink :to="`/contacts/people/${m.id}`">پروفایل</RouterLink> <RouterLink :to="`/contacts/people/${m.id}`">پروفایل</RouterLink>
<button type="button" @click="openLedger(m)">تاریخچه امتیاز</button> <button type="button" @click="openLedger(m)">تاریخچه امتیاز</button>
<button type="button" @click="openRedeem(undefined, m)">ثبت پاداش</button> <button type="button" @click="openRedeem(undefined, m)">ثبت پاداش</button>
</div> </UiDropdownMenu>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -815,9 +822,20 @@ onBeforeUnmount(() => {
<b class="mob-pts"><span>امتیاز</span><em>{{ faNum(m.loyaltyPoints) }}</em></b> <b class="mob-pts"><span>امتیاز</span><em>{{ faNum(m.loyaltyPoints) }}</em></b>
<div class="mob-side"> <div class="mob-side">
<div class="ops"> <div class="ops">
<button class="act more" type="button" @click.stop="menuId = menuId === m.id ? null : m.id"> <UiDropdownMenu
<UiIcon name="dots" :size="14" /> :open="menuId === m.id"
</button> align="end"
@update:open="(v) => (menuId = v ? m.id : null)"
>
<template #trigger="{ toggle }">
<button class="act more" type="button" @click.stop="toggle">
<UiIcon name="dots" :size="14" />
</button>
</template>
<RouterLink :to="`/contacts/people/${m.id}`">پروفایل</RouterLink>
<button type="button" @click="openLedger(m)">تاریخچه امتیاز</button>
<button type="button" @click="openRedeem(undefined, m)">ثبت پاداش</button>
</UiDropdownMenu>
<button class="act plus" type="button" @click="openAdjust(m, 1)"> <button class="act plus" type="button" @click="openAdjust(m, 1)">
<UiIcon name="plus" :size="14" /> <UiIcon name="plus" :size="14" />
</button> </button>
@@ -825,11 +843,6 @@ onBeforeUnmount(() => {
<UiIcon name="minus" :size="14" /> <UiIcon name="minus" :size="14" />
</button> </button>
</div> </div>
<div v-if="menuId === m.id" class="menu" @click.stop>
<RouterLink :to="`/contacts/people/${m.id}`">پروفایل</RouterLink>
<button type="button" @click="openLedger(m)">تاریخچه امتیاز</button>
<button type="button" @click="openRedeem(undefined, m)">ثبت پاداش</button>
</div>
</div> </div>
</li> </li>
</ul> </ul>
@@ -1287,17 +1300,6 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; }
.act.plus { background: #dcfce7; color: #15803d; } .act.plus { background: #dcfce7; color: #15803d; }
.act.minus { background: #fee2e2; color: #dc2626; } .act.minus { background: #fee2e2; color: #dc2626; }
.act.more { background: #f1f5f9; color: #64748b; } .act.more { background: #f1f5f9; color: #64748b; }
.menu {
position: absolute; inset-inline-end: 0; top: 34px; z-index: 5;
background: #fff; border: 1px solid #e2e8f0; border-radius: var(--radius);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
display: grid; min-width: 140px; overflow: hidden;
}
.menu a, .menu button {
background: none; border: 0; text-align: right; padding: 0.55rem 0.75rem;
font: inherit; cursor: pointer; color: var(--ink); text-decoration: none;
}
.menu a:hover, .menu button:hover { background: #f8fafc; }
.mob-list { display: none; list-style: none; margin: 0; padding: 0; gap: 0.65rem; } .mob-list { display: none; list-style: none; margin: 0; padding: 0; gap: 0.65rem; }
.mob-list li { .mob-list li {
+16 -35
View File
@@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu } from '@/components/ui';
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import type { import type {
CreateMessageAutomationRuleRequest, CreateMessageAutomationRuleRequest,
@@ -414,17 +415,23 @@ onUnmounted(() => {
> >
<header> <header>
<div class="m-menu"> <div class="m-menu">
<button <UiDropdownMenu
type="button" :open="menuId === row.id"
aria-label="عملیات" align="end"
@click="menuId = menuId === row.id ? null : row.id" @update:open="(v) => (menuId = v ? row.id : null)"
> >
<UiIcon name="dots-v" :size="18" /> <template #trigger="{ toggle }">
</button> <button
<div v-if="menuId === row.id" class="pop"> type="button"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="18" />
</button>
</template>
<button type="button" @click="openMobileEdit(row)">ویرایش</button> <button type="button" @click="openMobileEdit(row)">ویرایش</button>
<button type="button" class="danger" @click="askRemove(row)">حذف</button> <button type="button" class="danger" @click="askRemove(row)">حذف</button>
</div> </UiDropdownMenu>
</div> </div>
<div class="m-copy"> <div class="m-copy">
<strong>{{ row.name }}</strong> <strong>{{ row.name }}</strong>
@@ -2144,7 +2151,7 @@ textarea {
position: relative; position: relative;
} }
.m-menu > button, .m-menu :deep(button),
.m-back { .m-back {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
@@ -2157,32 +2164,6 @@ textarea {
padding: 0; padding: 0;
} }
.m-menu .pop {
position: absolute;
inset-inline-start: 0;
top: 2.1rem;
z-index: 4;
min-width: 7.5rem;
display: grid;
padding: 0.35rem;
border-radius: 12px;
background: #fff;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
}
.m-menu .pop button {
border: 0;
background: transparent;
text-align: start;
padding: 0.5rem 0.6rem;
font: inherit;
cursor: pointer;
}
.m-menu .pop .danger {
color: #b91c1c;
}
.m-card footer { .m-card footer {
display: flex; display: flex;
align-items: center; align-items: center;
+74 -65
View File
@@ -8,6 +8,7 @@ import { formatMoney } from '@/lib/money';
import { import {
UiBadge, UiBadge,
UiButton, UiButton,
UiDropdownMenu,
UiEmptyState, UiEmptyState,
UiInput, UiInput,
UiModal, UiModal,
@@ -272,22 +273,22 @@ onMounted(load);
<UiIcon name="plus" :size="16" /> <UiIcon name="plus" :size="16" />
محصول جدید محصول جدید
</UiButton> </UiButton>
<div class="more-wrap" @click.stop> <UiDropdownMenu v-model:open="moreOpen" align="end">
<UiButton <template #trigger="{ toggle }">
variant="ghost" <UiButton
size="md" variant="ghost"
aria-label="عملیات بیشتر" size="md"
@click="moreOpen = !moreOpen" aria-label="عملیات بیشتر"
> @click.stop="toggle"
<UiIcon name="dots" :size="18" /> >
</UiButton> <UiIcon name="dots" :size="18" />
<div v-if="moreOpen" class="menu"> </UiButton>
<button type="button" :disabled="!selected.size" @click="removeSelected"> </template>
حذف انتخاب‌شده‌ها <button type="button" :disabled="!selected.size" @click="removeSelected">
</button> حذف انتخاب‌شده‌ها
<button type="button" @click="load">بروزرسانی لیست</button> </button>
</div> <button type="button" @click="load">بروزرسانی لیست</button>
</div> </UiDropdownMenu>
</template> </template>
</UiPageHeader> </UiPageHeader>
@@ -312,23 +313,23 @@ onMounted(load);
<option value="active">فعال</option> <option value="active">فعال</option>
<option value="inactive">غیرفعال</option> <option value="inactive">غیرفعال</option>
</UiSelect> </UiSelect>
<div class="sort-wrap"> <UiDropdownMenu v-model:open="sortOpen" align="end">
<UiButton variant="outline" @click="sortOpen = !sortOpen"> <template #trigger="{ toggle }">
<UiIcon name="filter" :size="16" /> <UiButton variant="outline" @click.stop="toggle">
مرتب‌سازی <UiIcon name="filter" :size="16" />
</UiButton> مرتب‌سازی
<div v-if="sortOpen" class="menu"> </UiButton>
<button type="button" :class="{ on: sort === 'name' }" @click="sort = 'name'; sortOpen = false"> </template>
نام <button type="button" :class="{ on: sort === 'name' }" @click="sort = 'name'">
</button> نام
<button type="button" :class="{ on: sort === 'newest' }" @click="sort = 'newest'; sortOpen = false"> </button>
جدیدترین <button type="button" :class="{ on: sort === 'newest' }" @click="sort = 'newest'">
</button> جدیدترین
<button type="button" :class="{ on: sort === 'price' }" @click="sort = 'price'; sortOpen = false"> </button>
قیمت <button type="button" :class="{ on: sort === 'price' }" @click="sort = 'price'">
</button> قیمت
</div> </button>
</div> </UiDropdownMenu>
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
@@ -398,23 +399,27 @@ onMounted(load);
<small>{{ formatTime(p.createdAt) }}</small> <small>{{ formatTime(p.createdAt) }}</small>
</td> </td>
<td class="ops" @click.stop> <td class="ops" @click.stop>
<div class="row-more"> <UiDropdownMenu
<button :open="menuId === p.id"
type="button" align="end"
class="icon-btn" @update:open="(v) => (menuId = v ? p.id : null)"
aria-label="عملیات" >
@click="menuId = menuId === p.id ? null : p.id" <template #trigger="{ toggle }">
> <button
<UiIcon name="dots" :size="16" /> type="button"
</button> class="icon-btn"
<div v-if="menuId === p.id" class="menu"> aria-label="عملیات"
<button type="button" @click="goEdit(p.id)">ویرایش</button> @click.stop="toggle"
<button type="button" @click="toggleActive(p)"> >
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }} <UiIcon name="dots" :size="16" />
</button> </button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button> </template>
</div> <button type="button" @click="goEdit(p.id)">ویرایش</button>
</div> <button type="button" @click="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
</UiDropdownMenu>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -443,23 +448,27 @@ onMounted(load);
</span> </span>
</div> </div>
<div class="mob-side"> <div class="mob-side">
<div class="row-more"> <UiDropdownMenu
<button :open="menuId === p.id"
type="button" align="end"
class="icon-btn" @update:open="(v) => (menuId = v ? p.id : null)"
aria-label="عملیات" >
@click="menuId = menuId === p.id ? null : p.id" <template #trigger="{ toggle }">
> <button
<UiIcon name="dots-v" :size="16" /> type="button"
</button> class="icon-btn"
<div v-if="menuId === p.id" class="menu left"> aria-label="عملیات"
<button type="button" @click="goEdit(p.id)">ویرایش</button> @click.stop="toggle"
<button type="button" @click="toggleActive(p)"> >
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }} <UiIcon name="dots-v" :size="16" />
</button> </button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button> </template>
</div> <button type="button" @click="goEdit(p.id)">ویرایش</button>
</div> <button type="button" @click="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
</UiDropdownMenu>
<UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot> <UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot>
{{ p.isActive ? 'فعال' : 'غیرفعال' }} {{ p.isActive ? 'فعال' : 'غیرفعال' }}
</UiBadge> </UiBadge>
+27 -23
View File
@@ -15,6 +15,7 @@ import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { import {
UiDropdownMenu,
UiPageHeader, UiPageHeader,
UiPagination, UiPagination,
UiChart, UiChart,
@@ -649,13 +650,19 @@ onMounted(() => {
<button type="button" class="icon-btn sm" title="ویرایش" @click="openEdit(g)"> <button type="button" class="icon-btn sm" title="ویرایش" @click="openEdit(g)">
<UiIcon name="pencil" :size="15" /> <UiIcon name="pencil" :size="15" />
</button> </button>
<button type="button" class="icon-btn sm" @click="menuId = menuId === g.id ? null : g.id"> <UiDropdownMenu
<UiIcon name="dots-v" :size="15" /> :open="menuId === g.id"
</button> align="end"
<div v-if="menuId === g.id" class="menu"> @update:open="(v) => (menuId = v ? g.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="icon-btn sm" @click.stop="toggle">
<UiIcon name="dots-v" :size="15" />
</button>
</template>
<button type="button" @click="openEdit(g)">ویرایش</button> <button type="button" @click="openEdit(g)">ویرایش</button>
<button type="button" class="danger" @click="removeGoal(g.id)">حذف</button> <button type="button" class="danger" @click="removeGoal(g.id)">حذف</button>
</div> </UiDropdownMenu>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -672,13 +679,19 @@ onMounted(() => {
</button> </button>
</div> </div>
<article v-for="g in filteredGoals" :key="g.id" class="goal-card" @click.stop> <article v-for="g in filteredGoals" :key="g.id" class="goal-card" @click.stop>
<button type="button" class="dots" @click="menuId = menuId === g.id ? null : g.id"> <UiDropdownMenu
<UiIcon name="dots-v" :size="16" /> :open="menuId === g.id"
</button> align="end"
<div v-if="menuId === g.id" class="menu left"> @update:open="(v) => (menuId = v ? g.id : null)"
>
<template #trigger="{ toggle }">
<button type="button" class="dots" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<button type="button" @click="openEdit(g)">ویرایش</button> <button type="button" @click="openEdit(g)">ویرایش</button>
<button type="button" class="danger" @click="removeGoal(g.id)">حذف</button> <button type="button" class="danger" @click="removeGoal(g.id)">حذف</button>
</div> </UiDropdownMenu>
<div class="goal-body"> <div class="goal-body">
<div> <div>
<strong>{{ g.title }}</strong> <strong>{{ g.title }}</strong>
@@ -872,18 +885,6 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: #64748b; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: #64748b;
} }
.icon-btn.sm { width: 30px; height: 30px; } .icon-btn.sm { width: 30px; height: 30px; }
.menu {
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 120px;
background: #fff; border: 1px solid #e2e8f0; border-radius: var(--radius); box-shadow: 0 10px 24px rgba(15,23,42,.12);
padding: 0.3rem; display: grid;
}
.menu.left { right: 0; left: auto; }
.menu button {
border: 0; background: transparent; text-align: right; padding: 0.5rem 0.65rem; border-radius: var(--radius);
cursor: pointer; font: inherit;
}
.menu button:hover { background: var(--teal-soft); color: var(--teal); }
.menu button.danger { color: #b91c1c; }
.pager { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; align-items: center; } .pager { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; align-items: center; }
.pager-meta { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; } .pager-meta { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
@@ -907,8 +908,11 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
display: grid; place-items: center; cursor: pointer; display: grid; place-items: center; cursor: pointer;
} }
.goal-card { padding: 0.9rem; display: grid; gap: 0.65rem; position: relative; } .goal-card { padding: 0.9rem; display: grid; gap: 0.65rem; position: relative; }
.goal-card :deep(.ui-dd-anchor) {
position: absolute; top: 0.6rem; left: 0.5rem;
}
.goal-card .dots { .goal-card .dots {
position: absolute; top: 0.6rem; left: 0.5rem; border: 0; background: transparent; color: #94a3b8; cursor: pointer; border: 0; background: transparent; color: #94a3b8; cursor: pointer;
} }
.goal-body { display: grid; grid-template-columns: 1fr auto; gap: 0.65rem; align-items: center; } .goal-body { display: grid; grid-template-columns: 1fr auto; gap: 0.65rem; align-items: center; }
.goal-body strong { display: block; } .goal-body strong { display: block; }
+14 -43
View File
@@ -5,7 +5,7 @@ import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import UiModal from '@/components/ui/UiModal.vue'; import { UiDropdownMenu, UiModal } from '@/components/ui';
import { import {
ROLE_PERMISSION_ITEMS, ROLE_PERMISSION_ITEMS,
type InviteUserRequest, type InviteUserRequest,
@@ -754,16 +754,20 @@ onUnmounted(() => {
</td> </td>
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td> <td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
<td class="ops" @click.stop> <td class="ops" @click.stop>
<div class="kebab"> <UiDropdownMenu
<button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="menuUserId = menuUserId === user.id ? null : user.id"> :open="menuUserId === user.id"
<UiIcon name="dots-v" :size="16" /> align="end"
</button> @update:open="(v) => (menuUserId = v ? user.id : null)"
<div v-if="menuUserId === user.id" class="menu" @click.stop> >
<button type="button" @click="toggleStatus(user)"> <template #trigger="{ toggle }">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }} <button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="toggle">
<UiIcon name="dots-v" :size="16" />
</button> </button>
</div> </template>
</div> <button type="button" @click="toggleStatus(user)">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
</button>
</UiDropdownMenu>
</td> </td>
</tr> </tr>
<tr v-if="!loading && !pagedUsers.length"> <tr v-if="!loading && !pagedUsers.length">
@@ -1218,39 +1222,6 @@ tbody tr + tr td {
color: #94a3b8; color: #94a3b8;
} }
.kebab {
position: relative;
}
.menu {
position: absolute;
inset-inline-start: 0;
top: 100%;
z-index: 5;
min-width: 9rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
padding: 0.35rem;
}
.menu button {
width: 100%;
appearance: none;
background: none;
border: 0;
text-align: right;
padding: 0.45rem 0.55rem;
border-radius: 8px;
font: inherit;
cursor: pointer;
}
.menu button:hover {
background: #f8fafc;
}
.ghost-ico { .ghost-ico {
width: 32px; width: 32px;
height: 32px; height: 32px;
+5 -4
View File
@@ -141,9 +141,9 @@ mkdir -p uploads
# اختیاری یک‌بار: npm run db:seed # اختیاری یک‌بار: npm run db:seed
``` ```
### ۶) تبدیل صوت به متن (Pipecat) ### ۶) تبدیل صوت به متن (Pipecat — Whisper محلی)
فقط STT. روی سرور Python 3.11+ لازم است. فقط STT روی همین سرور؛ **بدون GapGPT**. Python 3.11+ و ترجیحاً `ffmpeg`.
```bash ```bash
cd /var/www/crm/apps/stt cd /var/www/crm/apps/stt
@@ -153,8 +153,9 @@ cp .env.example .env
nano .env nano .env
``` ```
`OPENAI_API_KEY` و `OPENAI_BASE_URL` را مثل `AI_*` در `apps/api/.env` بگذارید. در `.env` فقط مدل محلی لازم است (`STT_MODEL=small`، `STT_DEVICE=cpu`).
`STT_INTERNAL_TOKEN` باید با مقدار همین کلید در `apps/api/.env` یکی باشد. `OPENAI_API_KEY` برای دیکته لازم نیست.
`STT_INTERNAL_TOKEN` اگر پر شد باید با `apps/api/.env` یکی باشد.
### ۷) PM2 — فقط پروسه‌های جدید ### ۷) PM2 — فقط پروسه‌های جدید