fix some bugs from chart
This commit is contained in:
@@ -21,6 +21,18 @@ export const getOnlineCustomers = async () =>
|
||||
rows: Customer[]
|
||||
}>('/admin/customer/online')
|
||||
|
||||
/** حضور مشتریان دارای روم چت — برای نقطهٔ آنلاین لیست/هدر پشتیبانی */
|
||||
export const getCustomerChatPresence = async () =>
|
||||
await useAxios.get<{
|
||||
online_within_seconds: number
|
||||
rows: Array<{
|
||||
id: number
|
||||
chat_room_id?: string | null
|
||||
chat_user_id?: string | null
|
||||
last_seen_at?: string | null
|
||||
}>
|
||||
}>('/admin/customer/chat-presence')
|
||||
|
||||
export const getCustomer = async (id: number) =>
|
||||
await useAxios.get<Customer>(`/admin/customer/${id}`)
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
// import BasicInfoModal from '@/components/BasicInfoModal'
|
||||
import { inputLenRules, inputRules } from '@/utils/inputRules'
|
||||
import { useStaticApiListsStore } from '@/store/staticApisStore'
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { ref, watch, computed, onMounted, nextTick } from 'vue'
|
||||
import tbl from './components/table.vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import BasicInfoModal from '@/components/BasicInfoModal/index.vue'
|
||||
@@ -498,21 +498,76 @@ function infoOrder(val: Order) {
|
||||
userInfoOrderVisible.value = true
|
||||
}
|
||||
|
||||
/** جلوگیری از race وقتی چند بار پشتسرهم مدال باز میشود */
|
||||
let infoLoadSeq = 0
|
||||
|
||||
async function refetchCustomerProfile(customerId?: number | null) {
|
||||
const id = customerId ?? userInfo.value?.id
|
||||
if (id == null) return
|
||||
const seq = ++infoLoadSeq
|
||||
try {
|
||||
const fresh = await getCustomer(id)
|
||||
if (seq !== infoLoadSeq || !fresh) return
|
||||
userInfo.value = cloneDeep(fresh)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
if (seq === infoLoadSeq) {
|
||||
message.warning('بهروزرسانی مشخصات مشتری ناموفق بود؛ دادههای جدول نمایش داده شد')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** رفرش دادهٔ همان تب فعال داخل مدال مشخصات */
|
||||
async function refreshInfoTab(key: string) {
|
||||
if (!userInfoVisible.value || !userInfo.value?.id) return
|
||||
|
||||
switch (key) {
|
||||
case 'اطلاعات جامع':
|
||||
void loadInfoChargeTargets()
|
||||
await Promise.allSettled([refetchCustomerProfile(), refreshWalletPanels()])
|
||||
break
|
||||
case 'لیست سفارشات':
|
||||
await nextTick()
|
||||
refreshOrderTable()
|
||||
break
|
||||
case 'لیست تراکنش ها':
|
||||
await nextTick()
|
||||
refreshTransactionTable()
|
||||
break
|
||||
case 'پیامک ها':
|
||||
await nextTick()
|
||||
refreshSMSTable()
|
||||
break
|
||||
case 'معرفی کرده ها':
|
||||
await nextTick()
|
||||
refreshReferralTable()
|
||||
break
|
||||
case 'قلک ها':
|
||||
await nextTick()
|
||||
refreshFundTable()
|
||||
break
|
||||
case 'لاگ پرگار':
|
||||
await nextTick()
|
||||
refreshPargarLogsTable()
|
||||
break
|
||||
case 'سفارش شرطی':
|
||||
await refetchCustomerProfile()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function info(val: Customer) {
|
||||
activeKey.value = 'اطلاعات جامع'
|
||||
// فوری از ردیف جدول نشان بده؛ بعد تکی از API تازه میگیریم
|
||||
userInfo.value = cloneDeep(val)
|
||||
userInfoVisible.value = true
|
||||
void loadInfoChargeTargets()
|
||||
await nextTick(() => {
|
||||
loading.value = true
|
||||
refreshFundTable()
|
||||
refreshPargarLogsTable()
|
||||
refreshReferralTable()
|
||||
refreshSMSTable()
|
||||
refreshTransactionTable()
|
||||
refreshOrderTable()
|
||||
loading.value = false
|
||||
})
|
||||
// اگر تب از قبل «اطلاعات جامع» بود watch شلیک نمیکند — دستی رفرش کن
|
||||
const alreadyOnOverview = activeKey.value === 'اطلاعات جامع'
|
||||
activeKey.value = 'اطلاعات جامع'
|
||||
if (alreadyOnOverview) {
|
||||
await refreshInfoTab('اطلاعات جامع')
|
||||
}
|
||||
}
|
||||
|
||||
//edit
|
||||
@@ -598,6 +653,12 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** با هر عوض شدن تب داخل مدال، دادهٔ همان تب دوباره از API گرفته شود */
|
||||
watch(activeKey, (key) => {
|
||||
if (!userInfoVisible.value) return
|
||||
void refreshInfoTab(key)
|
||||
})
|
||||
|
||||
//add project modal
|
||||
const addUserVisible = ref<boolean>(false)
|
||||
const userInfoVisible = ref<boolean>(false)
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
</button>
|
||||
|
||||
<div class="admin-support-header__identity">
|
||||
<div class="admin-support-header__avatar" aria-hidden="true">
|
||||
<div
|
||||
class="admin-support-header__avatar"
|
||||
:class="{ 'admin-support-header__avatar--online': online }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
v-if="avatar"
|
||||
:src="avatar"
|
||||
@@ -21,13 +25,18 @@
|
||||
<span v-else class="admin-support-header__avatar-text">
|
||||
{{ titleInitial }}
|
||||
</span>
|
||||
<span v-if="online" class="admin-support-header__online" />
|
||||
</div>
|
||||
|
||||
<div class="admin-support-header__text min-w-0 flex-1">
|
||||
<h1 class="admin-support-header__title">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="subtitle" class="admin-support-header__subtitle">
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="admin-support-header__subtitle"
|
||||
:class="{ 'admin-support-header__subtitle--online': online }"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -43,6 +52,7 @@ const props = defineProps<{
|
||||
title: string
|
||||
subtitle: string
|
||||
avatar: string
|
||||
online?: boolean
|
||||
mobileListOpen: boolean
|
||||
}>()
|
||||
|
||||
@@ -97,13 +107,14 @@ const titleInitial = computed(() => {
|
||||
}
|
||||
|
||||
.admin-support-header__avatar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--admin-border-strong, #e4e4e7);
|
||||
background: color-mix(in srgb, var(--admin-gold-light, #2563eb) 14%, var(--admin-bg-muted, #eff6ff));
|
||||
@@ -116,12 +127,26 @@ const titleInitial = computed(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.admin-support-header__avatar-text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-support-header__online {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
border: 2px solid var(--admin-bg-elevated, #fff);
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.45);
|
||||
animation: admin-support-online-pulse 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
.admin-support-header__title {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
@@ -144,6 +169,23 @@ const titleInitial = computed(() => {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-support-header__subtitle--online {
|
||||
color: #16a34a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@keyframes admin-support-online-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.45);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.admin-support-header {
|
||||
padding: 14px 20px;
|
||||
@@ -169,4 +211,12 @@ html.is-dark .admin-support-header__avatar {
|
||||
border-color: var(--admin-border-strong);
|
||||
color: var(--admin-gold-light);
|
||||
}
|
||||
|
||||
html.is-dark .admin-support-header__online {
|
||||
border-color: var(--admin-bg-elevated, #18181b);
|
||||
}
|
||||
|
||||
html.is-dark .admin-support-header__subtitle--online {
|
||||
color: #4ade80;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -466,7 +466,7 @@ const getAvatarText = (name: string) => {
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
background: color-mix(in srgb, var(--admin-gold-light, #2563eb) 16%, var(--admin-bg-elevated, #eef2ff));
|
||||
color: var(--admin-gold-light, #2563eb);
|
||||
display: inline-flex;
|
||||
@@ -479,6 +479,7 @@ const getAvatarText = (name: string) => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.admin-room-card__avatar-text {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
:title="activeConversation.userName"
|
||||
:subtitle="activeConversation.online ? 'آنلاین' : activeConversation.lastSeenText"
|
||||
:avatar="activeConversation.avatar"
|
||||
:online="activeConversation.online"
|
||||
:mobile-list-open="mobileListOpen"
|
||||
@back-to-list="handleBackToList"
|
||||
@toggle-mobile-list="mobileListOpen = !mobileListOpen"
|
||||
@@ -101,6 +102,53 @@ import {
|
||||
setAdminActiveRoomAtBottom
|
||||
} from '@/utils/adminChat'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { getCustomerChatPresence } from '@/api/customer'
|
||||
import {
|
||||
formatCustomerPresenceLabel,
|
||||
isCustomerOnline
|
||||
} from '@/utils/customerPresence'
|
||||
|
||||
const PRESENCE_POLL_MS = 30_000
|
||||
|
||||
/** last_seen_at keyed by room_id و chat_user_id */
|
||||
const presenceByRoomId = ref<Record<string, string | null>>({})
|
||||
const presenceByUserId = ref<Record<string, string | null>>({})
|
||||
let presencePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadChatPresence() {
|
||||
try {
|
||||
const res = await getCustomerChatPresence()
|
||||
const byRoom: Record<string, string | null> = {}
|
||||
const byUser: Record<string, string | null> = {}
|
||||
|
||||
for (const row of res?.rows ?? []) {
|
||||
const seen = row.last_seen_at ?? null
|
||||
if (row.chat_room_id) byRoom[String(row.chat_room_id)] = seen
|
||||
if (row.chat_user_id) byUser[String(row.chat_user_id)] = seen
|
||||
byUser[`user_${row.id}`] = seen
|
||||
}
|
||||
|
||||
presenceByRoomId.value = byRoom
|
||||
presenceByUserId.value = byUser
|
||||
} catch (e) {
|
||||
console.error('chat presence load failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function startPresencePolling() {
|
||||
void loadChatPresence()
|
||||
if (presencePollTimer) clearInterval(presencePollTimer)
|
||||
presencePollTimer = setInterval(() => {
|
||||
void loadChatPresence()
|
||||
}, PRESENCE_POLL_MS)
|
||||
}
|
||||
|
||||
function stopPresencePolling() {
|
||||
if (presencePollTimer) {
|
||||
clearInterval(presencePollTimer)
|
||||
presencePollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -412,6 +460,32 @@ const getConversationName = (room: any) => {
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRoomLastSeen(room: any): string | null | undefined {
|
||||
const roomId = getRoomId(room)
|
||||
if (roomId && Object.prototype.hasOwnProperty.call(presenceByRoomId.value, roomId)) {
|
||||
return presenceByRoomId.value[roomId]
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
room?.user_id,
|
||||
room?.user?.id,
|
||||
room?.chat_user_id,
|
||||
room?.customer?.chat_user_id,
|
||||
room?.peer_id,
|
||||
room?.peer?.id
|
||||
]
|
||||
|
||||
for (const raw of candidates) {
|
||||
if (raw == null || raw === '' || raw === 'admin') continue
|
||||
const key = String(raw)
|
||||
if (Object.prototype.hasOwnProperty.call(presenceByUserId.value, key)) {
|
||||
return presenceByUserId.value[key]
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const mapRawAttachment = (item: any): SupportAttachment | null => {
|
||||
if (!item) return null
|
||||
|
||||
@@ -574,9 +648,21 @@ const conversations = computed<SupportConversation[]>(() => {
|
||||
id: getRoomId(room),
|
||||
userName: getConversationName(room),
|
||||
avatar: room?.avatar || room?.user?.avatar || '',
|
||||
online: Boolean(room?.online ?? room?.user?.online ?? false),
|
||||
online: (() => {
|
||||
const lastSeen = resolveRoomLastSeen(room)
|
||||
if (lastSeen === undefined) {
|
||||
return Boolean(room?.online ?? room?.user?.online ?? false)
|
||||
}
|
||||
return isCustomerOnline(lastSeen)
|
||||
})(),
|
||||
unreadCount: Number(room?.unread_count ?? room?.unreadCount ?? 0),
|
||||
lastSeenText: room?.last_seen_text || room?.lastSeenText || 'نامشخص',
|
||||
lastSeenText: (() => {
|
||||
const lastSeen = resolveRoomLastSeen(room)
|
||||
if (lastSeen === undefined) {
|
||||
return room?.last_seen_text || room?.lastSeenText || 'آفلاین'
|
||||
}
|
||||
return formatCustomerPresenceLabel(lastSeen)
|
||||
})(),
|
||||
lastMessagePreview,
|
||||
updatedAtText: formatTime(
|
||||
room?.last_message?.created_at || room?.updated_at || room?.created_at || new Date()
|
||||
@@ -788,6 +874,7 @@ const sendAdminReply = async (payload: ComposerSendPayload) => {
|
||||
|
||||
onMounted(() => {
|
||||
setAdminSupportPageOpen(true)
|
||||
startPresencePolling()
|
||||
if (isMobileViewport()) {
|
||||
mobileListOpen.value = !activeConversationId.value
|
||||
}
|
||||
@@ -795,10 +882,12 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
setAdminSupportPageOpen(false)
|
||||
stopPresencePolling()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
setAdminSupportPageOpen(true)
|
||||
startPresencePolling()
|
||||
if (isMobileViewport() && !activeConversationId.value) {
|
||||
mobileListOpen.value = true
|
||||
}
|
||||
@@ -806,6 +895,7 @@ onActivated(() => {
|
||||
|
||||
onDeactivated(() => {
|
||||
setAdminSupportPageOpen(false)
|
||||
stopPresencePolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { toJalali } from '@core/utils/useDataTime'
|
||||
|
||||
/** همتراز با CustomerPresenceService::ONLINE_THRESHOLD_SECONDS روی سرور */
|
||||
export const CUSTOMER_ONLINE_THRESHOLD_MS = 2 * 60 * 1000
|
||||
|
||||
@@ -16,3 +18,13 @@ export function getCustomerPresenceKind(
|
||||
export function isCustomerOnline(lastSeenAt: string | Date | null | undefined): boolean {
|
||||
return getCustomerPresenceKind(lastSeenAt) === 'online'
|
||||
}
|
||||
|
||||
/** متن وضعیت برای چت (بهجای «نامشخص») */
|
||||
export function formatCustomerPresenceLabel(
|
||||
lastSeenAt: string | Date | null | undefined
|
||||
): string {
|
||||
const kind = getCustomerPresenceKind(lastSeenAt)
|
||||
if (kind === 'online') return 'آنلاین'
|
||||
if (kind === 'never') return 'آفلاین'
|
||||
return lastSeenAt ? `آخرین بازدید ${toJalali(lastSeenAt as string)}` : 'آفلاین'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user