Add internal org chat (phase 1) with DM, groups, and org settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user