added chat 2
This commit is contained in:
Vendored
+1
@@ -29,6 +29,7 @@ declare module '@vue/runtime-core' {
|
|||||||
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
||||||
AForm: typeof import('ant-design-vue/es')['Form']
|
AForm: typeof import('ant-design-vue/es')['Form']
|
||||||
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||||
|
AImage: typeof import('ant-design-vue/es')['Image']
|
||||||
AInput: typeof import('ant-design-vue/es')['Input']
|
AInput: typeof import('ant-design-vue/es')['Input']
|
||||||
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||||
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||||
|
|||||||
Generated
+8
-1
@@ -27,7 +27,8 @@
|
|||||||
"vue-echarts": "^6.6.8",
|
"vue-echarts": "^6.6.8",
|
||||||
"vue-router": "^4.1.6",
|
"vue-router": "^4.1.6",
|
||||||
"vue3-apexcharts": "^1.4.4",
|
"vue3-apexcharts": "^1.4.4",
|
||||||
"vue3-otp-input": "^0.4.1"
|
"vue3-otp-input": "^0.4.1",
|
||||||
|
"wavesurfer.js": "^7.12.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.2.0",
|
"@rushstack/eslint-patch": "^1.2.0",
|
||||||
@@ -10641,6 +10642,12 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://mirror-npm.runflare.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
"resolved": "https://mirror-npm.runflare.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||||
|
|||||||
+2
-1
@@ -37,7 +37,8 @@
|
|||||||
"vue-echarts": "^6.6.8",
|
"vue-echarts": "^6.6.8",
|
||||||
"vue-router": "^4.1.6",
|
"vue-router": "^4.1.6",
|
||||||
"vue3-apexcharts": "^1.4.4",
|
"vue3-apexcharts": "^1.4.4",
|
||||||
"vue3-otp-input": "^0.4.1"
|
"vue3-otp-input": "^0.4.1",
|
||||||
|
"wavesurfer.js": "^7.12.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.2.0",
|
"@rushstack/eslint-patch": "^1.2.0",
|
||||||
|
|||||||
+46
-18
@@ -2,39 +2,67 @@
|
|||||||
<AConfigProvider direction="rtl">
|
<AConfigProvider direction="rtl">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<CustomerImportProgress />
|
<CustomerImportProgress />
|
||||||
<!-- <button @click="test">this is ssss</button>-->
|
|
||||||
</AConfigProvider>
|
</AConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 { useConfigStore } from '@/store/configStore'
|
||||||
import { initDarkmode } from '@core/stores/darkmode'
|
import { initDarkmode } from '@core/stores/darkmode'
|
||||||
import CustomerImportProgress from '@/components/CustomerImportProgress.vue'
|
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 config = useConfigStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { latestIncomingNotification } = useAdminChatService()
|
||||||
|
|
||||||
initDarkmode()
|
initDarkmode()
|
||||||
|
|
||||||
function test() {
|
let started = false
|
||||||
console.log('roussss', router)
|
let lastToastAt = 0
|
||||||
router.push({ name: 'users' })
|
|
||||||
}
|
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 () => {
|
onMounted(async () => {
|
||||||
await config.getConfigData()
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="less"></style>
|
<style lang="less"></style>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import BasicMobileOverflowMenu from '@/components/BasicMobileOverflowMenu.vue'
|
|||||||
import { sortBy } from 'lodash'
|
import { sortBy } from 'lodash'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { RouteRecordName, RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordName, RouteRecordRaw } from 'vue-router'
|
||||||
|
import { supportChatMenuEnabledSetting } from '@/utils/config'
|
||||||
|
|
||||||
const BOTTOM_NAV_PRIORITY: (string | symbol)[] = ['dashboard', 'customers', 'orders', 'transactions']
|
const BOTTOM_NAV_PRIORITY: (string | symbol)[] = ['dashboard', 'customers', 'orders', 'transactions']
|
||||||
|
|
||||||
@@ -38,7 +39,13 @@ const routerStore = useRouterStore()
|
|||||||
|
|
||||||
const allMenuItems = computed(() =>
|
const allMenuItems = computed(() =>
|
||||||
sortBy(
|
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
|
(item) => item.meta?.order
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: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: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: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>"
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,23 @@
|
|||||||
<div class="header-actions flex shrink-0 items-center gap-2">
|
<div class="header-actions flex shrink-0 items-center gap-2">
|
||||||
<SmsCreditWidget variant="desktop" class="header-actions__credit min-w-0" />
|
<SmsCreditWidget variant="desktop" class="header-actions__credit min-w-0" />
|
||||||
<div class="header-actions__fixed flex shrink-0 items-center gap-3">
|
<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">
|
<ADropdown placement="bottomLeft" class="ml-5">
|
||||||
<a class="ant-dropdown-link" @click.prevent>
|
<a class="ant-dropdown-link" @click.prevent>
|
||||||
<!-- Hover me-->
|
<!-- Hover me-->
|
||||||
@@ -158,19 +175,29 @@ import { inputRules, inputLenRules } from '@/utils/inputRules'
|
|||||||
import type { User } from '@/model/user'
|
import type { User } from '@/model/user'
|
||||||
import { createUser, updateMyInformation, updateUser } from '@/api/user'
|
import { createUser, updateMyInformation, updateUser } from '@/api/user'
|
||||||
import { message } from 'ant-design-vue/es'
|
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 type { FormInstance } from 'ant-design-vue'
|
||||||
import { cloneDeep } from 'lodash'
|
import { cloneDeep } from 'lodash'
|
||||||
import { useWindowSize } from '@vueuse/core'
|
import { useWindowSize } from '@vueuse/core'
|
||||||
import {getConfigApp} from "@/utils/config";
|
import {getConfigApp} from "@/utils/config";
|
||||||
import ThemeMenuSection from '@core/components/ThemeMenuSection.vue'
|
import ThemeMenuSection from '@core/components/ThemeMenuSection.vue'
|
||||||
import { useAdminTheme } from '@core/composable/useAdminTheme'
|
import { useAdminTheme } from '@core/composable/useAdminTheme'
|
||||||
|
import { useAdminChatService } from '@/utils/adminChat'
|
||||||
|
import { supportChatMenuEnabledSetting } from '@/utils/config'
|
||||||
const { prefixCls } = usePrefix('basic-header')
|
const { prefixCls } = usePrefix('basic-header')
|
||||||
const layoutStore = useLayoutStore()
|
const layoutStore = useLayoutStore()
|
||||||
const store = useStaticApiListsStore()
|
const store = useStaticApiListsStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const { menuTheme } = useAdminTheme()
|
const { menuTheme } = useAdminTheme()
|
||||||
|
const router = useRouter()
|
||||||
|
const { totalUnreadCount } = useAdminChatService()
|
||||||
|
|
||||||
|
const supportChatEnabled = computed(() => supportChatMenuEnabledSetting.value)
|
||||||
|
|
||||||
|
function goToSupport() {
|
||||||
|
router.push({ name: 'supportMain' })
|
||||||
|
}
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
//my info modal
|
//my info modal
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import BaseMenuItem from '@core/layouts/composnts/BaseMenuItem.vue'
|
|||||||
import type { RouteRecordName, RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordName, RouteRecordRaw } from 'vue-router'
|
||||||
import { useRouterStore } from '@core/stores/router'
|
import { useRouterStore } from '@core/stores/router'
|
||||||
import { useAdminTheme } from '@core/composable/useAdminTheme'
|
import { useAdminTheme } from '@core/composable/useAdminTheme'
|
||||||
|
import { supportChatMenuEnabledSetting } from '@/utils/config'
|
||||||
|
|
||||||
const { prefixCls } = usePrefix('basic-menu')
|
const { prefixCls } = usePrefix('basic-menu')
|
||||||
const layoutStore = useLayoutStore()
|
const layoutStore = useLayoutStore()
|
||||||
@@ -68,10 +69,18 @@ function walkInRoutes(routes: RouteRecordRaw[]) {
|
|||||||
walkInRoutes(routerStore.routes)
|
walkInRoutes(routerStore.routes)
|
||||||
selectedKeys.value = path
|
selectedKeys.value = path
|
||||||
|
|
||||||
|
function isSupportMenuRoute(item: RouteRecordRaw): boolean {
|
||||||
|
return item.name === 'support' || item.meta?.permissionsKey === 'support'
|
||||||
|
}
|
||||||
|
|
||||||
const menu: ComputedRef<RouteRecordRaw[]> = computed(() => {
|
const menu: ComputedRef<RouteRecordRaw[]> = computed(() => {
|
||||||
return sortBy(routerStore.routes, (item) => {
|
return sortBy(routerStore.routes, (item) => {
|
||||||
return item.meta?.order
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
<a-button
|
||||||
type="text"
|
type="text"
|
||||||
danger
|
danger
|
||||||
:disabled="disabled || rows.length <= 1"
|
:disabled="disabled"
|
||||||
aria-label="حذف"
|
aria-label="حذف"
|
||||||
@click="removeRow(index)"
|
@click="removeRow(index)"
|
||||||
>
|
>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<p v-if="rows.length === 0" class="support-messengers-field__hint">
|
<p v-if="rows.length === 0" class="support-messengers-field__hint">
|
||||||
حداقل یک پیامرسان با شناسه معتبر اضافه کنید.
|
لیست خالی است — در اپ کاربر اگر چت فعال باشد، پشتیبانی مستقیم به صفحه چت میرود.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -111,7 +111,7 @@ function addRow() {
|
|||||||
|
|
||||||
function removeRow(index: number) {
|
function removeRow(index: number) {
|
||||||
const next = rows.value.filter((_, i) => i !== index)
|
const next = rows.value.filter((_, i) => i !== index)
|
||||||
commit(next.length > 0 ? next : [{ messenger: 'bale', id: '' }])
|
commit(next)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ import SettingValueField from './SettingValueField.vue'
|
|||||||
import SettingCard from './SettingCard.vue'
|
import SettingCard from './SettingCard.vue'
|
||||||
import { refreshAppModeMenu } from '@/router'
|
import { refreshAppModeMenu } from '@/router'
|
||||||
import { useUserStore } from '@/store/userStore'
|
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 {
|
import {
|
||||||
EDITABLE_SETTING_GROUPS,
|
EDITABLE_SETTING_GROUPS,
|
||||||
SYSTEM_SETTING_GROUPS,
|
SYSTEM_SETTING_GROUPS,
|
||||||
@@ -426,12 +426,14 @@ async function initConfig() {
|
|||||||
applyPriceTriggerEnabledFromRows(baseData.value)
|
applyPriceTriggerEnabledFromRows(baseData.value)
|
||||||
applyAppNotificationEnabledFromRows(baseData.value)
|
applyAppNotificationEnabledFromRows(baseData.value)
|
||||||
applyPriceProviderFromRows(baseData.value)
|
applyPriceProviderFromRows(baseData.value)
|
||||||
|
applySupportChatMenuEnabledFromRows(baseData.value)
|
||||||
// در صفحهٔ عادی price_provider (can_edit=0) نیست — از کل تنظیمات بخوان
|
// در صفحهٔ عادی price_provider (can_edit=0) نیست — از کل تنظیمات بخوان
|
||||||
if (!baseData.value.some((row) => row.key === 'price_provider')) {
|
if (!baseData.value.some((row) => row.key === 'price_provider')) {
|
||||||
try {
|
try {
|
||||||
const { getConfiguration } = await import('@/api/configuration')
|
const { getConfiguration } = await import('@/api/configuration')
|
||||||
const all = await getConfiguration()
|
const all = await getConfiguration()
|
||||||
applyPriceProviderFromRows(Array.isArray(all) ? all : [])
|
applyPriceProviderFromRows(Array.isArray(all) ? all : [])
|
||||||
|
applySupportChatMenuEnabledFromRows(Array.isArray(all) ? all : [])
|
||||||
} catch {
|
} catch {
|
||||||
/* کش قبلی میماند */
|
/* کش قبلی میماند */
|
||||||
}
|
}
|
||||||
@@ -483,6 +485,16 @@ function syncMenuFlagsFromSetting(key: string | undefined, value: string) {
|
|||||||
}
|
}
|
||||||
if (key === 'has_page_notification') {
|
if (key === 'has_page_notification') {
|
||||||
setAppNotificationEnabledFromSettings(isSwitchOn(value))
|
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: 'دسترسی مشتری به قابلیتها',
|
title: 'دسترسی مشتری به قابلیتها',
|
||||||
icon: 'vuesax-linear:people',
|
icon: 'vuesax-linear:people',
|
||||||
match: (k) =>
|
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',
|
id: 'trading_hours',
|
||||||
@@ -134,10 +134,17 @@ export const SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [
|
|||||||
title: 'دسترسی مشتری به قابلیتها',
|
title: 'دسترسی مشتری به قابلیتها',
|
||||||
icon: 'vuesax-linear:people',
|
icon: 'vuesax-linear:people',
|
||||||
match: (k) =>
|
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
|
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',
|
id: 'general',
|
||||||
title: 'عمومی و برند',
|
title: 'عمومی و برند',
|
||||||
|
|||||||
@@ -76,29 +76,27 @@ export function parseSupportMessengers(value: string | undefined | null): Suppor
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** پارس برای UI — ردیفهای ناقص (بدون id) هم نگه داشته میشوند */
|
/** پارس برای UI — لیست میتواند خالی باشد (چت مستقیم) */
|
||||||
export function parseSupportMessengersDraft(
|
export function parseSupportMessengersDraft(
|
||||||
value: string | undefined | null,
|
value: string | undefined | null,
|
||||||
): SupportMessengerEntryDraft[] {
|
): SupportMessengerEntryDraft[] {
|
||||||
const raw = (value ?? '').trim()
|
const raw = (value ?? '').trim()
|
||||||
if (!raw) return [{ messenger: 'bale', id: '' }]
|
if (!raw) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = JSON.parse(raw)
|
const decoded = JSON.parse(raw)
|
||||||
if (!Array.isArray(decoded) || decoded.length === 0) {
|
if (!Array.isArray(decoded) || decoded.length === 0) {
|
||||||
return [{ messenger: 'bale', id: '' }]
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = decoded
|
return decoded
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
messenger: String(row?.messenger ?? '').trim().toLowerCase(),
|
messenger: String(row?.messenger ?? '').trim().toLowerCase(),
|
||||||
id: String(row?.id ?? '').trim(),
|
id: String(row?.id ?? '').trim(),
|
||||||
}))
|
}))
|
||||||
.filter((row) => isSupportMessengerType(row.messenger))
|
.filter((row) => isSupportMessengerType(row.messenger))
|
||||||
|
|
||||||
return rows.length > 0 ? rows : [{ messenger: 'bale', id: '' }]
|
|
||||||
} catch {
|
} 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_rial: 'number',
|
||||||
pargar_item_id_gold: 'number',
|
pargar_item_id_gold: 'number',
|
||||||
support_messengers: 'longtext',
|
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: 'longtext',
|
||||||
tracking_systems_enabled: 'switch',
|
tracking_systems_enabled: 'switch',
|
||||||
support_messenger_id: 'text',
|
support_messenger_id: 'text',
|
||||||
@@ -391,6 +395,13 @@ const SELECT_OPTIONS_BY_KEY: Record<string, string> = {
|
|||||||
/** سوییچهایی که مقدار required/خالی دارند (نه true/false) */
|
/** سوییچهایی که مقدار required/خالی دارند (نه true/false) */
|
||||||
export const REFERRAL_REQUIRED_KEY = 'referral_id'
|
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 {
|
export function isReferralRequired(value: string | undefined): boolean {
|
||||||
return (value ?? '').trim().toLowerCase() === 'required'
|
return (value ?? '').trim().toLowerCase() === 'required'
|
||||||
}
|
}
|
||||||
@@ -436,7 +447,7 @@ export function parseSelectOptions(
|
|||||||
|
|
||||||
export function isSwitchOn(value: string | undefined): boolean {
|
export function isSwitchOn(value: string | undefined): boolean {
|
||||||
const v = (value ?? '').trim().toLowerCase()
|
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 {
|
export function switchToString(checked: boolean): string {
|
||||||
@@ -447,6 +458,10 @@ export function resolveSwitchChecked(key: string | undefined, value: string | un
|
|||||||
if (key === REFERRAL_REQUIRED_KEY) {
|
if (key === REFERRAL_REQUIRED_KEY) {
|
||||||
return isReferralRequired(value)
|
return isReferralRequired(value)
|
||||||
}
|
}
|
||||||
|
if (isActivateDeactivateSwitch(key)) {
|
||||||
|
const v = (value ?? '').trim().toLowerCase()
|
||||||
|
return v === 'activate' || v === 'true' || v === '1' || v === 'yes'
|
||||||
|
}
|
||||||
return isSwitchOn(value)
|
return isSwitchOn(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,6 +469,9 @@ export function switchValueFromChecked(key: string | undefined, checked: boolean
|
|||||||
if (key === REFERRAL_REQUIRED_KEY) {
|
if (key === REFERRAL_REQUIRED_KEY) {
|
||||||
return referralRequiredToString(checked)
|
return referralRequiredToString(checked)
|
||||||
}
|
}
|
||||||
|
if (isActivateDeactivateSwitch(key)) {
|
||||||
|
return checked ? 'activate' : 'deactivate'
|
||||||
|
}
|
||||||
return switchToString(checked)
|
return switchToString(checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,7 +481,10 @@ export function formatValueForSave(
|
|||||||
key?: string
|
key?: string
|
||||||
): string {
|
): string {
|
||||||
if (type === 'switch') {
|
if (type === 'switch') {
|
||||||
return switchValueFromChecked(key, isSwitchOn(value) || (key === REFERRAL_REQUIRED_KEY && isReferralRequired(value)))
|
return switchValueFromChecked(
|
||||||
|
key,
|
||||||
|
resolveSwitchChecked(key, value)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (type === 'price') {
|
if (type === 'price') {
|
||||||
return value === '' ? '0' : String(value).replace(/,/g, '')
|
return value === '' ? '0' : String(value).replace(/,/g, '')
|
||||||
@@ -666,7 +687,19 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
|
|||||||
node_shared_secret:
|
node_shared_secret:
|
||||||
'کلید مشترک امضای درخواستها به سرویس سفارش خارجی. محرمانه نگه دارید.',
|
'کلید مشترک امضای درخواستها به سرویس سفارش خارجی. محرمانه نگه دارید.',
|
||||||
support_messengers:
|
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:
|
tracking_systems:
|
||||||
'لیست سایتهای ریگیری (name/url) — از منوی «سایتهای ریگیری» ویرایش کنید.',
|
'لیست سایتهای ریگیری (name/url) — از منوی «سایتهای ریگیری» ویرایش کنید.',
|
||||||
tracking_systems_enabled:
|
tracking_systems_enabled:
|
||||||
@@ -906,6 +939,12 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
|
|||||||
zarin_name_g: 'نام گروه حساب مشتریان زرین',
|
zarin_name_g: 'نام گروه حساب مشتریان زرین',
|
||||||
zarin_uid_prefix: 'پیشوند عددی uid زرین',
|
zarin_uid_prefix: 'پیشوند عددی uid زرین',
|
||||||
support_messengers: 'پیامرسانهای پشتیبانی',
|
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: 'سایتهای ریگیری',
|
||||||
tracking_systems_enabled: 'نمایش سایتهای ریگیری',
|
tracking_systems_enabled: 'نمایش سایتهای ریگیری',
|
||||||
permision__enabled: 'نمایش منوی مجوزها',
|
permision__enabled: 'نمایش منوی مجوزها',
|
||||||
@@ -974,7 +1013,8 @@ export const SETTING_LOCKED_KEYS = new Set([
|
|||||||
'unit',
|
'unit',
|
||||||
'up_price', 'down_price',
|
'up_price', 'down_price',
|
||||||
'wss_enabled',
|
'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 */
|
/** قابل ویرایش در پنل — همتراز 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',
|
'products_site_link_enabled', 'products_site_link_url', 'products_site_link_text',
|
||||||
'startDay',
|
'startDay',
|
||||||
'support_messengers', 'support_messenger_text',
|
'support_messengers', 'support_messenger_text',
|
||||||
|
'has_chat',
|
||||||
'deposit_support_message_text', 'deposit_account_holder', 'deposit_card_number', 'deposit_sheba',
|
'deposit_support_message_text', 'deposit_account_holder', 'deposit_card_number', 'deposit_sheba',
|
||||||
'text_about', 'text_rule',
|
'text_about', 'text_rule',
|
||||||
'zaarbox_price_key',
|
'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>
|
||||||
+36
-6
@@ -6,10 +6,30 @@ import { getConfigApp, isStoreApp } from '@/utils/config'
|
|||||||
import { isDeployAdmin } from '@/utils/deployAdmin'
|
import { isDeployAdmin } from '@/utils/deployAdmin'
|
||||||
import { hasSmsPanelAccess, isSmsBillingEnabled } from '@/utils/smsPanelAccess'
|
import { hasSmsPanelAccess, isSmsBillingEnabled } from '@/utils/smsPanelAccess'
|
||||||
import { useUserStore } from '@/store/userStore'
|
import { useUserStore } from '@/store/userStore'
|
||||||
import { cloneDeep } from 'lodash'
|
import { cloneDeep, get } from 'lodash'
|
||||||
|
import { isSettingFlagOn } from '@/utils/settingFlags'
|
||||||
//const _router = useRouter()
|
//const _router = useRouter()
|
||||||
//import { cloneDeep } from 'lodash'
|
//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) {
|
function applyAppModeMeta(route: RouteRecordRaw, appName: string | null) {
|
||||||
const store = isStoreApp()
|
const store = isStoreApp()
|
||||||
|
|
||||||
@@ -144,11 +164,11 @@ export function setupRoutes(
|
|||||||
|
|
||||||
function walkInRoutes(routes: RouteRecordRaw) {
|
function walkInRoutes(routes: RouteRecordRaw) {
|
||||||
if (!routes.children) return
|
if (!routes.children) return
|
||||||
routes.children = routes?.children?.filter(
|
routes.children = routes?.children?.filter((item) => {
|
||||||
(item) =>
|
const key = item.meta?.permissionsKey
|
||||||
!item.meta?.permissionsKey ||
|
if (key === 'support') return supportChatIsEnabled()
|
||||||
(item.meta?.permissionsKey && permissionsKeys.includes(item.meta?.permissionsKey))
|
return !key || permissionsKeys.includes(key)
|
||||||
)
|
})
|
||||||
for (const route of routes.children) {
|
for (const route of routes.children) {
|
||||||
walkInRoutes(route)
|
walkInRoutes(route)
|
||||||
}
|
}
|
||||||
@@ -162,11 +182,21 @@ export function setupRoutes(
|
|||||||
|
|
||||||
const route = cloneDeep(sourceRoute)
|
const route = cloneDeep(sourceRoute)
|
||||||
|
|
||||||
|
if (route.meta?.permissionsKey === 'support' && !supportChatIsEnabled()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (permissionsKeys.length && permissionsKeys[0] == '*') {
|
if (permissionsKeys.length && permissionsKeys[0] == '*') {
|
||||||
prepareRoute(route, appName, userMobile)
|
prepareRoute(route, appName, userMobile)
|
||||||
dynamicRoutes.push(route)
|
dynamicRoutes.push(route)
|
||||||
} else {
|
} else {
|
||||||
if (
|
if (
|
||||||
|
route.meta?.permissionsKey === 'support' && supportChatIsEnabled()
|
||||||
|
) {
|
||||||
|
walkInRoutes(route)
|
||||||
|
prepareRoute(route, appName, userMobile)
|
||||||
|
dynamicRoutes.push(route)
|
||||||
|
} else if (
|
||||||
!route?.meta?.permissionsKey ||
|
!route?.meta?.permissionsKey ||
|
||||||
(route.meta.permissionsKey && permissionsKeys.includes(route.meta.permissionsKey))
|
(route.meta.permissionsKey && permissionsKeys.includes(route.meta.permissionsKey))
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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: ComputedRef<string[]> = computed(() => {
|
||||||
const permissions: string[] = get(userInfo.value, 'permission', [])
|
const permissions: string[] = get(userInfo.value, 'permission', [])
|
||||||
const excludes: string[] = get(userInfo.value, 'exclude', [])
|
const excludes: string[] = get(userInfo.value, 'exclude', [])
|
||||||
@@ -39,6 +43,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
return {
|
return {
|
||||||
userInfo,
|
userInfo,
|
||||||
getMyInfo,
|
getMyInfo,
|
||||||
|
findSettingByKey,
|
||||||
permissions,
|
permissions,
|
||||||
canAccess,
|
canAccess,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+55
-1
@@ -55,6 +55,9 @@ export const appNotificationEnabledSetting = ref(true)
|
|||||||
/** reactive — منبع قیمت فعال (montzar / zaarbox / main_product) */
|
/** reactive — منبع قیمت فعال (montzar / zaarbox / main_product) */
|
||||||
export const priceProviderSetting = ref('')
|
export const priceProviderSetting = ref('')
|
||||||
|
|
||||||
|
/** reactive — منوی افقی / دکمه «چت پشتیبانی» */
|
||||||
|
export const supportChatMenuEnabledSetting = ref(false)
|
||||||
|
|
||||||
export const accountingProviderSetting = ref<AccountingProvider>('none')
|
export const accountingProviderSetting = ref<AccountingProvider>('none')
|
||||||
|
|
||||||
let tahesabEnabledFromSettings: boolean | null = null
|
let tahesabEnabledFromSettings: boolean | null = null
|
||||||
@@ -65,7 +68,7 @@ let accountingProviderFromSettings: AccountingProvider | null = null
|
|||||||
|
|
||||||
function isSwitchOn(value: string | undefined | null): boolean {
|
function isSwitchOn(value: string | undefined | null): boolean {
|
||||||
const v = (value ?? '').trim().toLowerCase()
|
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 {
|
function normalizeAccountingProvider(value: string | undefined | null): AccountingProvider {
|
||||||
@@ -146,6 +149,56 @@ export function setAppNotificationEnabledFromSettings(value: boolean): void {
|
|||||||
appNotificationEnabledSetting.value = value
|
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 {
|
export function setPriceProviderFromSettings(value?: string | null): void {
|
||||||
priceProviderSetting.value = (value ?? '').trim().toLowerCase()
|
priceProviderSetting.value = (value ?? '').trim().toLowerCase()
|
||||||
}
|
}
|
||||||
@@ -217,6 +270,7 @@ async function bootstrapSettingsFromApi(): Promise<void> {
|
|||||||
applyConditionalOrderEnabledFromRows(list)
|
applyConditionalOrderEnabledFromRows(list)
|
||||||
applyPriceTriggerEnabledFromRows(list)
|
applyPriceTriggerEnabledFromRows(list)
|
||||||
applyAppNotificationEnabledFromRows(list)
|
applyAppNotificationEnabledFromRows(list)
|
||||||
|
applySupportChatMenuEnabledFromRows(list)
|
||||||
applyPriceProviderFromRows(list)
|
applyPriceProviderFromRows(list)
|
||||||
setCachedMainMetal(mainMetalRow?.value)
|
setCachedMainMetal(mainMetalRow?.value)
|
||||||
const { setCachedMainWalletProductIds } = await import('@/components/mobile/utils/wallet')
|
const { setCachedMainWalletProductIds } = await import('@/components/mobile/utils/wallet')
|
||||||
|
|||||||
+24
-1
@@ -23,4 +23,27 @@ export function removeToken() {
|
|||||||
}
|
}
|
||||||
export function hotSelect(e: any) {
|
export function hotSelect(e: any) {
|
||||||
e.target?.select()
|
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)}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -1029,10 +1029,10 @@
|
|||||||
resolved "https://mirror-npm.runflare.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
|
resolved "https://mirror-npm.runflare.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
|
||||||
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
|
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
|
||||||
|
|
||||||
"@esbuild/win32-x64@0.18.20":
|
"@esbuild/linux-x64@0.18.20":
|
||||||
version "0.18.20"
|
version "0.18.20"
|
||||||
resolved "https://mirror-npm.runflare.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz"
|
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz"
|
||||||
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
|
integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==
|
||||||
|
|
||||||
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
|
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
|
||||||
version "4.4.0"
|
version "4.4.0"
|
||||||
@@ -5497,6 +5497,11 @@ watchpack@^2.5.1:
|
|||||||
glob-to-regexp "^0.4.1"
|
glob-to-regexp "^0.4.1"
|
||||||
graceful-fs "^4.1.2"
|
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:
|
webidl-conversions@^4.0.2:
|
||||||
version "4.0.2"
|
version "4.0.2"
|
||||||
resolved "https://mirror-npm.runflare.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz"
|
resolved "https://mirror-npm.runflare.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user