Add internal org chat (phase 1) with DM, groups, and org settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-29 10:34:01 +03:30
co-authored by Cursor
parent 589c763ef8
commit a1655432fc
21 changed files with 1912 additions and 8 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ const salesActive = computed(() =>
);
const activitiesActive = computed(() => pathIn(['/activities']));
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
const supportActive = computed(() => pathIn(['/tickets']));
const supportActive = computed(() => pathIn(['/tickets', '/conversations']));
const automationsActive = computed(
() =>
route.path.startsWith('/settings/automations') ||
+14
View File
@@ -247,6 +247,12 @@ export const NAV_HUBS: NavHubDef[] = [
shortcuts: [
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
{ to: '/tickets', title: 'تیکت‌ها', icon: 'chat', module: 'tickets' },
{
to: '/conversations',
title: 'گفتگو',
icon: 'chat',
module: 'conversations',
},
],
cards: [
{
@@ -257,6 +263,14 @@ export const NAV_HUBS: NavHubDef[] = [
tint: 'peach',
module: 'tickets',
},
{
to: '/conversations',
title: 'گفتگوی درون‌سازمانی',
desc: 'پیام خصوصی و گروه بین همکاران',
icon: 'chat',
tint: 'violet',
module: 'conversations',
},
],
},
{
+211
View File
@@ -0,0 +1,211 @@
import { computed, onUnmounted, ref, shallowRef } from 'vue';
import type { ConversationSessionDto } from '@mento/shared';
import { api } from '@/lib/api';
export type ChatMessage = {
id: string;
roomId: string;
senderId: string;
content: string;
type: 'text' | 'image' | 'voice';
createdAt: string;
reply: string | null;
optimistic?: boolean;
};
const PAGE_SIZE = 50;
let socket: WebSocket | null = null;
let sessionCache: ConversationSessionDto | null = null;
const status = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle');
const messages = shallowRef<ChatMessage[]>([]);
const activeRoomId = ref('');
let pingTimer: ReturnType<typeof setInterval> | null = null;
function normalizeIncoming(raw: Record<string, unknown>, roomFallback: string): ChatMessage | null {
const base =
raw.message && typeof raw.message === 'object'
? (raw.message as Record<string, unknown>)
: raw;
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
if (!content && !base.attachments) return null;
const id = String(base.id ?? base.message_id ?? crypto.randomUUID());
return {
id,
roomId: String(base.room_id ?? raw.room_id ?? roomFallback),
senderId: String(
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
),
content,
type: (String(base.type_content ?? base.type ?? 'text') as ChatMessage['type']) || 'text',
createdAt: String(
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
),
reply: base.reply ? String(base.reply) : null,
};
}
async function ensureSession(): Promise<ConversationSessionDto> {
if (sessionCache) return sessionCache;
const { data } = await api.get<ConversationSessionDto>('/conversations/session');
sessionCache = data;
return data;
}
function clearPing() {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
}
function disconnectSocket() {
clearPing();
if (socket) {
socket.close();
socket = null;
}
status.value = 'closed';
}
async function connectSocket() {
const session = await ensureSession();
if (socket?.readyState === WebSocket.OPEN) return;
disconnectSocket();
status.value = 'connecting';
socket = new WebSocket(session.wsUrl);
socket.addEventListener('open', () => {
status.value = 'open';
pingTimer = setInterval(() => {
if (socket?.readyState === WebSocket.OPEN) socket.send('ping');
}, 15000);
});
socket.addEventListener('close', () => {
status.value = 'closed';
clearPing();
});
socket.addEventListener('message', (event) => {
const raw = String(event.data ?? '').trim();
if (!raw || raw === 'pong' || raw === 'ping') return;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (parsed.type === 'seen' || parsed.type === 'typing' || parsed.type === 'read') return;
const msg = normalizeIncoming(parsed, activeRoomId.value);
if (!msg || msg.roomId !== activeRoomId.value) return;
messages.value = [...messages.value, msg];
} catch {
// ignore
}
});
await new Promise<void>((resolve, reject) => {
if (!socket) return reject(new Error('socket missing'));
const t = setTimeout(() => reject(new Error('ws timeout')), 12000);
socket.addEventListener(
'open',
() => {
clearTimeout(t);
resolve();
},
{ once: true },
);
socket.addEventListener(
'error',
() => {
clearTimeout(t);
reject(new Error('ws error'));
},
{ once: true },
);
});
}
export function useOrgChat() {
const loadingMessages = ref(false);
async function loadMessages(roomId: string) {
activeRoomId.value = roomId;
loadingMessages.value = true;
try {
const session = await ensureSession();
await connectSocket();
const params = new URLSearchParams({
api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '',
room_id: roomId,
user_id: session.chatUserId,
limit: String(PAGE_SIZE),
offset: '0',
});
const url = `${session.messagesBaseUrl}/api/messages?${params.toString()}`;
const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!res.ok) throw new Error('messages failed');
const body = await res.json();
const list = Array.isArray(body) ? body : body?.data ?? [];
messages.value = list
.map((item: Record<string, unknown>) => normalizeIncoming(item, roomId))
.filter(Boolean) as ChatMessage[];
} finally {
loadingMessages.value = false;
}
}
async function sendText(roomId: string, text: string, reply: string | null = null) {
const trimmed = text.trim();
if (!trimmed) return;
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
const session = await ensureSession();
const id = crypto.randomUUID();
const payload: Record<string, string> = {
id,
type: 'text',
room_id: roomId,
content: trimmed,
};
if (reply) payload.reply = reply;
const optimistic: ChatMessage = {
id,
roomId,
senderId: session.chatUserId,
content: trimmed,
type: 'text',
createdAt: new Date().toISOString(),
reply,
optimistic: true,
};
messages.value = [...messages.value, optimistic];
socket.send(JSON.stringify(payload));
sendRead(roomId);
}
function sendRead(roomId: string) {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
}
function resetSession() {
sessionCache = null;
disconnectSocket();
messages.value = [];
activeRoomId.value = '';
}
onUnmounted(() => {
disconnectSocket();
});
return {
status: computed(() => status.value),
messages: computed(() => messages.value),
loadingMessages,
loadMessages,
sendText,
sendRead,
resetSession,
ensureSession,
};
}
+11
View File
@@ -92,6 +92,11 @@ export const router = createRouter({
name: 'tickets',
component: () => import('@/views/TicketsView.vue'),
},
{
path: 'conversations',
name: 'conversations',
component: () => import('@/views/ConversationsView.vue'),
},
{
path: 'tickets/new',
name: 'ticket-new',
@@ -344,6 +349,12 @@ export const router = createRouter({
component: () => import('@/views/VoipSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/conversations',
name: 'conversations-settings',
component: () => import('@/views/ConversationsSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/faq',
name: 'faq-settings',
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type { ConversationSettingsDto, UpdateConversationSettingsRequest } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const form = reactive({
chatHost: '',
apiKey: '',
uploadUrl: '',
downloadUrl: '',
});
const apiKeySet = ref(false);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<ConversationSettingsDto>('/conversations/settings');
form.chatHost = data.chatHost;
form.uploadUrl = data.uploadUrl;
form.downloadUrl = data.downloadUrl;
apiKeySet.value = data.apiKeySet;
form.apiKey = '';
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری تنظیمات ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function save() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const payload: UpdateConversationSettingsRequest = {
chatHost: form.chatHost.trim(),
uploadUrl: form.uploadUrl.trim() || undefined,
downloadUrl: form.downloadUrl.trim() || undefined,
};
if (form.apiKey.trim()) payload.apiKey = form.apiKey.trim();
const { data } = await api.patch<ConversationSettingsDto>('/conversations/settings', payload);
apiKeySet.value = data.apiKeySet;
form.apiKey = '';
ok.value = 'ذخیره شد.';
} catch (e) {
if (axios.isAxiosError(e)) {
error.value =
(e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.';
} else {
error.value = 'ذخیره ناموفق بود.';
}
} finally {
saving.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<div class="page">
<UiPageHeader
title="تنظیمات گفتگوی درون‌سازمانی"
subtitle="اتصال به سرویس گفتگو (REST و WebSocket) — هر محیط CRM جداگانه."
>
<template #actions>
<RouterLink to="/conversations" class="link-back">بازگشت به گفتگو</RouterLink>
</template>
</UiPageHeader>
<p v-if="error" class="banner err">{{ error }}</p>
<p v-if="ok" class="banner ok">{{ ok }}</p>
<UiCard v-if="!loading" class="form-card">
<UiField label="Host سرویس چت" hint="بدون https — مثال: chat.montest.ir">
<UiInput v-model="form.chatHost" dir="ltr" autocomplete="off" />
</UiField>
<UiField
:label="apiKeySet ? 'API Key (برای تغییر وارد کنید)' : 'API Key'"
hint="فقط روی سرور ذخیره می‌شود."
>
<UiInput v-model="form.apiKey" type="password" dir="ltr" autocomplete="new-password" />
</UiField>
<UiField label="آدرس آپلود فایل">
<UiInput v-model="form.uploadUrl" dir="ltr" />
</UiField>
<UiField label="آدرس پایه دانلود فایل">
<UiInput v-model="form.downloadUrl" dir="ltr" />
</UiField>
<div class="actions">
<UiButton :disabled="saving" @click="save">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
</div>
</UiCard>
<p v-else class="muted">در حال بارگذاری…</p>
</div>
</template>
<style scoped>
.page {
max-width: 640px;
margin: 0 auto;
padding: 1rem 1.25rem 2rem;
}
.link-back {
font-size: 0.9rem;
color: var(--brand);
}
.banner {
padding: 0.65rem 0.85rem;
border-radius: 10px;
margin-bottom: 0.75rem;
}
.banner.err {
background: #fef2f2;
color: #b91c1c;
}
.banner.ok {
background: #ecfdf5;
color: #047857;
}
.form-card {
display: grid;
gap: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
.muted {
color: var(--muted);
}
</style>
+652
View File
@@ -0,0 +1,652 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ConversationAccessDto,
ConversationPeerDto,
ConversationRoomDto,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { formatClockFa, toFaDigits } from '@/lib/jalali';
import { useOrgChat } from '@/lib/useOrgChat';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
const auth = useAuthStore();
const access = ref<ConversationAccessDto | null>(null);
const rooms = ref<ConversationRoomDto[]>([]);
const peers = ref<ConversationPeerDto[]>([]);
const loading = ref(true);
const error = ref('');
const activeRoom = ref<ConversationRoomDto | null>(null);
const draft = ref('');
const showNewDm = ref(false);
const showNewGroup = ref(false);
const groupName = ref('');
const groupMemberIds = ref<string[]>([]);
const dmPeerId = ref('');
const { messages, loadingMessages, status, loadMessages, sendText, sendRead } = useOrgChat();
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
const sortedRooms = computed(() =>
[...rooms.value].sort((a, b) => {
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
return tb - ta;
}),
);
const chatUserId = ref('');
function roomTitle(room: ConversationRoomDto) {
if (room.kind === 'group') return room.name;
const peer = peers.value.find((p) => p.id === room.peerUserId);
return peer?.fullName ?? room.name;
}
function formatListTime(iso: string | null) {
if (!iso) return '';
try {
return formatClockFa(new Date(iso));
} catch {
return '';
}
}
async function bootstrap() {
loading.value = true;
error.value = '';
try {
const [accessRes, roomsRes, peersRes] = await Promise.all([
api.get<ConversationAccessDto>('/conversations/access'),
api.get<ConversationRoomDto[]>('/conversations/rooms'),
api.get<ConversationPeerDto[]>('/conversations/peers'),
]);
access.value = accessRes.data;
if (!accessRes.data.canUse) {
error.value = 'به گفتگوی درون‌سازمانی دسترسی ندارید.';
return;
}
rooms.value = roomsRes.data;
peers.value = peersRes.data;
const sessionRes = await api.get<{ chatUserId: string }>('/conversations/session');
chatUserId.value = sessionRes.data.chatUserId;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
} else if (axios.isAxiosError(e) && e.response?.status === 400) {
error.value =
(e.response.data as { message?: string })?.message ??
'ابتدا تنظیمات گفتگو را تکمیل کنید.';
} else {
error.value = 'بارگذاری گفتگو ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function openRoom(room: ConversationRoomDto) {
activeRoom.value = room;
await loadMessages(room.externalRoomId);
sendRead(room.externalRoomId);
}
async function submitMessage() {
if (!activeRoom.value || !draft.value.trim()) return;
try {
await sendText(activeRoom.value.externalRoomId, draft.value);
draft.value = '';
} catch {
error.value = 'ارسال پیام ناموفق بود.';
}
}
async function startDm() {
if (!dmPeerId.value) return;
try {
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/dm', {
peerUserId: dmPeerId.value,
});
rooms.value = [data, ...rooms.value.filter((r) => r.externalRoomId !== data.externalRoomId)];
showNewDm.value = false;
dmPeerId.value = '';
await openRoom(data);
} catch {
error.value = 'شروع گفتگوی جدید ناموفق بود.';
}
}
async function startGroup() {
if (!groupName.value.trim() || !groupMemberIds.value.length) return;
try {
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/group', {
name: groupName.value.trim(),
memberUserIds: groupMemberIds.value,
});
rooms.value = [data, ...rooms.value];
showNewGroup.value = false;
groupName.value = '';
groupMemberIds.value = [];
await openRoom(data);
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'اجازه ساخت گروه را ندارید.';
} else {
error.value = 'ساخت گروه ناموفق بود.';
}
}
}
function toggleGroupMember(id: string) {
if (groupMemberIds.value.includes(id)) {
groupMemberIds.value = groupMemberIds.value.filter((x) => x !== id);
} else {
groupMemberIds.value = [...groupMemberIds.value, id];
}
}
watch(activeRoom, () => {
draft.value = '';
});
onMounted(() => {
void bootstrap();
});
</script>
<template>
<div class="conv-page">
<div class="conv-aurora" aria-hidden="true" />
<header class="conv-head">
<div>
<p class="eyebrow">درون سازمان</p>
<h1>گفتگو</h1>
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیام‌رسان بیرونی.</p>
</div>
<div class="head-actions">
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
<RouterLink v-if="auth.user?.role === 'owner' || auth.user?.role === 'admin'" to="/settings/conversations" class="gear">
<UiIcon name="settings" :size="18" />
</RouterLink>
</div>
</header>
<p v-if="error" class="err-banner">{{ error }}</p>
<p v-else-if="loading" class="muted center">در حال آماده‌سازی…</p>
<div v-else class="conv-shell">
<aside class="conv-rail">
<div class="rail-actions">
<button type="button" class="fab" @click="showNewDm = true">+ گفتگو</button>
<button
v-if="canCreateGroup"
type="button"
class="fab ghost"
@click="showNewGroup = true"
>
گروه
</button>
</div>
<ul class="room-list">
<li v-for="room in sortedRooms" :key="room.externalRoomId">
<button
type="button"
class="room-chip"
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId }"
@click="openRoom(room)"
>
<span class="avatar-ring">
<UserAvatar :name="roomTitle(room)" :size="40" />
</span>
<span class="room-meta">
<strong>{{ roomTitle(room) }}</strong>
<small>{{ room.lastMessagePreview || 'بدون پیام' }}</small>
</span>
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
<time>{{ formatListTime(room.lastMessageAt) }}</time>
</button>
</li>
</ul>
</aside>
<section class="conv-stage">
<div v-if="!activeRoom" class="empty-stage">
<div class="empty-glow" />
<UiIcon name="chat" :size="48" />
<h2>یک مکالمه را انتخاب کنید</h2>
<p>یا گفتگوی تازه با همکاران خود بسازید.</p>
</div>
<template v-else>
<div class="stage-top">
<h2>{{ roomTitle(activeRoom) }}</h2>
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
</div>
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیام‌ها…</div>
<ul v-else class="msg-stream">
<li
v-for="msg in messages"
:key="msg.id"
class="msg-row"
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
>
<div class="bubble">
<p>{{ msg.content }}</p>
<time>{{ formatListTime(msg.createdAt) }}</time>
</div>
</li>
</ul>
<form class="composer" @submit.prevent="submitMessage">
<input v-model="draft" type="text" placeholder="پیام خود را بنویسید…" autocomplete="off" />
<button type="submit" :disabled="!draft.trim()">
<UiIcon name="send" :size="18" />
</button>
</form>
</template>
</section>
</div>
<dialog v-if="showNewDm" class="modal" open @click.self="showNewDm = false">
<div class="modal-card">
<h3>گفتگوی جدید</h3>
<select v-model="dmPeerId">
<option value="">همکار را انتخاب کنید</option>
<option v-for="p in peers" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
<div class="modal-actions">
<button type="button" @click="showNewDm = false">انصراف</button>
<button type="button" class="primary" @click="startDm">شروع</button>
</div>
</div>
</dialog>
<dialog v-if="showNewGroup" class="modal" open @click.self="showNewGroup = false">
<div class="modal-card wide">
<h3>گروه جدید</h3>
<input v-model="groupName" type="text" placeholder="نام گروه" />
<p class="hint">اعضا</p>
<div class="member-grid">
<label v-for="p in peers" :key="p.id" class="member-check">
<input
type="checkbox"
:checked="groupMemberIds.includes(p.id)"
@change="toggleGroupMember(p.id)"
/>
{{ p.fullName }}
</label>
</div>
<div class="modal-actions">
<button type="button" @click="showNewGroup = false">انصراف</button>
<button type="button" class="primary" @click="startGroup">ساخت گروه</button>
</div>
</div>
</dialog>
</div>
</template>
<style scoped>
.conv-page {
position: relative;
min-height: calc(100vh - 4rem);
padding: 1rem 1.25rem 1.5rem;
overflow: hidden;
}
.conv-aurora {
position: absolute;
inset: -20% -10% auto;
height: 320px;
background:
radial-gradient(ellipse 60% 80% at 85% 0%, rgba(244, 114, 182, 0.22), transparent 60%),
radial-gradient(ellipse 50% 70% at 10% 10%, rgba(45, 212, 191, 0.2), transparent 55%);
pointer-events: none;
z-index: 0;
}
.conv-head {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.eyebrow {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 0.25rem;
}
.conv-head h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 800;
background: linear-gradient(120deg, #0f766e, #7c3aed 55%, #db2777);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.sub {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.head-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ws-pill {
font-size: 0.75rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
}
.ws-pill[data-state='open'] {
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35);
}
.gear {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--line);
color: var(--text);
}
.err-banner {
background: #fef2f2;
color: #b91c1c;
padding: 0.65rem 0.85rem;
border-radius: 12px;
margin-bottom: 0.75rem;
}
.conv-shell {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(260px, 320px) 1fr;
gap: 0.75rem;
min-height: 520px;
}
@media (max-width: 900px) {
.conv-shell {
grid-template-columns: 1fr;
}
}
.conv-rail {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(12px);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 20px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rail-actions {
display: flex;
gap: 0.4rem;
}
.fab {
flex: 1;
border: none;
border-radius: 14px;
padding: 0.55rem 0.65rem;
font-weight: 600;
cursor: pointer;
background: linear-gradient(135deg, #0d9488, #6366f1);
color: #fff;
}
.fab.ghost {
background: transparent;
border: 1px dashed rgba(99, 102, 241, 0.45);
color: #4338ca;
}
.room-list {
list-style: none;
margin: 0;
padding: 0;
overflow: auto;
max-height: calc(100vh - 14rem);
}
.room-chip {
width: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
gap: 0.15rem 0.65rem;
align-items: center;
text-align: right;
border: none;
background: transparent;
padding: 0.55rem 0.45rem;
border-radius: 14px;
cursor: pointer;
transition: background 0.15s ease;
}
.room-chip:hover,
.room-chip.active {
background: rgba(99, 102, 241, 0.08);
}
.avatar-ring {
grid-row: span 2;
border-radius: 999px;
padding: 2px;
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
}
.room-meta strong {
display: block;
font-size: 0.92rem;
}
.room-meta small {
color: var(--muted);
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
display: block;
}
.unread {
grid-column: 3;
grid-row: 1;
background: #db2777;
color: #fff;
font-size: 0.7rem;
min-width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
display: grid;
place-items: center;
}
.room-chip time {
grid-column: 3;
grid-row: 2;
font-size: 0.68rem;
color: var(--muted);
}
.conv-stage {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(14px);
border-radius: 22px;
border: 1px solid rgba(148, 163, 184, 0.22);
display: flex;
flex-direction: column;
min-height: 480px;
overflow: hidden;
}
.stage-top {
padding: 1rem 1.15rem;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
gap: 0.5rem;
}
.stage-top h2 {
margin: 0;
font-size: 1.1rem;
}
.kind-tag {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: rgba(124, 58, 237, 0.1);
color: #6d28d9;
}
.msg-stream {
flex: 1;
list-style: none;
margin: 0;
padding: 1rem 1.15rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.msg-row {
display: flex;
justify-content: flex-start;
}
.msg-row.mine {
justify-content: flex-end;
}
.bubble {
max-width: min(420px, 85%);
padding: 0.65rem 0.85rem;
border-radius: 18px 18px 18px 4px;
background: #f1f5f9;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.msg-row.mine .bubble {
border-radius: 18px 18px 4px 18px;
background: linear-gradient(135deg, rgba(13, 148, 136, 0.15), rgba(99, 102, 241, 0.18));
}
.bubble p {
margin: 0 0 0.25rem;
white-space: pre-wrap;
word-break: break-word;
}
.bubble time {
font-size: 0.68rem;
color: var(--muted);
}
.composer {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--line);
}
.composer input {
flex: 1;
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.65rem 0.85rem;
font: inherit;
}
.composer button {
border: none;
border-radius: 14px;
width: 2.75rem;
background: #0d9488;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.empty-stage {
flex: 1;
display: grid;
place-content: center;
text-align: center;
gap: 0.35rem;
color: var(--muted);
position: relative;
}
.empty-glow {
position: absolute;
width: 200px;
height: 200px;
border-radius: 50%;
background: radial-gradient(circle, rgba(167, 139, 250, 0.25), transparent 70%);
filter: blur(8px);
}
.modal {
position: fixed;
inset: 0;
z-index: 40;
border: none;
background: rgba(15, 23, 42, 0.45);
display: grid;
place-items: center;
padding: 1rem;
}
.modal-card {
background: var(--surface);
border-radius: 18px;
padding: 1.25rem;
width: min(400px, 100%);
display: grid;
gap: 0.75rem;
}
.modal-card.wide {
width: min(480px, 100%);
}
.modal-card select,
.modal-card input {
width: 100%;
padding: 0.55rem 0.65rem;
border-radius: 10px;
border: 1px solid var(--line);
font: inherit;
}
.member-grid {
display: grid;
gap: 0.35rem;
max-height: 200px;
overflow: auto;
}
.member-check {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-actions .primary {
background: #6366f1;
color: #fff;
border: none;
padding: 0.45rem 0.85rem;
border-radius: 10px;
cursor: pointer;
}
.muted {
color: var(--muted);
}
.center {
text-align: center;
}
.pad {
padding: 2rem;
}
.hint {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
</style>
+16 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import type { PlatformStatsDto } from '@mento/shared';
import type { PlatformConversationStatsDto, PlatformStatsDto } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { toFaDigits } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth';
@@ -11,13 +11,18 @@ const platform = usePlatformAuthStore();
const loading = ref(true);
const error = ref('');
const stats = ref<PlatformStatsDto | null>(null);
const chatStats = ref<PlatformConversationStatsDto | null>(null);
async function load() {
error.value = '';
loading.value = true;
try {
const { data } = await platformApi.get<PlatformStatsDto>('/platform/stats');
stats.value = data;
const [statsRes, chatRes] = await Promise.all([
platformApi.get<PlatformStatsDto>('/platform/stats'),
platformApi.get<PlatformConversationStatsDto>('/platform/conversations/stats'),
]);
stats.value = statsRes.data;
chatStats.value = chatRes.data;
} catch {
error.value = 'بارگذاری آمار ناموفق بود';
} finally {
@@ -85,6 +90,14 @@ onMounted(() => {
<small>نیاز به تمدید</small>
</div>
</article>
<article v-if="chatStats" class="kpi">
<span class="ico"><UiIcon name="chat" :size="18" /></span>
<div>
<p>گفتگو (پیکربندی‌شده)</p>
<strong>{{ toFaDigits(chatStats.organizationsWithChat) }}</strong>
<small>{{ toFaDigits(chatStats.totalRoomsTracked) }} room ثبت‌شده</small>
</div>
</article>
</div>
<section class="actions">