fix some bugs from chart

This commit is contained in:
2026-08-01 15:54:13 +03:30
parent 31836f3f8e
commit 1aa8f6b867
6 changed files with 245 additions and 19 deletions
+12
View File
@@ -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}`)
+74 -13
View File
@@ -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 {
+92 -2
View File
@@ -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>
+12
View File
@@ -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)}` : 'آفلاین'
}