added chat 2
This commit is contained in:
@@ -30,7 +30,7 @@
|
||||
<a-button
|
||||
type="text"
|
||||
danger
|
||||
:disabled="disabled || rows.length <= 1"
|
||||
:disabled="disabled"
|
||||
aria-label="حذف"
|
||||
@click="removeRow(index)"
|
||||
>
|
||||
@@ -49,7 +49,7 @@
|
||||
</a-button>
|
||||
|
||||
<p v-if="rows.length === 0" class="support-messengers-field__hint">
|
||||
حداقل یک پیامرسان با شناسه معتبر اضافه کنید.
|
||||
لیست خالی است — در اپ کاربر اگر چت فعال باشد، پشتیبانی مستقیم به صفحه چت میرود.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -111,7 +111,7 @@ function addRow() {
|
||||
|
||||
function removeRow(index: number) {
|
||||
const next = rows.value.filter((_, i) => i !== index)
|
||||
commit(next.length > 0 ? next : [{ messenger: 'bale', id: '' }])
|
||||
commit(next)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ import SettingValueField from './SettingValueField.vue'
|
||||
import SettingCard from './SettingCard.vue'
|
||||
import { refreshAppModeMenu } from '@/router'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { applyBannerEnabledFromRows, applyTrackingSystemsEnabledFromRows, applyConditionalOrderEnabledFromRows, applyPriceTriggerEnabledFromRows, applyAppNotificationEnabledFromRows, applyPriceProviderFromRows, setBannerEnabledFromSettings, setConditionalOrderEnabledFromSettings, setPriceTriggerEnabledFromSettings, setAppNotificationEnabledFromSettings, setPriceProviderFromSettings, setStoreModeFromSettings, setTrackingSystemsEnabledFromSettings } from '@/utils/config'
|
||||
import { applyBannerEnabledFromRows, applyTrackingSystemsEnabledFromRows, applyConditionalOrderEnabledFromRows, applyPriceTriggerEnabledFromRows, applyAppNotificationEnabledFromRows, applyPriceProviderFromRows, applySupportChatMenuEnabledFromRows, setBannerEnabledFromSettings, setConditionalOrderEnabledFromSettings, setPriceTriggerEnabledFromSettings, setAppNotificationEnabledFromSettings, setPriceProviderFromSettings, setStoreModeFromSettings, setTrackingSystemsEnabledFromSettings } from '@/utils/config'
|
||||
import {
|
||||
EDITABLE_SETTING_GROUPS,
|
||||
SYSTEM_SETTING_GROUPS,
|
||||
@@ -426,12 +426,14 @@ async function initConfig() {
|
||||
applyPriceTriggerEnabledFromRows(baseData.value)
|
||||
applyAppNotificationEnabledFromRows(baseData.value)
|
||||
applyPriceProviderFromRows(baseData.value)
|
||||
applySupportChatMenuEnabledFromRows(baseData.value)
|
||||
// در صفحهٔ عادی price_provider (can_edit=0) نیست — از کل تنظیمات بخوان
|
||||
if (!baseData.value.some((row) => row.key === 'price_provider')) {
|
||||
try {
|
||||
const { getConfiguration } = await import('@/api/configuration')
|
||||
const all = await getConfiguration()
|
||||
applyPriceProviderFromRows(Array.isArray(all) ? all : [])
|
||||
applySupportChatMenuEnabledFromRows(Array.isArray(all) ? all : [])
|
||||
} catch {
|
||||
/* کش قبلی میماند */
|
||||
}
|
||||
@@ -483,6 +485,16 @@ function syncMenuFlagsFromSetting(key: string | undefined, value: string) {
|
||||
}
|
||||
if (key === 'has_page_notification') {
|
||||
setAppNotificationEnabledFromSettings(isSwitchOn(value))
|
||||
return
|
||||
}
|
||||
if (key === 'show_to_users_chat' || key === 'has_chat' || key === 'chat_api') {
|
||||
applySupportChatMenuEnabledFromRows([{ key, value }])
|
||||
// همگام با settings داخل userInfo تا گیت روت/هدر بعد از رفرش هم درست باشد
|
||||
const list = userStore.userInfo?.settings
|
||||
if (Array.isArray(list)) {
|
||||
const row = list.find((s: any) => s?.key === key)
|
||||
if (row) row.value = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export const EDITABLE_SETTING_GROUPS: SettingGroupDef[] = [
|
||||
title: 'دسترسی مشتری به قابلیتها',
|
||||
icon: 'vuesax-linear:people',
|
||||
match: (k) =>
|
||||
/^(banner_enabled|tracking_systems_enabled|jibit_verification|register_jibit_verify|show_product_details|show_product_trade_status|show_product_card_meta|products_site_link_enabled|products_site_link_url|products_site_link_text)$/.test(k)
|
||||
/^(banner_enabled|tracking_systems_enabled|jibit_verification|register_jibit_verify|show_product_details|show_product_trade_status|show_product_card_meta|products_site_link_enabled|products_site_link_url|products_site_link_text|has_chat)$/.test(k)
|
||||
},
|
||||
{
|
||||
id: 'trading_hours',
|
||||
@@ -134,10 +134,17 @@ export const SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [
|
||||
title: 'دسترسی مشتری به قابلیتها',
|
||||
icon: 'vuesax-linear:people',
|
||||
match: (k) =>
|
||||
/^(enable_conditional_order|has_page_price_trigger|has_page_notification|banner_enabled|jibit_enabled|customer_direct_sms|has_page_earn_money|group_sms|tracking_systems_enabled|permision__enabled)$/.test(
|
||||
/^(enable_conditional_order|has_page_price_trigger|has_page_notification|banner_enabled|jibit_enabled|customer_direct_sms|has_page_earn_money|group_sms|tracking_systems_enabled|permision__enabled|show_to_users_chat)$/.test(
|
||||
k
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'support_chat',
|
||||
title: 'چت پشتیبانی',
|
||||
icon: 'vuesax-linear:messages-2',
|
||||
match: (k) =>
|
||||
/^(chat_api|chat_api_key|chat_upload_url|chat_download_url)$/.test(k)
|
||||
},
|
||||
{
|
||||
id: 'general',
|
||||
title: 'عمومی و برند',
|
||||
|
||||
@@ -76,29 +76,27 @@ export function parseSupportMessengers(value: string | undefined | null): Suppor
|
||||
)
|
||||
}
|
||||
|
||||
/** پارس برای UI — ردیفهای ناقص (بدون id) هم نگه داشته میشوند */
|
||||
/** پارس برای UI — لیست میتواند خالی باشد (چت مستقیم) */
|
||||
export function parseSupportMessengersDraft(
|
||||
value: string | undefined | null,
|
||||
): SupportMessengerEntryDraft[] {
|
||||
const raw = (value ?? '').trim()
|
||||
if (!raw) return [{ messenger: 'bale', id: '' }]
|
||||
if (!raw) return []
|
||||
|
||||
try {
|
||||
const decoded = JSON.parse(raw)
|
||||
if (!Array.isArray(decoded) || decoded.length === 0) {
|
||||
return [{ messenger: 'bale', id: '' }]
|
||||
return []
|
||||
}
|
||||
|
||||
const rows = decoded
|
||||
return decoded
|
||||
.map((row) => ({
|
||||
messenger: String(row?.messenger ?? '').trim().toLowerCase(),
|
||||
id: String(row?.id ?? '').trim(),
|
||||
}))
|
||||
.filter((row) => isSupportMessengerType(row.messenger))
|
||||
|
||||
return rows.length > 0 ? rows : [{ messenger: 'bale', id: '' }]
|
||||
} catch {
|
||||
return [{ messenger: 'bale', id: '' }]
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +326,12 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
|
||||
pargar_item_id_rial: 'number',
|
||||
pargar_item_id_gold: 'number',
|
||||
support_messengers: 'longtext',
|
||||
show_to_users_chat: 'switch',
|
||||
has_chat: 'switch',
|
||||
chat_api: 'text',
|
||||
chat_api_key: 'text',
|
||||
chat_upload_url: 'text',
|
||||
chat_download_url: 'text',
|
||||
tracking_systems: 'longtext',
|
||||
tracking_systems_enabled: 'switch',
|
||||
support_messenger_id: 'text',
|
||||
@@ -391,6 +395,13 @@ const SELECT_OPTIONS_BY_KEY: Record<string, string> = {
|
||||
/** سوییچهایی که مقدار required/خالی دارند (نه true/false) */
|
||||
export const REFERRAL_REQUIRED_KEY = 'referral_id'
|
||||
|
||||
/** سوییچهایی که مقدار activate/deactivate دارند (نه true/false) */
|
||||
export const ACTIVATE_DEACTIVATE_SWITCH_KEYS = new Set(['show_to_users_chat'])
|
||||
|
||||
export function isActivateDeactivateSwitch(key: string | undefined): boolean {
|
||||
return Boolean(key && ACTIVATE_DEACTIVATE_SWITCH_KEYS.has(key))
|
||||
}
|
||||
|
||||
export function isReferralRequired(value: string | undefined): boolean {
|
||||
return (value ?? '').trim().toLowerCase() === 'required'
|
||||
}
|
||||
@@ -436,7 +447,7 @@ export function parseSelectOptions(
|
||||
|
||||
export function isSwitchOn(value: string | undefined): boolean {
|
||||
const v = (value ?? '').trim().toLowerCase()
|
||||
return v === 'true' || v === '1' || v === 'yes'
|
||||
return v === 'true' || v === '1' || v === 'yes' || v === 'activate'
|
||||
}
|
||||
|
||||
export function switchToString(checked: boolean): string {
|
||||
@@ -447,6 +458,10 @@ export function resolveSwitchChecked(key: string | undefined, value: string | un
|
||||
if (key === REFERRAL_REQUIRED_KEY) {
|
||||
return isReferralRequired(value)
|
||||
}
|
||||
if (isActivateDeactivateSwitch(key)) {
|
||||
const v = (value ?? '').trim().toLowerCase()
|
||||
return v === 'activate' || v === 'true' || v === '1' || v === 'yes'
|
||||
}
|
||||
return isSwitchOn(value)
|
||||
}
|
||||
|
||||
@@ -454,6 +469,9 @@ export function switchValueFromChecked(key: string | undefined, checked: boolean
|
||||
if (key === REFERRAL_REQUIRED_KEY) {
|
||||
return referralRequiredToString(checked)
|
||||
}
|
||||
if (isActivateDeactivateSwitch(key)) {
|
||||
return checked ? 'activate' : 'deactivate'
|
||||
}
|
||||
return switchToString(checked)
|
||||
}
|
||||
|
||||
@@ -463,7 +481,10 @@ export function formatValueForSave(
|
||||
key?: string
|
||||
): string {
|
||||
if (type === 'switch') {
|
||||
return switchValueFromChecked(key, isSwitchOn(value) || (key === REFERRAL_REQUIRED_KEY && isReferralRequired(value)))
|
||||
return switchValueFromChecked(
|
||||
key,
|
||||
resolveSwitchChecked(key, value)
|
||||
)
|
||||
}
|
||||
if (type === 'price') {
|
||||
return value === '' ? '0' : String(value).replace(/,/g, '')
|
||||
@@ -666,7 +687,19 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
|
||||
node_shared_secret:
|
||||
'کلید مشترک امضای درخواستها به سرویس سفارش خارجی. محرمانه نگه دارید.',
|
||||
support_messengers:
|
||||
'فقط مواردی که در این لیست ثبت شوند در منوی شناور پشتیبانی اپ مشتری نمایش داده میشوند. برای تماس تلفنی یا پیامک، نوع «تماس تلفنی» / «پیامک» را با شماره اضافه کنید.',
|
||||
'فقط مواردی که در این لیست ثبت شوند در منوی شناور پشتیبانی اپ مشتری نمایش داده میشوند. برای تماس تلفنی یا پیامک، نوع «تماس تلفنی» / «پیامک» را با شماره اضافه کنید. لیست میتواند خالی باشد؛ اگر چت فعال باشد، پشتیبانی مستقیم به صفحه چت میرود.',
|
||||
show_to_users_chat:
|
||||
'روشن: قابلیت چت پشتیبانی برای این دیپلوی فعال است (منوی ادمین و پیشنیاز نمایش به کاربر). خاموش: چت در ادمین و اپ کاربر مخفی است. سوییچ مشتری (has_chat) جداگانه در تنظیمات عادی است.',
|
||||
has_chat:
|
||||
'روشن: چت پشتیبانی در اپ مشتری نمایش داده میشود (اگر نمایش چت به کاربران هم روشن باشد). خاموش: دکمه و صفحه چت برای کاربر مخفی است.',
|
||||
chat_api:
|
||||
'هاست سرور چت جدا (بدون https)، مثلاً chat.example.ir — برای WebSocket و ساخت روم.',
|
||||
chat_api_key:
|
||||
'کلید API سرور چت جدا.',
|
||||
chat_upload_url:
|
||||
'URL کامل آپلود مدیا برای چت پشتیبانی.',
|
||||
chat_download_url:
|
||||
'پایهٔ URL دانلود/نمایش فایلهای چت پشتیبانی.',
|
||||
tracking_systems:
|
||||
'لیست سایتهای ریگیری (name/url) — از منوی «سایتهای ریگیری» ویرایش کنید.',
|
||||
tracking_systems_enabled:
|
||||
@@ -906,6 +939,12 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
|
||||
zarin_name_g: 'نام گروه حساب مشتریان زرین',
|
||||
zarin_uid_prefix: 'پیشوند عددی uid زرین',
|
||||
support_messengers: 'پیامرسانهای پشتیبانی',
|
||||
show_to_users_chat: 'نمایش چت به کاربران',
|
||||
has_chat: 'چت پشتیبانی',
|
||||
chat_api: 'API چت',
|
||||
chat_api_key: 'کلید API چت',
|
||||
chat_upload_url: 'آدرس آپلود فایل چت',
|
||||
chat_download_url: 'آدرس دانلود فایل چت',
|
||||
tracking_systems: 'سایتهای ریگیری',
|
||||
tracking_systems_enabled: 'نمایش سایتهای ریگیری',
|
||||
permision__enabled: 'نمایش منوی مجوزها',
|
||||
@@ -974,7 +1013,8 @@ export const SETTING_LOCKED_KEYS = new Set([
|
||||
'unit',
|
||||
'up_price', 'down_price',
|
||||
'wss_enabled',
|
||||
'zaarbox_api_url'
|
||||
'zaarbox_api_url',
|
||||
'show_to_users_chat', 'chat_api', 'chat_api_key', 'chat_upload_url', 'chat_download_url'
|
||||
])
|
||||
|
||||
/** قابل ویرایش در پنل — همتراز deploy_settings_const.sql */
|
||||
@@ -1008,6 +1048,7 @@ export const SETTING_EDITABLE_KEYS = new Set([
|
||||
'products_site_link_enabled', 'products_site_link_url', 'products_site_link_text',
|
||||
'startDay',
|
||||
'support_messengers', 'support_messenger_text',
|
||||
'has_chat',
|
||||
'deposit_support_message_text', 'deposit_account_holder', 'deposit_card_number', 'deposit_sheba',
|
||||
'text_about', 'text_rule',
|
||||
'zaarbox_price_key',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<header class="admin-support-header">
|
||||
<div class="admin-support-header__row">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-support-header__back md:hidden"
|
||||
aria-label="بازگشت به لیست گفتگوها"
|
||||
@click="$emit('back-to-list')"
|
||||
>
|
||||
<BaseIcon icon="vuesax-linear:arrow-right" size="20" />
|
||||
</button>
|
||||
|
||||
<div class="admin-support-header__identity">
|
||||
<div class="admin-support-header__avatar" aria-hidden="true">
|
||||
<img
|
||||
v-if="avatar"
|
||||
:src="avatar"
|
||||
:alt="title"
|
||||
class="admin-support-header__avatar-img"
|
||||
/>
|
||||
<span v-else class="admin-support-header__avatar-text">
|
||||
{{ titleInitial }}
|
||||
</span>
|
||||
</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">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
subtitle: string
|
||||
avatar: string
|
||||
mobileListOpen: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'back-to-list'): void
|
||||
(e: 'toggle-mobile-list'): void
|
||||
}>()
|
||||
|
||||
const titleInitial = computed(() => {
|
||||
const value = String(props.title || '').trim()
|
||||
return value ? value.charAt(0) : '؟'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.admin-support-header {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--admin-border, #e4e4e7);
|
||||
background: color-mix(in srgb, var(--admin-bg-elevated, #fff) 92%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.admin-support-header__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-support-header__back {
|
||||
display: inline-flex;
|
||||
flex: 0 0 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--admin-border-strong, #e4e4e7);
|
||||
background: var(--admin-bg-muted, #fff);
|
||||
color: var(--admin-text-muted, #3f3f46);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-support-header__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-support-header__avatar {
|
||||
display: flex;
|
||||
flex: 0 0 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
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));
|
||||
color: var(--admin-gold-light, #2563eb);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-support-header__avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.admin-support-header__avatar-text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-support-header__title {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
color: var(--admin-text, #18181b);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.admin-support-header__subtitle {
|
||||
margin: 2px 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--admin-text-secondary, #71717a);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.admin-support-header {
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.admin-support-header__back {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.admin-support-header__title {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
html.is-dark .admin-support-header__back {
|
||||
background: var(--admin-bg-muted) !important;
|
||||
border-color: var(--admin-border-strong) !important;
|
||||
color: var(--admin-text-secondary) !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-support-header__avatar {
|
||||
background: color-mix(in srgb, var(--admin-gold-light) 18%, var(--admin-bg-muted));
|
||||
border-color: var(--admin-border-strong);
|
||||
color: var(--admin-gold-light);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,785 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="mobileOpen"
|
||||
class="admin-sidebar-backdrop md:hidden"
|
||||
@click="$emit('close-mobile')"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<aside
|
||||
class="admin-sidebar"
|
||||
:class="[
|
||||
mobileOpen ? 'admin-sidebar--open' : 'admin-sidebar--closed',
|
||||
mobileOpen ? 'pointer-events-auto' : 'pointer-events-none md:pointer-events-auto'
|
||||
]"
|
||||
>
|
||||
<div class="admin-sidebar__header">
|
||||
<div class="admin-sidebar__top">
|
||||
<div class="admin-sidebar__title-wrap">
|
||||
<div class="admin-sidebar__icon">
|
||||
<BaseIcon icon="vuesax-linear:messages-2" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h2 class="admin-sidebar__title">گفتگوهای پشتیبانی</h2>
|
||||
<p class="admin-sidebar__subtitle">مدیریت کاربران و پیامها</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
v-if="activeConversationId"
|
||||
type="default"
|
||||
class="admin-sidebar__close px-0"
|
||||
danger
|
||||
@click="$emit('close-mobile')"
|
||||
>
|
||||
<div class="flex ">
|
||||
<BaseIcon icon="vuesax-linear:add" class="rotate-45" />
|
||||
بستن
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="admin-sidebar__stats">
|
||||
<div class="admin-sidebar__stat">
|
||||
<span class="admin-sidebar__stat-value">{{ conversations.length }}</span>
|
||||
<span class="admin-sidebar__stat-label">گفتگو</span>
|
||||
</div>
|
||||
|
||||
<div class="admin-sidebar__stat admin-sidebar__stat--unread">
|
||||
<span class="admin-sidebar__stat-value">{{ totalUnread }}</span>
|
||||
<span class="admin-sidebar__stat-label">خواندهنشده</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-sidebar__search">
|
||||
<BaseIcon icon="vuesax-linear:search-normal-1" class="admin-sidebar__search-icon" />
|
||||
|
||||
<BasicInput
|
||||
v-model:value="search"
|
||||
placeHolder="جستجو در کاربران یا پیامها..."
|
||||
class="admin-search-input"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="search.trim()"
|
||||
type="button"
|
||||
class="admin-sidebar__search-clear"
|
||||
@click="search = ''"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-sidebar__body">
|
||||
<button
|
||||
v-for="conversation in filteredConversations"
|
||||
:key="conversation.id"
|
||||
type="button"
|
||||
class="admin-room-card"
|
||||
:class="[
|
||||
activeConversationId === conversation.id ? 'admin-room-card--active' : '',
|
||||
conversation.unreadCount > 0 ? 'admin-room-card--unread' : ''
|
||||
]"
|
||||
@click="$emit('select', conversation.id)"
|
||||
>
|
||||
<div class="admin-room-card__avatar">
|
||||
<img
|
||||
v-if="conversation.avatar"
|
||||
:src="conversation.avatar"
|
||||
:alt="conversation.userName"
|
||||
class="admin-room-card__avatar-img"
|
||||
/>
|
||||
|
||||
<span v-else class="admin-room-card__avatar-text">
|
||||
{{ getAvatarText(conversation.userName) }}
|
||||
</span>
|
||||
|
||||
<span v-if="conversation.online" class="admin-room-card__online" />
|
||||
</div>
|
||||
|
||||
<div class="admin-room-card__content">
|
||||
<div class="admin-room-card__head">
|
||||
<h3 class="admin-room-card__name">
|
||||
{{ conversation.userName }}
|
||||
</h3>
|
||||
|
||||
<span class="admin-room-card__time">
|
||||
{{ conversation.updatedAtText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="admin-room-card__message-row">
|
||||
<p class="admin-room-card__preview">
|
||||
{{ conversation.lastMessagePreview || 'پیامی وجود ندارد' }}
|
||||
</p>
|
||||
|
||||
<span v-if="conversation.unreadCount > 0" class="admin-room-card__badge">
|
||||
{{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="!filteredConversations.length" class="admin-sidebar__empty">
|
||||
<div class="admin-sidebar__empty-icon">
|
||||
<BaseIcon icon="vuesax-linear:message-question" />
|
||||
</div>
|
||||
|
||||
<h3 class="admin-sidebar__empty-title">موردی پیدا نشد</h3>
|
||||
|
||||
<p class="admin-sidebar__empty-text">عبارت جستجو را تغییر بده یا بعداً دوباره بررسی کن.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { SupportConversation } from '@/model/support'
|
||||
import BasicInput from '@/components/BasicInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
conversations: SupportConversation[]
|
||||
activeConversationId: string
|
||||
mobileOpen: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'select', value: string): void
|
||||
(e: 'close-mobile'): void
|
||||
}>()
|
||||
|
||||
const search = ref('')
|
||||
|
||||
const normalizeText = (value: string) => {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/ي/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
const filteredConversations = computed(() => {
|
||||
const value = normalizeText(search.value)
|
||||
if (!value) return props.conversations
|
||||
|
||||
return props.conversations.filter((item) => {
|
||||
return (
|
||||
normalizeText(item.userName).includes(value) ||
|
||||
normalizeText(item.lastMessagePreview).includes(value)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const totalUnread = computed(() => {
|
||||
return props.conversations.reduce((sum, item) => {
|
||||
return sum + Number(item.unreadCount || 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const getAvatarText = (name: string) => {
|
||||
const value = String(name || 'ک').trim()
|
||||
return value.slice(0, 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.admin-sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
border-radius: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
background: var(--admin-bg-elevated, #fbfdff);
|
||||
border-left: none;
|
||||
box-shadow: none;
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.admin-sidebar--open {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.admin-sidebar--closed {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.admin-sidebar__header {
|
||||
flex: 0 0 auto;
|
||||
padding: 18px 16px 14px;
|
||||
border-bottom: 1px solid var(--admin-border, rgba(226, 232, 240, 0.9));
|
||||
background: color-mix(in srgb, var(--admin-bg-elevated, #fff) 88%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.admin-sidebar__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-sidebar__title-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.admin-sidebar__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
color: var(--admin-gold-light, #2563eb);
|
||||
background: color-mix(in srgb, var(--admin-gold-light, #2563eb) 16%, var(--admin-bg-muted, #eff6ff));
|
||||
border: 1px solid var(--admin-border-strong, rgba(147, 197, 253, 0.55));
|
||||
box-shadow: none;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.admin-sidebar__title {
|
||||
margin: 0;
|
||||
color: var(--admin-text, #111827);
|
||||
font-size: 17px;
|
||||
font-weight: 950;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.admin-sidebar__subtitle {
|
||||
margin: 2px 0 0;
|
||||
color: var(--admin-text-secondary, #64748b);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-sidebar__close {
|
||||
:deep(.ant-btn) {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
min-width: 40px !important;
|
||||
padding: 0 !important;
|
||||
border: 1px solid var(--admin-border-strong, rgba(226, 232, 240, 0.95)) !important;
|
||||
border-radius: 14px !important;
|
||||
background: var(--admin-bg-muted, rgba(255, 255, 255, 0.92)) !important;
|
||||
color: var(--admin-text-muted, #334155) !important;
|
||||
box-shadow: 0 8px 18px var(--admin-shadow, rgba(15, 23, 42, 0.06)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-sidebar__close-icon {
|
||||
transform: rotate(45deg);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.admin-sidebar__stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.admin-sidebar__stat {
|
||||
min-width: 0;
|
||||
padding: 11px 12px;
|
||||
border-radius: 16px;
|
||||
background: var(--admin-bg-muted, #ffffff);
|
||||
border: 1px solid var(--admin-border, rgba(226, 232, 240, 0.9));
|
||||
box-shadow: 0 8px 18px var(--admin-shadow, rgba(15, 23, 42, 0.04));
|
||||
}
|
||||
|
||||
.admin-sidebar__stat--unread {
|
||||
background: color-mix(in srgb, #f97316 12%, var(--admin-bg-muted, #fff7ed));
|
||||
border-color: rgba(251, 146, 60, 0.26);
|
||||
}
|
||||
|
||||
.admin-sidebar__stat-value {
|
||||
display: block;
|
||||
color: var(--admin-text, #111827);
|
||||
font-size: 16px;
|
||||
font-weight: 950;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-sidebar__stat-label {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--admin-text-secondary, #64748b);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-sidebar__search {
|
||||
position: relative;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.admin-sidebar__search-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 14px;
|
||||
z-index: 2;
|
||||
transform: translateY(-50%);
|
||||
color: var(--admin-text-secondary, #94a3b8);
|
||||
font-size: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-sidebar__search-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--admin-bg-muted, #eef2ff);
|
||||
color: var(--admin-text-muted, #475569);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-search-input {
|
||||
display: block;
|
||||
|
||||
:deep(.ant-input),
|
||||
:deep(input) {
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
padding-right: 42px !important;
|
||||
padding-left: 42px !important;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid var(--admin-border-strong, rgba(203, 213, 225, 0.85)) !important;
|
||||
background: var(--admin-bg-elevated, rgba(255, 255, 255, 0.95)) !important;
|
||||
box-shadow: 0 8px 18px var(--admin-shadow, rgba(15, 23, 42, 0.04)) !important;
|
||||
color: var(--admin-text, #111827) !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
:deep(.ant-input:focus),
|
||||
:deep(input:focus) {
|
||||
border-color: var(--admin-gold-light, rgba(59, 130, 246, 0.65)) !important;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--admin-gold-light, #3b82f6) 18%, transparent) !important;
|
||||
}
|
||||
|
||||
:deep(.ant-input::placeholder),
|
||||
:deep(input::placeholder) {
|
||||
color: var(--admin-text-secondary, #94a3b8) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-sidebar__body {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
.admin-sidebar__body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.admin-sidebar__body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.admin-sidebar__body::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.admin-room-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--admin-border, rgba(226, 232, 240, 0.86));
|
||||
border-radius: 20px;
|
||||
background: var(--admin-bg-muted, rgba(255, 255, 255, 0.78));
|
||||
text-align: right;
|
||||
box-shadow: 0 8px 22px var(--admin-shadow, rgba(15, 23, 42, 0.035));
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.admin-room-card:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--admin-bg-hover, #ffffff);
|
||||
border-color: var(--admin-border-strong, rgba(147, 197, 253, 0.72));
|
||||
box-shadow: 0 12px 26px var(--admin-shadow, rgba(15, 23, 42, 0.07));
|
||||
}
|
||||
|
||||
.admin-room-card--active {
|
||||
background: color-mix(in srgb, var(--admin-gold-light, #2563eb) 10%, var(--admin-bg-muted, #eff6ff));
|
||||
border-color: var(--admin-gold-light, rgba(37, 99, 235, 0.88));
|
||||
box-shadow: inset 4px 0 0 var(--admin-gold-light, #2563eb);
|
||||
}
|
||||
|
||||
.admin-room-card--unread:not(.admin-room-card--active) {
|
||||
background: color-mix(in srgb, #f97316 10%, var(--admin-bg-muted, #fff7ed));
|
||||
border-color: rgba(251, 146, 60, 0.34);
|
||||
}
|
||||
|
||||
.admin-room-card__avatar {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-room-card__avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.admin-room-card__avatar-text {
|
||||
font-size: 16px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.admin-room-card__online {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
border: 2px solid var(--admin-bg-muted, #fff);
|
||||
}
|
||||
|
||||
.admin-room-card__content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-room-card__head {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-room-card__name {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--admin-text, #111827);
|
||||
font-size: 14px;
|
||||
font-weight: 950;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.admin-room-card__time {
|
||||
flex: 0 0 auto;
|
||||
color: var(--admin-text-secondary, #94a3b8);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-room-card--unread .admin-room-card__time {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.admin-room-card__message-row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.admin-room-card__preview {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--admin-text-secondary, #64748b);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.8;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.admin-room-card--unread .admin-room-card__preview {
|
||||
color: var(--admin-text-muted, #334155);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.admin-room-card__badge {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 950;
|
||||
box-shadow: 0 8px 18px rgba(239, 68, 68, 0.22);
|
||||
}
|
||||
|
||||
.admin-sidebar__empty {
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.admin-sidebar__empty-icon {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 22px;
|
||||
background: var(--admin-bg-muted, #ffffff);
|
||||
border: 1px solid var(--admin-border, transparent);
|
||||
color: var(--admin-text-secondary, #94a3b8);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
box-shadow: 0 10px 26px var(--admin-shadow, rgba(15, 23, 42, 0.06));
|
||||
}
|
||||
|
||||
.admin-sidebar__empty-title {
|
||||
margin: 14px 0 0;
|
||||
color: var(--admin-text, #334155);
|
||||
font-size: 15px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.admin-sidebar__empty-text {
|
||||
max-width: 220px;
|
||||
margin: 6px 0 0;
|
||||
color: var(--admin-text-secondary, #94a3b8);
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.admin-sidebar {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
width: 360px;
|
||||
max-width: 360px;
|
||||
flex: 0 0 360px;
|
||||
transform: none !important;
|
||||
opacity: 1 !important;
|
||||
border-radius: 0;
|
||||
border-left: 1px solid var(--admin-border, rgba(226, 232, 240, 0.95));
|
||||
box-shadow: none;
|
||||
z-index: auto;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.admin-sidebar__close {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.admin-sidebar__header {
|
||||
padding: 14px 14px 12px;
|
||||
}
|
||||
|
||||
.admin-sidebar__stats {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar__body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.admin-room-card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* dark mode */
|
||||
html.is-dark .admin-sidebar {
|
||||
background: var(--admin-bg-elevated);
|
||||
border-left-color: var(--admin-border);
|
||||
box-shadow: -12px 0 32px var(--admin-shadow);
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__header {
|
||||
border-bottom-color: var(--admin-border);
|
||||
background: color-mix(in srgb, var(--admin-bg-elevated) 88%, transparent);
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__title,
|
||||
html.is-dark .admin-sidebar__stat-value,
|
||||
html.is-dark .admin-room-card__name {
|
||||
color: var(--admin-text) !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__subtitle,
|
||||
html.is-dark .admin-sidebar__stat-label,
|
||||
html.is-dark .admin-room-card__preview,
|
||||
html.is-dark .admin-room-card__time,
|
||||
html.is-dark .admin-sidebar__empty-title,
|
||||
html.is-dark .admin-sidebar__empty-text {
|
||||
color: var(--admin-text-secondary) !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__icon {
|
||||
color: var(--admin-gold-light);
|
||||
background: color-mix(in srgb, var(--admin-gold-light) 18%, var(--admin-bg-muted));
|
||||
border-color: var(--admin-border-strong);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__close :deep(.ant-btn) {
|
||||
border-color: var(--admin-border-strong) !important;
|
||||
background: var(--admin-bg-muted) !important;
|
||||
color: var(--admin-text-muted) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__stat,
|
||||
html.is-dark .admin-sidebar__search,
|
||||
html.is-dark .admin-room-card {
|
||||
background: var(--admin-bg-muted) !important;
|
||||
border-color: var(--admin-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__stat--unread {
|
||||
background: color-mix(in srgb, #f97316 16%, var(--admin-bg-muted)) !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-room-card:hover {
|
||||
background: var(--admin-bg-hover) !important;
|
||||
border-color: var(--admin-border-strong) !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-room-card--active {
|
||||
background: color-mix(in srgb, var(--admin-gold-light) 12%, var(--admin-bg-muted)) !important;
|
||||
border-color: var(--admin-gold-light) !important;
|
||||
box-shadow: inset 4px 0 0 var(--admin-gold-light) !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-room-card--unread:not(.admin-room-card--active) {
|
||||
background: color-mix(in srgb, #f97316 12%, var(--admin-bg-muted)) !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-room-card__avatar {
|
||||
background: color-mix(in srgb, var(--admin-gold-light) 16%, var(--admin-bg-elevated));
|
||||
color: var(--admin-gold-light);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html.is-dark .admin-room-card__online {
|
||||
border-color: var(--admin-bg-muted);
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__search-icon {
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
html.is-dark .admin-search-input :deep(.ant-input),
|
||||
html.is-dark .admin-search-input :deep(input) {
|
||||
background: var(--admin-bg-elevated) !important;
|
||||
border-color: var(--admin-border-strong) !important;
|
||||
color: var(--admin-text) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-search-input :deep(.ant-input:focus),
|
||||
html.is-dark .admin-search-input :deep(input:focus) {
|
||||
border-color: var(--admin-gold-light) !important;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--admin-gold-light) 18%, transparent) !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-search-input :deep(.ant-input::placeholder),
|
||||
html.is-dark .admin-search-input :deep(input::placeholder) {
|
||||
color: var(--admin-text-secondary) !important;
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__search-clear {
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__body {
|
||||
scrollbar-color: var(--admin-border-strong) transparent;
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__body::-webkit-scrollbar-thumb {
|
||||
background: var(--admin-border-strong);
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__empty,
|
||||
html.is-dark .admin-sidebar__empty-icon {
|
||||
color: var(--admin-text-secondary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.is-dark .admin-sidebar__empty-icon {
|
||||
background: var(--admin-bg-muted);
|
||||
border-color: var(--admin-border);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,932 @@
|
||||
<template>
|
||||
<BasicMainCard class="support-style admin-support-card">
|
||||
<div class="admin-support-shell">
|
||||
<AdminSupportSidebar
|
||||
:conversations="conversations"
|
||||
:active-conversation-id="activeConversationId"
|
||||
:mobile-open="mobileListOpen"
|
||||
@select="handleSelectConversation"
|
||||
@close-mobile="handleCloseMobileList"
|
||||
/>
|
||||
|
||||
<div class="admin-support-content">
|
||||
<button
|
||||
v-if="!activeConversation && !mobileListOpen"
|
||||
type="button"
|
||||
class="admin-mobile-sidebar-trigger md:hidden"
|
||||
@click="mobileListOpen = true"
|
||||
>
|
||||
<BaseIcon icon="vuesax-linear:messages-2" size="20" />
|
||||
<span>لیست گفتگوها</span>
|
||||
</button>
|
||||
|
||||
<template v-if="activeConversation">
|
||||
<AdminSupportHeader
|
||||
:title="activeConversation.userName"
|
||||
:subtitle="activeConversation.online ? 'آنلاین' : activeConversation.lastSeenText"
|
||||
:avatar="activeConversation.avatar"
|
||||
:mobile-list-open="mobileListOpen"
|
||||
@back-to-list="handleBackToList"
|
||||
@toggle-mobile-list="mobileListOpen = !mobileListOpen"
|
||||
/>
|
||||
|
||||
<AdminSupportMessages
|
||||
ref="messagesRef"
|
||||
:messages="activeMessages"
|
||||
:support-typing="userTyping"
|
||||
:has-more="activeHasMore"
|
||||
:loading-older="activeLoadingOlder"
|
||||
:loading="activeRoomLoading"
|
||||
:loading-label="activeLoadingLabel"
|
||||
@load-older="loadOlderMessages"
|
||||
@reply="handleReplyMessage"
|
||||
@bottom-state-change="handleBottomStateChange"
|
||||
/>
|
||||
|
||||
<AdminSupportComposer
|
||||
v-model="draft"
|
||||
:disabled="isSending"
|
||||
:reply-to="replyingTo"
|
||||
@cancel-reply="replyingTo = null"
|
||||
@send="sendAdminReply"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="admin-empty-pane">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-4xl">💬</div>
|
||||
|
||||
<h2 class="text-xl font-black" style="color: var(--admin-text)">
|
||||
{{ roomsLoading ? 'در حال دریافت گفتگوها...' : 'گفتگو را انتخاب کن' }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-2 text-sm" style="color: var(--admin-text-secondary)">
|
||||
{{
|
||||
roomsLoading
|
||||
? 'چند لحظه صبر کن'
|
||||
: conversations.length
|
||||
? 'از لیست گفتگوها یک روم را باز کن'
|
||||
: 'فعلاً گفتگویی وجود ندارد'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</BasicMainCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
|
||||
import AdminSupportSidebar from './components/AdminSupportSidebar.vue'
|
||||
import AdminSupportHeader from './components/AdminSupportHeader.vue'
|
||||
import AdminSupportMessages from './components/AdminSupportMessages.vue'
|
||||
import AdminSupportComposer from './components/AdminSupportComposer.vue'
|
||||
import type {
|
||||
ComposerSendPayload,
|
||||
SupportAttachment,
|
||||
SupportConversation,
|
||||
SupportMessage
|
||||
} from '@/model/support'
|
||||
import BasicMainCard from '@/components/BasicMainCard.vue'
|
||||
import {
|
||||
fetchOlderAdminRoomMessages,
|
||||
sendAdminTextMessage,
|
||||
sendAdminMediaMessage,
|
||||
setAdminActiveRoom,
|
||||
setAdminSupportPageOpen,
|
||||
useAdminChatService,
|
||||
setAdminActiveRoomAtBottom
|
||||
} from '@/utils/adminChat'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const draft = ref('')
|
||||
const isSending = ref(false)
|
||||
const userTyping = ref(false)
|
||||
const mobileListOpen = ref(false)
|
||||
const replyingTo = ref<SupportMessage | null>(null)
|
||||
const uploadingMessagesByRoom = ref<Record<string, SupportMessage[]>>({})
|
||||
|
||||
const IMAGE_MAX_WIDTH = 1600
|
||||
const IMAGE_MAX_HEIGHT = 1600
|
||||
const IMAGE_WEBP_QUALITY = 0.85
|
||||
|
||||
const createId = () => `${Date.now()}-${Math.floor(Math.random() * 100000)}`
|
||||
|
||||
const getSettingValue = (key: string) => {
|
||||
const setting = userStore.findSettingByKey(key) as any
|
||||
|
||||
if (typeof setting === 'string') {
|
||||
return setting.trim()
|
||||
}
|
||||
|
||||
return String(
|
||||
setting?.value ?? setting?.data?.value ?? setting?.setting_value ?? setting?.val ?? ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
const getChatUploadUrl = () => {
|
||||
return getSettingValue('chat_upload_url')
|
||||
}
|
||||
|
||||
const getChatDownloadUrl = () => {
|
||||
return getSettingValue('chat_download_url')
|
||||
}
|
||||
|
||||
const handleBottomStateChange = (value: boolean) => {
|
||||
if (!activeConversationId.value) return
|
||||
|
||||
setAdminActiveRoomAtBottom(activeConversationId.value, value)
|
||||
}
|
||||
|
||||
const buildChatFileUrl = (value: string) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
if (/^(https?:\/\/|blob:|data:)/i.test(raw)) {
|
||||
return raw
|
||||
}
|
||||
|
||||
const downloadUrl = getChatDownloadUrl()
|
||||
|
||||
if (!downloadUrl) {
|
||||
console.error('chat_download_url is empty')
|
||||
return raw
|
||||
}
|
||||
|
||||
const cleanRaw = raw.replace(/^\/+/, '')
|
||||
|
||||
try {
|
||||
const baseUrl = new URL(downloadUrl)
|
||||
const basePath = baseUrl.pathname.replace(/^\/+|\/+$/g, '')
|
||||
const baseParts = basePath.split('/').filter(Boolean)
|
||||
const rawParts = cleanRaw.split('/').filter(Boolean)
|
||||
|
||||
if (basePath && cleanRaw.startsWith(`${basePath}/`)) {
|
||||
return `${baseUrl.origin}/${cleanRaw}`
|
||||
}
|
||||
|
||||
const lastBasePart = baseParts[baseParts.length - 1]
|
||||
|
||||
if (lastBasePart && rawParts[0] === lastBasePart) {
|
||||
const parentPath = baseParts.slice(0, -1).join('/')
|
||||
|
||||
return `${baseUrl.origin}/${[parentPath, cleanRaw].filter(Boolean).join('/')}`
|
||||
}
|
||||
|
||||
return `${downloadUrl.replace(/\/+$/, '')}/${cleanRaw}`
|
||||
} catch (error) {
|
||||
console.error('invalid chat_download_url:', downloadUrl, error)
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
const extractUploadedPath = (result: any) => {
|
||||
const value =
|
||||
result?.path ||
|
||||
result?.url ||
|
||||
result?.file_url ||
|
||||
result?.fileUrl ||
|
||||
result?.data?.path ||
|
||||
result?.data?.url ||
|
||||
result?.data?.file_url ||
|
||||
result?.data?.fileUrl ||
|
||||
result?.file?.path ||
|
||||
result?.file?.url
|
||||
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
const getCompressedImageName = (fileName: string) => {
|
||||
const cleanName = String(fileName || `image-${Date.now()}`)
|
||||
.replace(/\.[^/.]+$/, '')
|
||||
.replace(/[^\w\u0600-\u06FF.-]+/g, '-')
|
||||
|
||||
return `${cleanName}.webp`
|
||||
}
|
||||
|
||||
const canvasToBlob = (canvas: HTMLCanvasElement, type: string, quality: number) => {
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), type, quality)
|
||||
})
|
||||
}
|
||||
|
||||
const loadImageFromFile = (file: File) => {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(img)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('image load failed'))
|
||||
}
|
||||
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
const compressImageToWebp = async (file: File) => {
|
||||
if (!file.type.startsWith('image/')) return file
|
||||
|
||||
// gif متحرک با تبدیل به webp ثابت میشه، پس دست نزنیم
|
||||
if (file.type === 'image/gif') return file
|
||||
if (file.type === 'image/svg+xml') return file
|
||||
try {
|
||||
const img = await loadImageFromFile(file)
|
||||
|
||||
const ratio = Math.min(
|
||||
IMAGE_MAX_WIDTH / img.naturalWidth,
|
||||
IMAGE_MAX_HEIGHT / img.naturalHeight,
|
||||
1
|
||||
)
|
||||
|
||||
const width = Math.round(img.naturalWidth * ratio)
|
||||
const height = Math.round(img.naturalHeight * ratio)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return file
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
const blob = await canvasToBlob(canvas, 'image/webp', IMAGE_WEBP_QUALITY)
|
||||
if (!blob) return file
|
||||
|
||||
const webpFile = new File([blob], getCompressedImageName(file.name), {
|
||||
type: 'image/webp',
|
||||
lastModified: Date.now()
|
||||
})
|
||||
|
||||
// اگه webp بزرگتر شد، فایل اصلی رو بفرست
|
||||
if (webpFile.size >= file.size) {
|
||||
return file
|
||||
}
|
||||
|
||||
return webpFile
|
||||
} catch (error) {
|
||||
console.error('compress admin image to webp failed:', error)
|
||||
return file
|
||||
}
|
||||
}
|
||||
const uploadChatFile = (file: File, onProgress?: (percent: number) => void) => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const uploadUrl = getChatUploadUrl()
|
||||
|
||||
if (!uploadUrl) {
|
||||
reject(new Error('chat_upload_url is empty'))
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.open('POST', uploadUrl)
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable) return
|
||||
|
||||
const percent = Math.round((event.loaded / event.total) * 100)
|
||||
onProgress?.(Math.min(percent, 99))
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
reject(new Error(`chat file upload failed: ${xhr.status}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = xhr.getResponseHeader('content-type') || ''
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const result = JSON.parse(xhr.responseText)
|
||||
const uploadedPath = extractUploadedPath(result)
|
||||
|
||||
if (!uploadedPath) {
|
||||
console.error('upload response has no path/url:', result)
|
||||
reject(new Error('upload response has no path/url'))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(uploadedPath)
|
||||
return
|
||||
}
|
||||
|
||||
const uploadedPath = xhr.responseText.trim()
|
||||
|
||||
if (!uploadedPath) {
|
||||
reject(new Error('upload response is empty'))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(uploadedPath)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new Error('chat file upload network error'))
|
||||
}
|
||||
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
const formatTime = (date: Date | string | number | null | undefined) => {
|
||||
const value = date ? new Date(date) : new Date()
|
||||
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
const getMessageFallbackText = (message: SupportMessage) => {
|
||||
const firstAttachment = message.attachments?.[0]
|
||||
|
||||
if (message.text?.trim()) return message.text.trim()
|
||||
if (firstAttachment?.type === 'image') return 'تصویر'
|
||||
if (firstAttachment?.type === 'audio') return 'ویس'
|
||||
|
||||
return 'پیام'
|
||||
}
|
||||
|
||||
const handleReplyMessage = (message: SupportMessage) => {
|
||||
const firstAttachment = message.attachments?.[0] || null
|
||||
|
||||
replyingTo.value = {
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
text: getMessageFallbackText(message),
|
||||
attachment: firstAttachment
|
||||
? {
|
||||
type: firstAttachment.type,
|
||||
url: firstAttachment.url,
|
||||
name: firstAttachment.name
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
rooms,
|
||||
messagesByRoom,
|
||||
status,
|
||||
activeRoomId: serviceActiveRoomId,
|
||||
hasMoreByRoom,
|
||||
isFetchingOlderByRoom,
|
||||
isInitialLoadingByRoom,
|
||||
adminRoomsLoading
|
||||
} = useAdminChatService()
|
||||
|
||||
const activeConversationId = computed(() => serviceActiveRoomId.value || '')
|
||||
const roomsLoading = computed(() => adminRoomsLoading.value)
|
||||
|
||||
const getRoomId = (room: any) => {
|
||||
return String(room?.room_id ?? room?.id ?? room?.uuid ?? room?.room?.id ?? '')
|
||||
}
|
||||
|
||||
const getConversationName = (room: any) => {
|
||||
return (
|
||||
room?.name ||
|
||||
room?.user?.name ||
|
||||
room?.user_name ||
|
||||
room?.customer?.name ||
|
||||
room?.full_name ||
|
||||
room?.title ||
|
||||
'کاربر'
|
||||
)
|
||||
}
|
||||
|
||||
const mapRawAttachment = (item: any): SupportAttachment | null => {
|
||||
if (!item) return null
|
||||
|
||||
const type = item.type || item.file_type || item.kind
|
||||
const rawUrl = item.url || item.file_url || item.path
|
||||
const url = buildChatFileUrl(rawUrl)
|
||||
const name = item.name || item.file_name || 'file'
|
||||
|
||||
if (!url) return null
|
||||
|
||||
if (type === 'image') {
|
||||
return {
|
||||
id: item.id || createId(),
|
||||
type: 'image',
|
||||
url,
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'audio' || type === 'voice') {
|
||||
return {
|
||||
id: item.id || createId(),
|
||||
type: 'audio',
|
||||
url,
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const mapRoomMessagesToUi = (roomId: string): SupportMessage[] => {
|
||||
const items = messagesByRoom.value[roomId] || []
|
||||
|
||||
const mapped = items
|
||||
.map((item: any) => {
|
||||
const normalized = item.message ?? item
|
||||
|
||||
const senderId =
|
||||
normalized.sender_id ??
|
||||
normalized.user_id ??
|
||||
normalized.from_user_id ??
|
||||
normalized.sender?.id
|
||||
|
||||
const typeContent = normalized.type_content ?? normalized.type ?? 'text'
|
||||
const rawContent = normalized.content ?? normalized.text ?? normalized.body ?? ''
|
||||
|
||||
const content =
|
||||
typeContent === 'image' || typeContent === 'voice' || typeContent === 'audio'
|
||||
? buildChatFileUrl(rawContent)
|
||||
: rawContent
|
||||
|
||||
let attachments: SupportAttachment[] = []
|
||||
|
||||
if (typeContent === 'image' && content) {
|
||||
attachments = [
|
||||
{
|
||||
id: normalized.id || normalized.message_id || createId(),
|
||||
type: 'image',
|
||||
url: content,
|
||||
name: 'image'
|
||||
}
|
||||
]
|
||||
} else if ((typeContent === 'voice' || typeContent === 'audio') && content) {
|
||||
attachments = [
|
||||
{
|
||||
id: normalized.id || normalized.message_id || createId(),
|
||||
type: 'audio',
|
||||
url: content,
|
||||
name: 'voice'
|
||||
}
|
||||
]
|
||||
} else {
|
||||
const rawAttachments = normalized.attachments ?? normalized.files ?? normalized.media ?? []
|
||||
|
||||
attachments = Array.isArray(rawAttachments)
|
||||
? (rawAttachments.map(mapRawAttachment).filter(Boolean) as SupportAttachment[])
|
||||
: []
|
||||
}
|
||||
|
||||
if (!content && attachments.length === 0) return null
|
||||
|
||||
const rawCreatedAt =
|
||||
normalized.created_at || normalized.createdAt || normalized.timestamp || null
|
||||
|
||||
return {
|
||||
id: normalized.id || normalized.message_id || normalized._client_key || createId(),
|
||||
sender: String(senderId) === 'admin' ? 'admin' : 'user',
|
||||
text: typeContent === 'text' ? content : '',
|
||||
createdAt: formatTime(rawCreatedAt),
|
||||
createdAtRaw: rawCreatedAt,
|
||||
status:
|
||||
String(senderId) === 'admin'
|
||||
? normalized._optimistic
|
||||
? 'sending'
|
||||
: normalized.read_at
|
||||
? 'seen'
|
||||
: 'sent'
|
||||
: undefined,
|
||||
attachments,
|
||||
_replyId: normalized.reply ? String(normalized.reply) : ''
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<SupportMessage & { _replyId?: string }>
|
||||
|
||||
const byId = new Map(mapped.map((message) => [message.id, message]))
|
||||
|
||||
return mapped.map((message) => {
|
||||
const replyId = message._replyId
|
||||
const replyTarget = replyId ? byId.get(replyId) : null
|
||||
|
||||
const { _replyId, ...cleanMessage } = message
|
||||
|
||||
return {
|
||||
...cleanMessage,
|
||||
replyTo: replyId
|
||||
? {
|
||||
id: replyId,
|
||||
sender: replyTarget?.sender,
|
||||
text:
|
||||
replyTarget?.text ||
|
||||
(replyTarget?.attachments?.[0]?.type === 'image'
|
||||
? 'تصویر'
|
||||
: replyTarget?.attachments?.[0]?.type === 'audio'
|
||||
? 'ویس'
|
||||
: 'پیام'),
|
||||
attachment: replyTarget?.attachments?.[0]
|
||||
? {
|
||||
type: replyTarget.attachments[0].type,
|
||||
url: replyTarget.attachments[0].url,
|
||||
name: replyTarget.attachments[0].name
|
||||
}
|
||||
: null
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const conversations = computed<SupportConversation[]>(() => {
|
||||
return rooms.value
|
||||
.map((room: any) => {
|
||||
const lastMessageType =
|
||||
room?.last_message?.type_content ||
|
||||
room?.last_message?.type ||
|
||||
room?.lastMessageType ||
|
||||
'text'
|
||||
|
||||
const rawPreview =
|
||||
room?.last_message?.content || room?.last_message?.text || room?.lastMessagePreview || ''
|
||||
|
||||
const lastMessagePreview =
|
||||
lastMessageType === 'image'
|
||||
? 'تصویر'
|
||||
: lastMessageType === 'voice' || lastMessageType === 'audio'
|
||||
? 'وویس'
|
||||
: rawPreview || 'پیامی وجود ندارد'
|
||||
|
||||
return {
|
||||
id: getRoomId(room),
|
||||
userName: getConversationName(room),
|
||||
avatar: room?.avatar || room?.user?.avatar || '',
|
||||
online: Boolean(room?.online ?? room?.user?.online ?? false),
|
||||
unreadCount: Number(room?.unread_count ?? room?.unreadCount ?? 0),
|
||||
lastSeenText: room?.last_seen_text || room?.lastSeenText || 'نامشخص',
|
||||
lastMessagePreview,
|
||||
updatedAtText: formatTime(
|
||||
room?.last_message?.created_at || room?.updated_at || room?.created_at || new Date()
|
||||
),
|
||||
messages: []
|
||||
}
|
||||
})
|
||||
.filter((item) => !!item.id)
|
||||
})
|
||||
|
||||
const activeConversation = computed(() => {
|
||||
return conversations.value.find((item) => item.id === activeConversationId.value) || null
|
||||
})
|
||||
|
||||
const activeMessages = computed<SupportMessage[]>(() => {
|
||||
if (!activeConversationId.value) return []
|
||||
|
||||
const realMessages = mapRoomMessagesToUi(activeConversationId.value)
|
||||
const uploadingMessages = uploadingMessagesByRoom.value[activeConversationId.value] || []
|
||||
|
||||
return [...realMessages, ...uploadingMessages]
|
||||
})
|
||||
|
||||
const activeHasMore = computed(() => {
|
||||
if (!activeConversationId.value) return false
|
||||
return Boolean(hasMoreByRoom.value[activeConversationId.value])
|
||||
})
|
||||
|
||||
const activeLoadingOlder = computed(() => {
|
||||
if (!activeConversationId.value) return false
|
||||
return Boolean(isFetchingOlderByRoom.value[activeConversationId.value])
|
||||
})
|
||||
|
||||
const activeRoomLoading = computed(() => {
|
||||
if (!activeConversationId.value) return false
|
||||
return Boolean(isInitialLoadingByRoom.value[activeConversationId.value])
|
||||
})
|
||||
|
||||
const activeLoadingLabel = computed(() => {
|
||||
if (!activeRoomLoading.value) return ''
|
||||
if (status.value === 'CONNECTING') return 'در حال دریافت پیامها و اتصال به سوکت...'
|
||||
return 'در حال دریافت پیامها...'
|
||||
})
|
||||
|
||||
const messagesRef = ref<InstanceType<typeof AdminSupportMessages> | null>(null)
|
||||
|
||||
const scrollToBottom = async (smooth = true) => {
|
||||
await nextTick()
|
||||
messagesRef.value?.scrollToBottom(smooth)
|
||||
}
|
||||
|
||||
const handleSelectConversation = async (conversationId: string) => {
|
||||
mobileListOpen.value = false
|
||||
replyingTo.value = null
|
||||
|
||||
await setAdminActiveRoom(conversationId, {
|
||||
fetchIfNeeded: true,
|
||||
syncIfAlreadyLoaded: true
|
||||
})
|
||||
|
||||
await scrollToBottom(false)
|
||||
}
|
||||
|
||||
const handleBackToList = () => {
|
||||
mobileListOpen.value = true
|
||||
}
|
||||
|
||||
const handleCloseMobileList = () => {
|
||||
if (isMobileViewport() && !activeConversationId.value) {
|
||||
mobileListOpen.value = true
|
||||
return
|
||||
}
|
||||
mobileListOpen.value = false
|
||||
}
|
||||
|
||||
const isMobileViewport = () =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||
|
||||
const loadOlderMessages = async () => {
|
||||
if (!activeConversationId.value) return
|
||||
|
||||
try {
|
||||
messagesRef.value?.prepareForPrepend()
|
||||
await fetchOlderAdminRoomMessages(activeConversationId.value)
|
||||
await messagesRef.value?.restoreAfterPrepend()
|
||||
} catch (error) {
|
||||
console.error('load older admin messages failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sendAdminReply = async (payload: ComposerSendPayload) => {
|
||||
const text = payload.text.trim()
|
||||
const attachments = payload.attachments.map((item) => ({ ...item }))
|
||||
const replyId = payload.replyTo?.id || null
|
||||
|
||||
if (!activeConversationId.value) return
|
||||
if (!text && attachments.length === 0) return
|
||||
if (isSending.value) return
|
||||
|
||||
const roomId = activeConversationId.value
|
||||
|
||||
try {
|
||||
isSending.value = true
|
||||
|
||||
if (text) {
|
||||
await sendAdminTextMessage(roomId, text, replyId)
|
||||
}
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.file) {
|
||||
if (attachment.url) {
|
||||
await sendAdminMediaMessage(
|
||||
roomId,
|
||||
attachment.url,
|
||||
attachment.type === 'audio' ? 'voice' : 'image',
|
||||
replyId
|
||||
)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const tempMessageId = `uploading-${createId()}`
|
||||
const previewUrl = URL.createObjectURL(attachment.file)
|
||||
|
||||
const uploadingMessage: SupportMessage = {
|
||||
id: tempMessageId,
|
||||
sender: 'admin',
|
||||
text: '',
|
||||
createdAt: formatTime(new Date()),
|
||||
createdAtRaw: new Date().toISOString(),
|
||||
status: 'sending',
|
||||
replyTo: payload.replyTo || null,
|
||||
attachments: [
|
||||
{
|
||||
...attachment,
|
||||
id: attachment.id || createId(),
|
||||
url: previewUrl,
|
||||
uploading: true,
|
||||
uploadProgress: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
uploadingMessagesByRoom.value[roomId] = [
|
||||
...(uploadingMessagesByRoom.value[roomId] || []),
|
||||
uploadingMessage
|
||||
]
|
||||
|
||||
uploadingMessagesByRoom.value = { ...uploadingMessagesByRoom.value }
|
||||
|
||||
await nextTick()
|
||||
await scrollToBottom(true)
|
||||
|
||||
const setUploadProgress = (percent: number) => {
|
||||
const list = uploadingMessagesByRoom.value[roomId] || []
|
||||
const target = list.find((message) => message.id === tempMessageId)
|
||||
const targetAttachment = target?.attachments?.[0]
|
||||
|
||||
if (targetAttachment) {
|
||||
targetAttachment.uploadProgress = percent
|
||||
uploadingMessagesByRoom.value = { ...uploadingMessagesByRoom.value }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadProgress(5)
|
||||
|
||||
const originalFile = attachment.file
|
||||
|
||||
const fileForUpload =
|
||||
attachment.type === 'image' ? await compressImageToWebp(originalFile) : originalFile
|
||||
|
||||
setUploadProgress(12)
|
||||
|
||||
const uploadedPath = await uploadChatFile(fileForUpload, (percent) => {
|
||||
const mappedPercent = 12 + Math.round(percent * 0.87)
|
||||
setUploadProgress(Math.min(mappedPercent, 99))
|
||||
})
|
||||
|
||||
setUploadProgress(99)
|
||||
|
||||
await sendAdminMediaMessage(
|
||||
roomId,
|
||||
uploadedPath,
|
||||
attachment.type === 'audio' ? 'voice' : 'image',
|
||||
replyId
|
||||
)
|
||||
} finally {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
|
||||
uploadingMessagesByRoom.value[roomId] = (
|
||||
uploadingMessagesByRoom.value[roomId] || []
|
||||
).filter((message) => message.id !== tempMessageId)
|
||||
|
||||
uploadingMessagesByRoom.value = { ...uploadingMessagesByRoom.value }
|
||||
}
|
||||
}
|
||||
|
||||
draft.value = ''
|
||||
replyingTo.value = null
|
||||
await scrollToBottom(true)
|
||||
} catch (error) {
|
||||
console.error('send admin reply failed:', error)
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setAdminSupportPageOpen(true)
|
||||
if (isMobileViewport()) {
|
||||
mobileListOpen.value = !activeConversationId.value
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
setAdminSupportPageOpen(false)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
setAdminSupportPageOpen(true)
|
||||
if (isMobileViewport() && !activeConversationId.value) {
|
||||
mobileListOpen.value = true
|
||||
}
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
setAdminSupportPageOpen(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.admin-support-card {
|
||||
height: 100% !important;
|
||||
min-height: 0;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.admin-support-card > .ant-card,
|
||||
.admin-support-card .ant-card {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.admin-support-card > .ant-card-body,
|
||||
.support-style .ant-card-body {
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.admin-support-shell {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.admin-support-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
background: var(--admin-bg-elevated, #fff);
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.admin-mobile-sidebar-trigger {
|
||||
position: absolute;
|
||||
inset-inline: 16px;
|
||||
top: 50%;
|
||||
z-index: 12;
|
||||
transform: translateY(-50%);
|
||||
min-height: 48px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--admin-border-strong, #e4e4e7);
|
||||
border-radius: 16px;
|
||||
background: var(--admin-bg-elevated, #fff);
|
||||
color: var(--admin-text, #18181b);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px var(--admin-shadow, rgba(15, 23, 42, 0.08));
|
||||
}
|
||||
|
||||
.admin-empty-pane {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--admin-bg-elevated, #fff);
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.admin-support-card {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.admin-support-card .ant-card {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.admin-support-shell {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.admin-support-card {
|
||||
padding: 0 !important;
|
||||
margin-right: 10px !important;
|
||||
margin-left: 5px !important;
|
||||
}
|
||||
|
||||
.admin-support-card .ant-card {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.admin-support-shell {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.admin-support-content {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.admin-mobile-sidebar-trigger {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html.is-dark .admin-mobile-sidebar-trigger {
|
||||
background: var(--admin-bg-muted);
|
||||
border-color: var(--admin-border-strong);
|
||||
color: var(--admin-text);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user