Refining the speech-to-text conversion structure
This commit is contained in:
@@ -66,9 +66,12 @@ export class AiSttService {
|
||||
const detail = Array.isArray(payload.detail)
|
||||
? payload.detail.map((item) => item.msg).filter(Boolean).join('، ')
|
||||
: payload.detail;
|
||||
throw new ServiceUnavailableException(
|
||||
detail || 'تبدیل صوت به متن ناموفق بود',
|
||||
);
|
||||
const raw = String(detail || '').trim();
|
||||
let message = raw || 'تبدیل صوت به متن ناموفق بود';
|
||||
if (raw.length > 220) {
|
||||
message = 'تبدیل صوت به متن ناموفق بود. سرویس STT محلی را بررسی کنید.';
|
||||
}
|
||||
throw new ServiceUnavailableException(message);
|
||||
}
|
||||
|
||||
return { text: (payload.text ?? '').trim() };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Pipecat STT only — same OpenAI-compatible key as the Nest AI chat (GapGPT).
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.gapgpt.app/v1
|
||||
STT_MODEL=whisper-1
|
||||
# Local Pipecat Whisper — no GapGPT / OpenAI cloud key required for STT.
|
||||
STT_MODEL=small
|
||||
STT_DEVICE=cpu
|
||||
STT_COMPUTE_TYPE=int8
|
||||
STT_PROMPT=یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسبوکار را دقیق بنویس.
|
||||
|
||||
STT_HOST=127.0.0.1
|
||||
|
||||
+29
-7
@@ -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
|
||||
cd apps/stt
|
||||
@@ -16,8 +16,6 @@ pip install -r requirements.txt
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
در `.env` همان `AI_API_KEY` / `AI_BASE_URL` پنل را بگذارید (`OPENAI_API_KEY` و `OPENAI_BASE_URL`).
|
||||
|
||||
```powershell
|
||||
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` را پر کردید، همان مقدار را در هر دو `.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
@@ -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
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
|
||||
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
|
||||
|
||||
load_dotenv(Path(__file__).resolve().parent / ".env")
|
||||
@@ -20,7 +23,7 @@ MAX_BYTES = 8 * 1024 * 1024
|
||||
WAV_MAGIC = (b"RIFF", b"WAVE")
|
||||
|
||||
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:
|
||||
@@ -35,31 +38,53 @@ def _require_token(header_token: str | None) -> None:
|
||||
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
|
||||
if _stt is not None:
|
||||
return _stt
|
||||
|
||||
api_key = _env("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=503, detail="کلید تبدیل صوت تنظیم نشده است")
|
||||
|
||||
model = _env("STT_MODEL", "whisper-1")
|
||||
device = _env("STT_DEVICE", "cpu") or "cpu"
|
||||
compute = _env("STT_COMPUTE_TYPE", "int8") or "int8"
|
||||
prompt = _env(
|
||||
"STT_PROMPT",
|
||||
"یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسبوکار را دقیق بنویس.",
|
||||
)
|
||||
base_url = _env("OPENAI_BASE_URL") or None
|
||||
|
||||
_stt = OpenAISTTService(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
settings=OpenAISTTService.Settings(
|
||||
model=model,
|
||||
language=Language.FA,
|
||||
prompt=prompt,
|
||||
),
|
||||
)
|
||||
try:
|
||||
_stt = WhisperSTTService(
|
||||
device=device,
|
||||
compute_type=compute,
|
||||
settings=WhisperSTTService.Settings(
|
||||
model=_model_name(),
|
||||
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
|
||||
|
||||
|
||||
@@ -105,13 +130,93 @@ def _to_wav(data: bytes, filename: str) -> 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")
|
||||
async def health() -> dict[str, object]:
|
||||
return {
|
||||
"ok": True,
|
||||
"engine": "pipecat",
|
||||
"service": "OpenAISTTService",
|
||||
"configured": bool(_env("OPENAI_API_KEY")),
|
||||
"service": "WhisperSTTService",
|
||||
"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="حجم فایل صوتی بیش از حد مجاز است")
|
||||
|
||||
wav = _to_wav(data, audio.filename or "audio.wav")
|
||||
service = _stt_service()
|
||||
|
||||
try:
|
||||
result = await service._transcribe(wav)
|
||||
text = _transcribe_local(wav)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"تبدیل صوت به متن ناموفق بود: {exc}",
|
||||
) from exc
|
||||
status, detail = _friendly_stt_error(exc)
|
||||
raise HTTPException(status_code=status, detail=detail) from exc
|
||||
|
||||
text = (getattr(result, "text", None) or "").strip()
|
||||
return JSONResponse({"text": text})
|
||||
|
||||
@@ -2,4 +2,6 @@ fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
python-multipart==0.0.20
|
||||
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>
|
||||
@@ -13,3 +13,4 @@ export { default as UiSearch } from './UiSearch.vue';
|
||||
export { default as UiFilterBar } from './UiFilterBar.vue';
|
||||
export { default as UiSegmented } from './UiSegmented.vue';
|
||||
export { default as UiChart, CHART_PALETTE, type UiChartOption } from './UiChart.vue';
|
||||
export { default as UiDropdownMenu } from './UiDropdownMenu.vue';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/lib/jalali';
|
||||
import {
|
||||
UiButton,
|
||||
UiDropdownMenu,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
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 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) {
|
||||
if (item.status !== 'open' || !item.dueAt) return false;
|
||||
return startOfLocalDay(new Date(item.dueAt)).getTime() < startOfLocalDay().getTime();
|
||||
@@ -480,7 +476,6 @@ onMounted(() => {
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="trow"
|
||||
:class="{ 'menu-open': menuId === item.id }"
|
||||
role="row"
|
||||
>
|
||||
<div class="title-cell">
|
||||
@@ -516,29 +511,31 @@ onMounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-more" @click.stop>
|
||||
<button
|
||||
class="icon-more"
|
||||
type="button"
|
||||
aria-label="عملیات"
|
||||
@click="menuId = menuId === item.id ? null : item.id"
|
||||
<UiDropdownMenu
|
||||
:open="menuId === item.id"
|
||||
align="end"
|
||||
@update:open="(v) => (menuId = v ? item.id : null)"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<div
|
||||
v-if="menuId === item.id"
|
||||
class="popover"
|
||||
:class="{ 'drop-up': isLastItem(item.id) }"
|
||||
>
|
||||
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }" @click="menuId = null">
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
class="icon-more"
|
||||
type="button"
|
||||
aria-label="عملیات"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
</template>
|
||||
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }">
|
||||
مشاهده
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'activity-edit', params: { id: item.id } }" @click="menuId = null">
|
||||
<RouterLink :to="{ name: 'activity-edit', params: { id: item.id } }">
|
||||
ویرایش
|
||||
</RouterLink>
|
||||
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
|
||||
انجام شد
|
||||
</button>
|
||||
</div>
|
||||
</UiDropdownMenu>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -565,31 +562,32 @@ onMounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-side">
|
||||
<button
|
||||
class="icon-more"
|
||||
type="button"
|
||||
aria-label="عملیات"
|
||||
@click.stop="menuId = menuId === item.id ? null : item.id"
|
||||
<UiDropdownMenu
|
||||
:open="menuId === item.id"
|
||||
align="end"
|
||||
@update:open="(v) => (menuId = v ? item.id : null)"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<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
|
||||
v-if="menuId === item.id"
|
||||
class="popover"
|
||||
:class="{ 'drop-up': isLastItem(item.id) }"
|
||||
@click.stop
|
||||
>
|
||||
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }" @click="menuId = null">
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
class="icon-more"
|
||||
type="button"
|
||||
aria-label="عملیات"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
</template>
|
||||
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }">
|
||||
مشاهده
|
||||
</RouterLink>
|
||||
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
|
||||
انجام شد
|
||||
</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>
|
||||
<footer class="card-foot">
|
||||
@@ -849,11 +847,6 @@ h1 {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.popover.drop-up {
|
||||
top: auto;
|
||||
bottom: calc(100% + 6px);
|
||||
}
|
||||
|
||||
.popover a,
|
||||
.popover button {
|
||||
display: block;
|
||||
@@ -968,10 +961,6 @@ h1 {
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
}
|
||||
|
||||
.trow.menu-open {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
|
||||
@@ -9,6 +9,7 @@ import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiDropdownMenu,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
@@ -372,9 +373,27 @@ onMounted(load);
|
||||
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
|
||||
{{ p.fullName }}
|
||||
</RouterLink>
|
||||
<button class="more" type="button" @click.stop="menuId = menuId === p.id ? null : p.id">
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<UiDropdownMenu
|
||||
:open="menuId === p.id"
|
||||
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>
|
||||
<p class="role">
|
||||
{{ [p.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}
|
||||
@@ -389,17 +408,6 @@ onMounted(load);
|
||||
</p>
|
||||
<span class="badge" :class="moodOf(p)">{{ moodLabel(moodOf(p)) }}</span>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -449,21 +457,28 @@ onMounted(load);
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(p.createdAt) }}</td>
|
||||
<td class="ops">
|
||||
<button type="button" class="more" @click.stop="menuId = menuId === p.id ? null : p.id">
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<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>
|
||||
<td class="ops" @click.stop>
|
||||
<UiDropdownMenu
|
||||
:open="menuId === p.id"
|
||||
align="end"
|
||||
@update:open="(v) => (menuId = v ? p.id : null)"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button type="button" class="more" @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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -1005,12 +1020,6 @@ strong.newcomer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-menu {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 0;
|
||||
top: 2.4rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 1.4rem;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiDropdownMenu,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
@@ -522,12 +523,18 @@ onMounted(load);
|
||||
<RouterLink class="icon-btn sm" :to="r.href" title="ویرایش">
|
||||
<UiIcon name="pencil" :size="15" />
|
||||
</RouterLink>
|
||||
<button type="button" class="icon-btn sm" @click="menuId = menuId === r.id ? null : r.id">
|
||||
<UiIcon name="dots-v" :size="15" />
|
||||
</button>
|
||||
<div v-if="menuId === r.id" class="menu left">
|
||||
<UiDropdownMenu
|
||||
:open="menuId === r.id"
|
||||
align="end"
|
||||
@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>
|
||||
</div>
|
||||
</UiDropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -548,14 +555,20 @@ onMounted(load);
|
||||
{{ r.statusLabel }}
|
||||
</span>
|
||||
<div class="mob-aside">
|
||||
<button type="button" class="icon-btn sm ghost" @click="menuId = menuId === r.id ? null : r.id">
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<UiDropdownMenu
|
||||
:open="menuId === r.id"
|
||||
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>
|
||||
</div>
|
||||
<div v-if="menuId === r.id" class="menu left mob-menu">
|
||||
<RouterLink :to="r.href">باز کردن</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mob-card-bottom">
|
||||
<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 .mono, .mob-info .muted { font-size: 0.8rem; color: var(--muted); }
|
||||
.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 em { font-style: normal; font-weight: 600; font-size: 0.92rem; }
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiDropdownMenu } from '@/components/ui';
|
||||
import { formatRelativeContact, toFaDigits } from '@/lib/jalali';
|
||||
import type {
|
||||
CreateLoyaltyRewardRequest,
|
||||
@@ -785,14 +786,20 @@ onBeforeUnmount(() => {
|
||||
<button class="act minus" type="button" title="کسر امتیاز" @click="openAdjust(m, -1)">
|
||||
<UiIcon name="minus" :size="14" />
|
||||
</button>
|
||||
<button class="act more" type="button" @click="menuId = menuId === m.id ? null : m.id">
|
||||
<UiIcon name="dots" :size="14" />
|
||||
</button>
|
||||
<div v-if="menuId === m.id" class="menu">
|
||||
<UiDropdownMenu
|
||||
:open="menuId === m.id"
|
||||
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>
|
||||
</div>
|
||||
</UiDropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -815,9 +822,20 @@ onBeforeUnmount(() => {
|
||||
<b class="mob-pts"><span>امتیاز</span><em>{{ faNum(m.loyaltyPoints) }}</em></b>
|
||||
<div class="mob-side">
|
||||
<div class="ops">
|
||||
<button class="act more" type="button" @click.stop="menuId = menuId === m.id ? null : m.id">
|
||||
<UiIcon name="dots" :size="14" />
|
||||
</button>
|
||||
<UiDropdownMenu
|
||||
:open="menuId === m.id"
|
||||
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)">
|
||||
<UiIcon name="plus" :size="14" />
|
||||
</button>
|
||||
@@ -825,11 +843,6 @@ onBeforeUnmount(() => {
|
||||
<UiIcon name="minus" :size="14" />
|
||||
</button>
|
||||
</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>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1287,17 +1300,6 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; }
|
||||
.act.plus { background: #dcfce7; color: #15803d; }
|
||||
.act.minus { background: #fee2e2; color: #dc2626; }
|
||||
.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 li {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiDropdownMenu } from '@/components/ui';
|
||||
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import type {
|
||||
CreateMessageAutomationRuleRequest,
|
||||
@@ -414,17 +415,23 @@ onUnmounted(() => {
|
||||
>
|
||||
<header>
|
||||
<div class="m-menu">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="عملیات"
|
||||
@click="menuId = menuId === row.id ? null : row.id"
|
||||
<UiDropdownMenu
|
||||
:open="menuId === row.id"
|
||||
align="end"
|
||||
@update:open="(v) => (menuId = v ? row.id : null)"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="18" />
|
||||
</button>
|
||||
<div v-if="menuId === row.id" class="pop">
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
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" class="danger" @click="askRemove(row)">حذف</button>
|
||||
</div>
|
||||
</UiDropdownMenu>
|
||||
</div>
|
||||
<div class="m-copy">
|
||||
<strong>{{ row.name }}</strong>
|
||||
@@ -2144,7 +2151,7 @@ textarea {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.m-menu > button,
|
||||
.m-menu :deep(button),
|
||||
.m-back {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
@@ -2157,32 +2164,6 @@ textarea {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { formatMoney } from '@/lib/money';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiDropdownMenu,
|
||||
UiEmptyState,
|
||||
UiInput,
|
||||
UiModal,
|
||||
@@ -272,22 +273,22 @@ onMounted(load);
|
||||
<UiIcon name="plus" :size="16" />
|
||||
محصول جدید
|
||||
</UiButton>
|
||||
<div class="more-wrap" @click.stop>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
aria-label="عملیات بیشتر"
|
||||
@click="moreOpen = !moreOpen"
|
||||
>
|
||||
<UiIcon name="dots" :size="18" />
|
||||
</UiButton>
|
||||
<div v-if="moreOpen" class="menu">
|
||||
<button type="button" :disabled="!selected.size" @click="removeSelected">
|
||||
حذف انتخابشدهها
|
||||
</button>
|
||||
<button type="button" @click="load">بروزرسانی لیست</button>
|
||||
</div>
|
||||
</div>
|
||||
<UiDropdownMenu v-model:open="moreOpen" align="end">
|
||||
<template #trigger="{ toggle }">
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
aria-label="عملیات بیشتر"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="dots" :size="18" />
|
||||
</UiButton>
|
||||
</template>
|
||||
<button type="button" :disabled="!selected.size" @click="removeSelected">
|
||||
حذف انتخابشدهها
|
||||
</button>
|
||||
<button type="button" @click="load">بروزرسانی لیست</button>
|
||||
</UiDropdownMenu>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
@@ -312,23 +313,23 @@ onMounted(load);
|
||||
<option value="active">فعال</option>
|
||||
<option value="inactive">غیرفعال</option>
|
||||
</UiSelect>
|
||||
<div class="sort-wrap">
|
||||
<UiButton variant="outline" @click="sortOpen = !sortOpen">
|
||||
<UiIcon name="filter" :size="16" />
|
||||
مرتبسازی
|
||||
</UiButton>
|
||||
<div v-if="sortOpen" class="menu">
|
||||
<button type="button" :class="{ on: sort === 'name' }" @click="sort = 'name'; sortOpen = false">
|
||||
نام
|
||||
</button>
|
||||
<button type="button" :class="{ on: sort === 'newest' }" @click="sort = 'newest'; sortOpen = false">
|
||||
جدیدترین
|
||||
</button>
|
||||
<button type="button" :class="{ on: sort === 'price' }" @click="sort = 'price'; sortOpen = false">
|
||||
قیمت
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<UiDropdownMenu v-model:open="sortOpen" align="end">
|
||||
<template #trigger="{ toggle }">
|
||||
<UiButton variant="outline" @click.stop="toggle">
|
||||
<UiIcon name="filter" :size="16" />
|
||||
مرتبسازی
|
||||
</UiButton>
|
||||
</template>
|
||||
<button type="button" :class="{ on: sort === 'name' }" @click="sort = 'name'">
|
||||
نام
|
||||
</button>
|
||||
<button type="button" :class="{ on: sort === 'newest' }" @click="sort = 'newest'">
|
||||
جدیدترین
|
||||
</button>
|
||||
<button type="button" :class="{ on: sort === 'price' }" @click="sort = 'price'">
|
||||
قیمت
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
@@ -398,23 +399,27 @@ onMounted(load);
|
||||
<small>{{ formatTime(p.createdAt) }}</small>
|
||||
</td>
|
||||
<td class="ops" @click.stop>
|
||||
<div class="row-more">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="عملیات"
|
||||
@click="menuId = menuId === p.id ? null : p.id"
|
||||
>
|
||||
<UiIcon name="dots" :size="16" />
|
||||
</button>
|
||||
<div v-if="menuId === p.id" class="menu">
|
||||
<button type="button" @click="goEdit(p.id)">ویرایش</button>
|
||||
<button type="button" @click="toggleActive(p)">
|
||||
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
|
||||
<UiDropdownMenu
|
||||
:open="menuId === p.id"
|
||||
align="end"
|
||||
@update:open="(v) => (menuId = v ? p.id : null)"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="عملیات"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="dots" :size="16" />
|
||||
</button>
|
||||
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" @click="goEdit(p.id)">ویرایش</button>
|
||||
<button type="button" @click="toggleActive(p)">
|
||||
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
|
||||
</button>
|
||||
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
|
||||
</UiDropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -443,23 +448,27 @@ onMounted(load);
|
||||
</span>
|
||||
</div>
|
||||
<div class="mob-side">
|
||||
<div class="row-more">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="عملیات"
|
||||
@click="menuId = menuId === p.id ? null : p.id"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<div v-if="menuId === p.id" class="menu left">
|
||||
<button type="button" @click="goEdit(p.id)">ویرایش</button>
|
||||
<button type="button" @click="toggleActive(p)">
|
||||
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
|
||||
<UiDropdownMenu
|
||||
:open="menuId === p.id"
|
||||
align="end"
|
||||
@update:open="(v) => (menuId = v ? p.id : null)"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="عملیات"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<button type="button" class="danger" @click="removeProduct(p)">حذف</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" @click="goEdit(p.id)">ویرایش</button>
|
||||
<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>
|
||||
{{ p.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
|
||||
@@ -15,6 +15,7 @@ import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
UiDropdownMenu,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
UiChart,
|
||||
@@ -649,13 +650,19 @@ onMounted(() => {
|
||||
<button type="button" class="icon-btn sm" title="ویرایش" @click="openEdit(g)">
|
||||
<UiIcon name="pencil" :size="15" />
|
||||
</button>
|
||||
<button type="button" class="icon-btn sm" @click="menuId = menuId === g.id ? null : g.id">
|
||||
<UiIcon name="dots-v" :size="15" />
|
||||
</button>
|
||||
<div v-if="menuId === g.id" class="menu">
|
||||
<UiDropdownMenu
|
||||
:open="menuId === g.id"
|
||||
align="end"
|
||||
@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" class="danger" @click="removeGoal(g.id)">حذف</button>
|
||||
</div>
|
||||
</UiDropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -672,13 +679,19 @@ onMounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<div v-if="menuId === g.id" class="menu left">
|
||||
<UiDropdownMenu
|
||||
:open="menuId === g.id"
|
||||
align="end"
|
||||
@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" class="danger" @click="removeGoal(g.id)">حذف</button>
|
||||
</div>
|
||||
</UiDropdownMenu>
|
||||
<div class="goal-body">
|
||||
<div>
|
||||
<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;
|
||||
}
|
||||
.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-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;
|
||||
}
|
||||
.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 {
|
||||
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 strong { display: block; }
|
||||
|
||||
@@ -5,7 +5,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
import { UiDropdownMenu, UiModal } from '@/components/ui';
|
||||
import {
|
||||
ROLE_PERMISSION_ITEMS,
|
||||
type InviteUserRequest,
|
||||
@@ -754,16 +754,20 @@ onUnmounted(() => {
|
||||
</td>
|
||||
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
|
||||
<td class="ops" @click.stop>
|
||||
<div class="kebab">
|
||||
<button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="menuUserId = menuUserId === user.id ? null : user.id">
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<div v-if="menuUserId === user.id" class="menu" @click.stop>
|
||||
<button type="button" @click="toggleStatus(user)">
|
||||
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
|
||||
<UiDropdownMenu
|
||||
:open="menuUserId === user.id"
|
||||
align="end"
|
||||
@update:open="(v) => (menuUserId = v ? user.id : null)"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button type="button" class="ghost-ico" aria-label="عملیات" @click.stop="toggle">
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" @click="toggleStatus(user)">
|
||||
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !pagedUsers.length">
|
||||
@@ -1218,39 +1222,6 @@ tbody tr + tr td {
|
||||
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 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
+5
-4
@@ -141,9 +141,9 @@ mkdir -p uploads
|
||||
# اختیاری یکبار: npm run db:seed
|
||||
```
|
||||
|
||||
### ۶) تبدیل صوت به متن (Pipecat)
|
||||
### ۶) تبدیل صوت به متن (Pipecat — Whisper محلی)
|
||||
|
||||
فقط STT. روی سرور Python 3.11+ لازم است.
|
||||
فقط STT روی همین سرور؛ **بدون GapGPT**. Python 3.11+ و ترجیحاً `ffmpeg`.
|
||||
|
||||
```bash
|
||||
cd /var/www/crm/apps/stt
|
||||
@@ -153,8 +153,9 @@ cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
`OPENAI_API_KEY` و `OPENAI_BASE_URL` را مثل `AI_*` در `apps/api/.env` بگذارید.
|
||||
`STT_INTERNAL_TOKEN` باید با مقدار همین کلید در `apps/api/.env` یکی باشد.
|
||||
در `.env` فقط مدل محلی لازم است (`STT_MODEL=small`، `STT_DEVICE=cpu`).
|
||||
`OPENAI_API_KEY` برای دیکته لازم نیست.
|
||||
`STT_INTERNAL_TOKEN` اگر پر شد باید با `apps/api/.env` یکی باشد.
|
||||
|
||||
### ۷) PM2 — فقط پروسههای جدید
|
||||
|
||||
|
||||
Reference in New Issue
Block a user