added chat 2

This commit is contained in:
2026-07-30 18:30:32 +03:30
parent 99698e345a
commit f4733214ed
26 changed files with 6921 additions and 53 deletions
+1
View File
@@ -29,6 +29,7 @@ declare module '@vue/runtime-core' {
AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
+8 -1
View File
@@ -27,7 +27,8 @@
"vue-echarts": "^6.6.8",
"vue-router": "^4.1.6",
"vue3-apexcharts": "^1.4.4",
"vue3-otp-input": "^0.4.1"
"vue3-otp-input": "^0.4.1",
"wavesurfer.js": "^7.12.11"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.2.0",
@@ -10641,6 +10642,12 @@
"node": ">=10.13.0"
}
},
"node_modules/wavesurfer.js": {
"version": "7.12.11",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.11.tgz",
"integrity": "sha512-Sx0yZIz7jRJ9J9p7UL+pl9y0UQBB6UN/XNo+R7Gy4tCR5xZI9jMyA0dnt1R8TMVSQ4LZ10SeB2HJYQ+dV2dPSA==",
"license": "BSD-3-Clause"
},
"node_modules/webidl-conversions": {
"version": "4.0.2",
"resolved": "https://mirror-npm.runflare.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+2 -1
View File
@@ -37,7 +37,8 @@
"vue-echarts": "^6.6.8",
"vue-router": "^4.1.6",
"vue3-apexcharts": "^1.4.4",
"vue3-otp-input": "^0.4.1"
"vue3-otp-input": "^0.4.1",
"wavesurfer.js": "^7.12.11"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.2.0",
+46 -18
View File
@@ -2,39 +2,67 @@
<AConfigProvider direction="rtl">
<RouterView />
<CustomerImportProgress />
<!-- <button @click="test">this is ssss</button>-->
</AConfigProvider>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue'
import { onMounted, watch } from 'vue'
import { message } from 'ant-design-vue'
import { useConfigStore } from '@/store/configStore'
import { initDarkmode } from '@core/stores/darkmode'
import CustomerImportProgress from '@/components/CustomerImportProgress.vue'
import { useUserStore } from '@/store/userStore'
import { bootstrapAdminChat, useAdminChatService } from '@/utils/adminChat'
import { supportChatMenuEnabledSetting } from '@/utils/config'
const router = useRouter()
const config = useConfigStore()
const userStore = useUserStore()
const { latestIncomingNotification } = useAdminChatService()
initDarkmode()
function test() {
console.log('roussss', router)
router.push({ name: 'users' })
}
let started = false
let lastToastAt = 0
watch(
() => [
userStore.userInfo?.id,
supportChatMenuEnabledSetting.value,
userStore.findSettingByKey('chat_api_key'),
],
async ([userId, chatMenuOn, chatApiKey]) => {
if (started) return
if (!userId || !chatMenuOn || !chatApiKey) {
return
}
started = true
try {
await bootstrapAdminChat({
connectEvenIfNoRooms: true,
askNotificationPermission: false,
})
} catch (error) {
started = false
console.error('bootstrapAdminChat failed:', error)
}
},
{ immediate: true },
)
watch(latestIncomingNotification, (payload) => {
if (!payload) return
if (payload.at === lastToastAt) return
lastToastAt = payload.at
message.info({
content: `پیام پشتیبانی جدید: ${payload.title}`,
duration: 3,
})
})
onMounted(async () => {
await config.getConfigData()
})
/*const router = useRouter()
console.log('this is router', router.currentRoute.value)*/
/*import { useStaticApiListsStore } from '@/store/staticApisStore'
useStaticApiListsStore().getWholeData()*/
//define store
/*import { useStaticApiListsStore } from '@/store/staticApisStore'
import { getToken } from '@/utils'*/
/*const store = useStaticApiListsStore()
const token = getToken()
if (token) store.getMyInfo()*/
</script>
<style lang="less"></style>
+8 -1
View File
@@ -29,6 +29,7 @@ import BasicMobileOverflowMenu from '@/components/BasicMobileOverflowMenu.vue'
import { sortBy } from 'lodash'
import { computed } from 'vue'
import type { RouteRecordName, RouteRecordRaw } from 'vue-router'
import { supportChatMenuEnabledSetting } from '@/utils/config'
const BOTTOM_NAV_PRIORITY: (string | symbol)[] = ['dashboard', 'customers', 'orders', 'transactions']
@@ -38,7 +39,13 @@ const routerStore = useRouterStore()
const allMenuItems = computed(() =>
sortBy(
routerStore.routes.filter((item) => !item.meta?.hideMenu),
routerStore.routes.filter((item) => {
if (item.meta?.hideMenu) return false
if (item.name === 'support' || item.meta?.permissionsKey === 'support') {
return supportChatMenuEnabledSetting.value
}
return true
}),
(item) => item.meta?.order
)
)
+3 -2
View File
@@ -1017,6 +1017,7 @@
"vuesax-linear:youtube": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M17 20H7C4 20 2 18 2 15V9C2 6 4 4 7 4H17C20 4 22 6 22 9V15C22 18 20 20 17 20Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M11.4001 9.5L13.9001 11C14.8001 11.6 14.8001 12.5 13.9001 13.1L11.4001 14.6C10.4001 15.2 9.6001 14.7 9.6001 13.6V10.6C9.6001 9.3 10.4001 8.9 11.4001 9.5Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:zel-(zel)": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M10.5 11.5V14.3L8 15.8L5.5 14.3V11.5L8 10L9.2 10.7L10.5 11.5Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linejoin=\"round\"/>\n<path d=\"M18.5 11.5V14.3L16 15.8L13.5 14.3V11.5L14.8 10.7L16 10L18.5 11.5Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linejoin=\"round\"/>\n<path d=\"M14.8 9.29999V10.7L13.5 11.5V13.2L12 14.1L10.5 13.2V11.5L9.19995 10.7V9.29999L12 7.79999L14.8 9.29999Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linejoin=\"round\"/>\n<path d=\"M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:zoom": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 9.69999V12.5C6 14 7.3 15.3 8.8 15.3H12.5C13 15.3 13.4 14.9 13.4 14.4V11.6C13.4 10.1 12.2 8.79999 10.6 8.79999H6.9C6.4 8.79999 6 9.19999 6 9.69999Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\"/>\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M18 9.20001V14.8L15.2 12.9V11.1L18 9.20001Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z\" stroke=\"#17191C\" stroke-width=\"1.5\" stroke-miterlimit=\"10\"/>\n</svg>\n",
"vuesax-linear:verify": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M8.38 12L10.79 14.42L15.62 9.57996\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n<path d=\"M10.75 2.44995C11.44 1.85995 12.57 1.85995 13.27 2.44995L14.85 3.80995C15.15 4.06995 15.71 4.27995 16.11 4.27995H17.81C18.87 4.27995 19.74 5.14995 19.74 6.20995V7.90995C19.74 8.29995 19.95 8.86995 20.21 9.16995L21.57 10.7499C22.16 11.4399 22.16 12.5699 21.57 13.2699L20.21 14.8499C19.95 15.1499 19.74 15.7099 19.74 16.1099V17.8099C19.74 18.8699 18.87 19.7399 17.81 19.7399H16.11C15.72 19.7399 15.15 19.9499 14.85 20.2099L13.27 21.5699C12.58 22.1599 11.45 22.1599 10.75 21.5699L9.17 20.2099C8.87 19.9499 8.31 19.7399 7.91 19.7399H6.18C5.12 19.7399 4.25 18.8699 4.25 17.8099V16.0999C4.25 15.7099 4.04 15.1499 3.79 14.8499L2.44 13.2599C1.86 12.5699 1.86 11.4499 2.44 10.7599L3.79 9.16995C4.04 8.86995 4.25 8.30995 4.25 7.91995V6.19995C4.25 5.13995 5.12 4.26995 6.18 4.26995H7.91C8.3 4.26995 8.87 4.05995 9.17 3.79995L10.75 2.44995Z\" stroke=\"#292D32\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n</svg>\n",
"vuesax-linear:chart-fail": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\">\n<path d=\"M17.23 21.5199C17.04 21.5199 16.85 21.4499 16.7 21.2999C16.41 21.0099 16.41 20.5299 16.7 20.2399L20.24 16.6999C20.53 16.4099 21.01 16.4099 21.3 16.6999C21.59 16.9899 21.59 17.4699 21.3 17.7599L17.76 21.2999C17.62 21.4399 17.42 21.5199 17.23 21.5199Z\" fill=\"#292D32\"/>\n<path d=\"M20.77 21.5199C20.58 21.5199 20.39 21.4499 20.24 21.2999L16.7 17.7599C16.41 17.4699 16.41 16.9899 16.7 16.6999C16.99 16.4099 17.47 16.4099 17.76 16.6999L21.3 20.2399C21.59 20.5299 21.59 21.0099 21.3 21.2999C21.15 21.4399 20.96 21.5199 20.77 21.5199Z\" fill=\"#292D32\"/>\n<path d=\"M7 14.25C6.59 14.25 6.25 13.91 6.25 13.5V10.5C6.25 10.09 6.59 9.75 7 9.75C7.41 9.75 7.75 10.09 7.75 10.5V13.5C7.75 13.91 7.41 14.25 7 14.25Z\" fill=\"#292D32\"/>\n<path d=\"M12 14.25C11.59 14.25 11.25 13.91 11.25 13.5V10.5C11.25 10.09 11.59 9.75 12 9.75C12.41 9.75 12.75 10.09 12.75 10.5V13.5C12.75 13.91 12.41 14.25 12 14.25Z\" fill=\"#292D32\"/>\n<path d=\"M17 14.25C16.59 14.25 16.25 13.91 16.25 13.5V10.5C16.25 10.09 16.59 9.75 17 9.75C17.41 9.75 17.75 10.09 17.75 10.5V13.5C17.75 13.91 17.41 14.25 17 14.25Z\" fill=\"#292D32\"/>\n<path d=\"M13 22.75H9C3.57 22.75 1.25 20.43 1.25 15V9C1.25 3.57 3.57 1.25 9 1.25H15C20.43 1.25 22.75 3.57 22.75 9V13C22.75 13.41 22.41 13.75 22 13.75C21.59 13.75 21.25 13.41 21.25 13V9C21.25 4.39 19.61 2.75 15 2.75H9C4.39 2.75 2.75 4.39 2.75 9V15C2.75 19.61 4.39 21.25 9 21.25H13C13.41 21.25 13.75 21.59 13.75 22C13.75 22.41 13.41 22.75 13 22.75Z\" fill=\"#292D32\"/>\n</svg>"
"vuesax-linear:chart-fail": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\">\n<path d=\"M17.23 21.5199C17.04 21.5199 16.85 21.4499 16.7 21.2999C16.41 21.0099 16.41 20.5299 16.7 20.2399L20.24 16.6999C20.53 16.4099 21.01 16.4099 21.3 16.6999C21.59 16.9899 21.59 17.4699 21.3 17.7599L17.76 21.2999C17.62 21.4399 17.42 21.5199 17.23 21.5199Z\" fill=\"#292D32\"/>\n<path d=\"M20.77 21.5199C20.58 21.5199 20.39 21.4499 20.24 21.2999L16.7 17.7599C16.41 17.4699 16.41 16.9899 16.7 16.6999C16.99 16.4099 17.47 16.4099 17.76 16.6999L21.3 20.2399C21.59 20.5299 21.59 21.0099 21.3 21.2999C21.15 21.4399 20.96 21.5199 20.77 21.5199Z\" fill=\"#292D32\"/>\n<path d=\"M7 14.25C6.59 14.25 6.25 13.91 6.25 13.5V10.5C6.25 10.09 6.59 9.75 7 9.75C7.41 9.75 7.75 10.09 7.75 10.5V13.5C7.75 13.91 7.41 14.25 7 14.25Z\" fill=\"#292D32\"/>\n<path d=\"M12 14.25C11.59 14.25 11.25 13.91 11.25 13.5V10.5C11.25 10.09 11.59 9.75 12 9.75C12.41 9.75 12.75 10.09 12.75 10.5V13.5C12.75 13.91 12.41 14.25 12 14.25Z\" fill=\"#292D32\"/>\n<path d=\"M17 14.25C16.59 14.25 16.25 13.91 16.25 13.5V10.5C16.25 10.09 16.59 9.75 17 9.75C17.41 9.75 17.75 10.09 17.75 10.5V13.5C17.75 13.91 17.41 14.25 17 14.25Z\" fill=\"#292D32\"/>\n<path d=\"M13 22.75H9C3.57 22.75 1.25 20.43 1.25 15V9C1.25 3.57 3.57 1.25 9 1.25H15C20.43 1.25 22.75 3.57 22.75 9V13C22.75 13.41 22.41 13.75 22 13.75C21.59 13.75 21.25 13.41 21.25 13V9C21.25 4.39 19.61 2.75 15 2.75H9C4.39 2.75 2.75 4.39 2.75 9V15C2.75 19.61 4.39 21.25 9 21.25H13C13.41 21.25 13.75 21.59 13.75 22C13.75 22.41 13.41 22.75 13 22.75Z\" fill=\"#292D32\"/>\n</svg>",
"custom:single-check": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"none\"><path d=\"M5.5 10.5L8.2 13.2L14.5 6.8\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>",
"custom:double-check": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"none\"><path d=\"M2.8 10.7L5.2 13.1L10.2 8.1\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><path d=\"M7.8 10.7L10.2 13.1L15.2 8.1\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>"
}
+28 -1
View File
@@ -20,6 +20,23 @@
<div class="header-actions flex shrink-0 items-center gap-2">
<SmsCreditWidget variant="desktop" class="header-actions__credit min-w-0" />
<div class="header-actions__fixed flex shrink-0 items-center gap-3">
<button
v-if="supportChatEnabled"
type="button"
class="flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent text-[var(--admin-text-secondary)] transition-colors hover:opacity-80"
aria-label="چت پشتیبانی"
@click="goToSupport"
>
<a-badge
:count="totalUnreadCount"
:overflow-count="99"
:show-zero="false"
>
<span class="inline-flex items-center justify-center">
<Icon icon="vuesax-linear:messages-2" size="22" />
</span>
</a-badge>
</button>
<ADropdown placement="bottomLeft" class="ml-5">
<a class="ant-dropdown-link" @click.prevent>
<!-- Hover me-->
@@ -158,19 +175,29 @@ import { inputRules, inputLenRules } from '@/utils/inputRules'
import type { User } from '@/model/user'
import { createUser, updateMyInformation, updateUser } from '@/api/user'
import { message } from 'ant-design-vue/es'
import { onMounted, ref } from 'vue'
import { onMounted, ref, computed } from 'vue'
import type { FormInstance } from 'ant-design-vue'
import { cloneDeep } from 'lodash'
import { useWindowSize } from '@vueuse/core'
import {getConfigApp} from "@/utils/config";
import ThemeMenuSection from '@core/components/ThemeMenuSection.vue'
import { useAdminTheme } from '@core/composable/useAdminTheme'
import { useAdminChatService } from '@/utils/adminChat'
import { supportChatMenuEnabledSetting } from '@/utils/config'
const { prefixCls } = usePrefix('basic-header')
const layoutStore = useLayoutStore()
const store = useStaticApiListsStore()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const { menuTheme } = useAdminTheme()
const router = useRouter()
const { totalUnreadCount } = useAdminChatService()
const supportChatEnabled = computed(() => supportChatMenuEnabledSetting.value)
function goToSupport() {
router.push({ name: 'supportMain' })
}
const { width } = useWindowSize()
//my info modal
+10 -1
View File
@@ -36,6 +36,7 @@ import BaseMenuItem from '@core/layouts/composnts/BaseMenuItem.vue'
import type { RouteRecordName, RouteRecordRaw } from 'vue-router'
import { useRouterStore } from '@core/stores/router'
import { useAdminTheme } from '@core/composable/useAdminTheme'
import { supportChatMenuEnabledSetting } from '@/utils/config'
const { prefixCls } = usePrefix('basic-menu')
const layoutStore = useLayoutStore()
@@ -68,10 +69,18 @@ function walkInRoutes(routes: RouteRecordRaw[]) {
walkInRoutes(routerStore.routes)
selectedKeys.value = path
function isSupportMenuRoute(item: RouteRecordRaw): boolean {
return item.name === 'support' || item.meta?.permissionsKey === 'support'
}
const menu: ComputedRef<RouteRecordRaw[]> = computed(() => {
return sortBy(routerStore.routes, (item) => {
return item.meta?.order
}).filter((item) => !item.meta?.hideMenu)
}).filter((item) => {
if (item.meta?.hideMenu) return false
if (isSupportMenuRoute(item)) return supportChatMenuEnabledSetting.value
return true
})
})
</script>
+56
View File
@@ -0,0 +1,56 @@
export type SupportAttachmentType = 'image' | 'audio'
export type SupportMessageSender = 'user' | 'admin'
export type SupportMessageStatus = 'sending' | 'sent' | 'seen'
export type SupportMessageReplyTo = {
id: string
sender?: SupportMessageSender
text?: string
attachment?: {
type: 'image' | 'audio'
url: string
name?: string
} | null
}
export interface SupportAttachment {
id: string
type: SupportAttachmentType
url: string
name: string
size?: number
mimeType?: string
durationMs?: number
file?: File
uploading?: boolean
uploadProgress?: number
}
export interface SupportMessage {
id: string
sender: SupportMessageSender
text?: string
createdAt: string
createdAtRaw?: string | number | Date | null
status?: SupportMessageStatus
attachments?: SupportAttachment[]
replyTo?: SupportMessageReplyTo | null
}
export interface SupportConversation {
id: string
userName: string
avatar: string
online: boolean
unreadCount: number
lastSeenText: string
lastMessagePreview: string
updatedAtText: string
messages: SupportMessage[]
}
export interface ComposerSendPayload {
text: string
attachments: SupportAttachment[]
replyTo?: SupportMessageReplyTo | null
}
@@ -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>
+13 -1
View File
@@ -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>
+932
View File
@@ -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>
+36 -6
View File
@@ -6,10 +6,30 @@ import { getConfigApp, isStoreApp } from '@/utils/config'
import { isDeployAdmin } from '@/utils/deployAdmin'
import { hasSmsPanelAccess, isSmsBillingEnabled } from '@/utils/smsPanelAccess'
import { useUserStore } from '@/store/userStore'
import { cloneDeep } from 'lodash'
import { cloneDeep, get } from 'lodash'
import { isSettingFlagOn } from '@/utils/settingFlags'
//const _router = useRouter()
//import { cloneDeep } from 'lodash'
function supportChatIsEnabled(): boolean {
try {
const userStore = useUserStore()
const settings: any[] = get(userStore.userInfo, 'settings', [])
if (!Array.isArray(settings)) return false
const chatFlag = settings.find((s: any) => s?.key === 'show_to_users_chat')
const hasChat = settings.find((s: any) => s?.key === 'has_chat')
const chatApi = settings.find((s: any) => s?.key === 'chat_api')
const flagOk = isSettingFlagOn(chatFlag?.value)
const hasChatOk = isSettingFlagOn(hasChat?.value)
const apiOk =
Boolean(String(chatApi?.value ?? '').trim()) &&
String(chatApi?.value).toLowerCase() !== 'null'
return flagOk && hasChatOk && apiOk
} catch {
return false
}
}
function applyAppModeMeta(route: RouteRecordRaw, appName: string | null) {
const store = isStoreApp()
@@ -144,11 +164,11 @@ export function setupRoutes(
function walkInRoutes(routes: RouteRecordRaw) {
if (!routes.children) return
routes.children = routes?.children?.filter(
(item) =>
!item.meta?.permissionsKey ||
(item.meta?.permissionsKey && permissionsKeys.includes(item.meta?.permissionsKey))
)
routes.children = routes?.children?.filter((item) => {
const key = item.meta?.permissionsKey
if (key === 'support') return supportChatIsEnabled()
return !key || permissionsKeys.includes(key)
})
for (const route of routes.children) {
walkInRoutes(route)
}
@@ -162,11 +182,21 @@ export function setupRoutes(
const route = cloneDeep(sourceRoute)
if (route.meta?.permissionsKey === 'support' && !supportChatIsEnabled()) {
continue
}
if (permissionsKeys.length && permissionsKeys[0] == '*') {
prepareRoute(route, appName, userMobile)
dynamicRoutes.push(route)
} else {
if (
route.meta?.permissionsKey === 'support' && supportChatIsEnabled()
) {
walkInRoutes(route)
prepareRoute(route, appName, userMobile)
dynamicRoutes.push(route)
} else if (
!route?.meta?.permissionsKey ||
(route.meta.permissionsKey && permissionsKeys.includes(route.meta.permissionsKey))
) {
+34
View File
@@ -0,0 +1,34 @@
import type { RouteRecordRaw } from 'vue-router'
const support: RouteRecordRaw = {
name: 'support',
path: '/support',
component: () => import('@core/layouts/BasicLayout.vue'),
redirect: { name: 'supportMain' },
meta: {
icon: 'vuesax-linear:messages-2',
permissionsKey: 'support',
title: 'چت پشتیبانی',
disabled: false,
hideMenu: false,
hideChildren: true,
order: 11,
},
children: [
{
name: 'supportMain',
path: '',
component: () => import('@/pages/support/index.vue'),
meta: {
icon: 'vuesax-linear:messages-2',
order: 1,
title: 'چت پشتیبانی',
disabled: false,
permissionsKey: 'support',
hideMenu: true,
},
},
],
}
export default support
+5
View File
@@ -16,6 +16,10 @@ export const useUserStore = defineStore('user', () => {
}
}
function findSettingByKey(key: string) {
return userInfo.value?.settings?.find((setting: any) => setting.key === key)?.value
}
const permissions: ComputedRef<string[]> = computed(() => {
const permissions: string[] = get(userInfo.value, 'permission', [])
const excludes: string[] = get(userInfo.value, 'exclude', [])
@@ -39,6 +43,7 @@ export const useUserStore = defineStore('user', () => {
return {
userInfo,
getMyInfo,
findSettingByKey,
permissions,
canAccess,
isAdmin,
File diff suppressed because it is too large Load Diff
+55 -1
View File
@@ -55,6 +55,9 @@ export const appNotificationEnabledSetting = ref(true)
/** reactive — منبع قیمت فعال (montzar / zaarbox / main_product) */
export const priceProviderSetting = ref('')
/** reactive — منوی افقی / دکمه «چت پشتیبانی» */
export const supportChatMenuEnabledSetting = ref(false)
export const accountingProviderSetting = ref<AccountingProvider>('none')
let tahesabEnabledFromSettings: boolean | null = null
@@ -65,7 +68,7 @@ let accountingProviderFromSettings: AccountingProvider | null = null
function isSwitchOn(value: string | undefined | null): boolean {
const v = (value ?? '').trim().toLowerCase()
return v === 'true' || v === '1' || v === 'yes'
return v === 'true' || v === '1' || v === 'yes' || v === 'activate'
}
function normalizeAccountingProvider(value: string | undefined | null): AccountingProvider {
@@ -146,6 +149,56 @@ export function setAppNotificationEnabledFromSettings(value: boolean): void {
appNotificationEnabledSetting.value = value
}
export function setSupportChatMenuEnabledFromSettings(value: boolean): void {
supportChatMenuEnabledSetting.value = value
}
let lastShowToUsersChat = ''
let lastHasChat = ''
let lastChatApi = ''
/** منوی چت: show_to_users_chat + has_chat + chat_api */
export function resolveSupportChatMenuEnabled(
getValue: (key: string) => string | undefined | null
): boolean {
const api = String(getValue('chat_api') ?? '').trim()
return (
isSwitchOn(getValue('show_to_users_chat')) &&
isSwitchOn(getValue('has_chat')) &&
Boolean(api) &&
api.toLowerCase() !== 'null'
)
}
export function applySupportChatMenuEnabledFromRows(
rows: Array<{ key?: string; value?: string | null }>
): void {
let touched = false
for (const row of rows) {
const key = String(row.key ?? '')
if (key === 'show_to_users_chat') {
lastShowToUsersChat = String(row.value ?? '')
touched = true
} else if (key === 'has_chat') {
lastHasChat = String(row.value ?? '')
touched = true
} else if (key === 'chat_api') {
lastChatApi = String(row.value ?? '')
touched = true
}
}
if (!touched) return
setSupportChatMenuEnabledFromSettings(
resolveSupportChatMenuEnabled((key) => {
if (key === 'show_to_users_chat') return lastShowToUsersChat
if (key === 'has_chat') return lastHasChat
if (key === 'chat_api') return lastChatApi
return undefined
})
)
}
export function setPriceProviderFromSettings(value?: string | null): void {
priceProviderSetting.value = (value ?? '').trim().toLowerCase()
}
@@ -217,6 +270,7 @@ async function bootstrapSettingsFromApi(): Promise<void> {
applyConditionalOrderEnabledFromRows(list)
applyPriceTriggerEnabledFromRows(list)
applyAppNotificationEnabledFromRows(list)
applySupportChatMenuEnabledFromRows(list)
applyPriceProviderFromRows(list)
setCachedMainMetal(mainMetalRow?.value)
const { setCachedMainWalletProductIds } = await import('@/components/mobile/utils/wallet')
+24 -1
View File
@@ -23,4 +23,27 @@ export function removeToken() {
}
export function hotSelect(e: any) {
e.target?.select()
}
}
export const createUuid = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-')
}
return `${Date.now()}-${Math.floor(Math.random() * 1e9)}`
}
+5
View File
@@ -0,0 +1,5 @@
/** true / 1 / yes / activate */
export function isSettingFlagOn(value: unknown): boolean {
const v = String(value ?? '').trim().toLowerCase()
return v === 'true' || v === '1' || v === 'yes' || v === 'activate'
}
+8 -3
View File
@@ -1029,10 +1029,10 @@
resolved "https://mirror-npm.runflare.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
"@esbuild/win32-x64@0.18.20":
"@esbuild/linux-x64@0.18.20":
version "0.18.20"
resolved "https://mirror-npm.runflare.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz"
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz"
integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
version "4.4.0"
@@ -5497,6 +5497,11 @@ watchpack@^2.5.1:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"
wavesurfer.js@^7.12.11:
version "7.12.11"
resolved "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.11.tgz"
integrity sha512-Sx0yZIz7jRJ9J9p7UL+pl9y0UQBB6UN/XNo+R7Gy4tCR5xZI9jMyA0dnt1R8TMVSQ4LZ10SeB2HJYQ+dV2dPSA==
webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://mirror-npm.runflare.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz"