diff --git a/components.d.ts b/components.d.ts index 50acf2e..8394b59 100644 --- a/components.d.ts +++ b/components.d.ts @@ -29,6 +29,7 @@ declare module '@vue/runtime-core' { AEmpty: typeof import('ant-design-vue/es')['Empty'] AForm: typeof import('ant-design-vue/es')['Form'] AFormItem: typeof import('ant-design-vue/es')['FormItem'] + AImage: typeof import('ant-design-vue/es')['Image'] AInput: typeof import('ant-design-vue/es')['Input'] AInputNumber: typeof import('ant-design-vue/es')['InputNumber'] AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] diff --git a/package-lock.json b/package-lock.json index 5cf99ce..2749af2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "vue-echarts": "^6.6.8", "vue-router": "^4.1.6", "vue3-apexcharts": "^1.4.4", - "vue3-otp-input": "^0.4.1" + "vue3-otp-input": "^0.4.1", + "wavesurfer.js": "^7.12.11" }, "devDependencies": { "@rushstack/eslint-patch": "^1.2.0", @@ -10641,6 +10642,12 @@ "node": ">=10.13.0" } }, + "node_modules/wavesurfer.js": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.11.tgz", + "integrity": "sha512-Sx0yZIz7jRJ9J9p7UL+pl9y0UQBB6UN/XNo+R7Gy4tCR5xZI9jMyA0dnt1R8TMVSQ4LZ10SeB2HJYQ+dV2dPSA==", + "license": "BSD-3-Clause" + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://mirror-npm.runflare.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/package.json b/package.json index ba79ab5..1119769 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "vue-echarts": "^6.6.8", "vue-router": "^4.1.6", "vue3-apexcharts": "^1.4.4", - "vue3-otp-input": "^0.4.1" + "vue3-otp-input": "^0.4.1", + "wavesurfer.js": "^7.12.11" }, "devDependencies": { "@rushstack/eslint-patch": "^1.2.0", diff --git a/src/App.vue b/src/App.vue index cbcbacb..c5c70ec 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,39 +2,67 @@ - diff --git a/src/components/BasicMobileBottomNav.vue b/src/components/BasicMobileBottomNav.vue index 21343b9..565f140 100644 --- a/src/components/BasicMobileBottomNav.vue +++ b/src/components/BasicMobileBottomNav.vue @@ -29,6 +29,7 @@ import BasicMobileOverflowMenu from '@/components/BasicMobileOverflowMenu.vue' import { sortBy } from 'lodash' import { computed } from 'vue' import type { RouteRecordName, RouteRecordRaw } from 'vue-router' +import { supportChatMenuEnabledSetting } from '@/utils/config' const BOTTOM_NAV_PRIORITY: (string | symbol)[] = ['dashboard', 'customers', 'orders', 'transactions'] @@ -38,7 +39,13 @@ const routerStore = useRouterStore() const allMenuItems = computed(() => sortBy( - routerStore.routes.filter((item) => !item.meta?.hideMenu), + routerStore.routes.filter((item) => { + if (item.meta?.hideMenu) return false + if (item.name === 'support' || item.meta?.permissionsKey === 'support') { + return supportChatMenuEnabledSetting.value + } + return true + }), (item) => item.meta?.order ) ) diff --git a/src/core/assets/icons/icon.json b/src/core/assets/icons/icon.json index e1050d9..10f97fd 100644 --- a/src/core/assets/icons/icon.json +++ b/src/core/assets/icons/icon.json @@ -1017,6 +1017,7 @@ "vuesax-linear:youtube": "\n\n\n\n", "vuesax-linear:zel-(zel)": "\n\n\n\n\n\n", "vuesax-linear:zoom": "\n\n\n\n\n", - "vuesax-linear:verify": "\n\n\n\n", - "vuesax-linear:chart-fail": "\n\n\n\n\n\n\n" + "vuesax-linear:chart-fail": "\n\n\n\n\n\n\n", + "custom:single-check": "", + "custom:double-check": "" } \ No newline at end of file diff --git a/src/core/layouts/BasicHeader.vue b/src/core/layouts/BasicHeader.vue index 6dfe78c..212fe06 100644 --- a/src/core/layouts/BasicHeader.vue +++ b/src/core/layouts/BasicHeader.vue @@ -20,6 +20,23 @@
+ @@ -158,19 +175,29 @@ import { inputRules, inputLenRules } from '@/utils/inputRules' import type { User } from '@/model/user' import { createUser, updateMyInformation, updateUser } from '@/api/user' import { message } from 'ant-design-vue/es' -import { onMounted, ref } from 'vue' +import { onMounted, ref, computed } from 'vue' import type { FormInstance } from 'ant-design-vue' import { cloneDeep } from 'lodash' import { useWindowSize } from '@vueuse/core' import {getConfigApp} from "@/utils/config"; import ThemeMenuSection from '@core/components/ThemeMenuSection.vue' import { useAdminTheme } from '@core/composable/useAdminTheme' +import { useAdminChatService } from '@/utils/adminChat' +import { supportChatMenuEnabledSetting } from '@/utils/config' const { prefixCls } = usePrefix('basic-header') const layoutStore = useLayoutStore() const store = useStaticApiListsStore() const userStore = useUserStore() const settingsStore = useSettingsStore() const { menuTheme } = useAdminTheme() +const router = useRouter() +const { totalUnreadCount } = useAdminChatService() + +const supportChatEnabled = computed(() => supportChatMenuEnabledSetting.value) + +function goToSupport() { + router.push({ name: 'supportMain' }) +} const { width } = useWindowSize() //my info modal diff --git a/src/core/layouts/composnts/BasicMenu.vue b/src/core/layouts/composnts/BasicMenu.vue index b645f78..17b1430 100644 --- a/src/core/layouts/composnts/BasicMenu.vue +++ b/src/core/layouts/composnts/BasicMenu.vue @@ -36,6 +36,7 @@ import BaseMenuItem from '@core/layouts/composnts/BaseMenuItem.vue' import type { RouteRecordName, RouteRecordRaw } from 'vue-router' import { useRouterStore } from '@core/stores/router' import { useAdminTheme } from '@core/composable/useAdminTheme' +import { supportChatMenuEnabledSetting } from '@/utils/config' const { prefixCls } = usePrefix('basic-menu') const layoutStore = useLayoutStore() @@ -68,10 +69,18 @@ function walkInRoutes(routes: RouteRecordRaw[]) { walkInRoutes(routerStore.routes) selectedKeys.value = path +function isSupportMenuRoute(item: RouteRecordRaw): boolean { + return item.name === 'support' || item.meta?.permissionsKey === 'support' +} + const menu: ComputedRef = computed(() => { return sortBy(routerStore.routes, (item) => { return item.meta?.order - }).filter((item) => !item.meta?.hideMenu) + }).filter((item) => { + if (item.meta?.hideMenu) return false + if (isSupportMenuRoute(item)) return supportChatMenuEnabledSetting.value + return true + }) }) diff --git a/src/model/support.ts b/src/model/support.ts new file mode 100644 index 0000000..089d159 --- /dev/null +++ b/src/model/support.ts @@ -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 +} diff --git a/src/pages/menuPages/configuration/SupportMessengersField.vue b/src/pages/menuPages/configuration/SupportMessengersField.vue index c96e652..1ba3c82 100644 --- a/src/pages/menuPages/configuration/SupportMessengersField.vue +++ b/src/pages/menuPages/configuration/SupportMessengersField.vue @@ -30,7 +30,7 @@ @@ -49,7 +49,7 @@

- حداقل یک پیام‌رسان با شناسه معتبر اضافه کنید. + لیست خالی است — در اپ کاربر اگر چت فعال باشد، پشتیبانی مستقیم به صفحه چت می‌رود.

@@ -111,7 +111,7 @@ function addRow() { function removeRow(index: number) { const next = rows.value.filter((_, i) => i !== index) - commit(next.length > 0 ? next : [{ messenger: 'bale', id: '' }]) + commit(next) } diff --git a/src/pages/menuPages/configuration/index.vue b/src/pages/menuPages/configuration/index.vue index 27f33cf..2bd22fa 100644 --- a/src/pages/menuPages/configuration/index.vue +++ b/src/pages/menuPages/configuration/index.vue @@ -174,7 +174,7 @@ import SettingValueField from './SettingValueField.vue' import SettingCard from './SettingCard.vue' import { refreshAppModeMenu } from '@/router' import { useUserStore } from '@/store/userStore' -import { applyBannerEnabledFromRows, applyTrackingSystemsEnabledFromRows, applyConditionalOrderEnabledFromRows, applyPriceTriggerEnabledFromRows, applyAppNotificationEnabledFromRows, applyPriceProviderFromRows, setBannerEnabledFromSettings, setConditionalOrderEnabledFromSettings, setPriceTriggerEnabledFromSettings, setAppNotificationEnabledFromSettings, setPriceProviderFromSettings, setStoreModeFromSettings, setTrackingSystemsEnabledFromSettings } from '@/utils/config' +import { applyBannerEnabledFromRows, applyTrackingSystemsEnabledFromRows, applyConditionalOrderEnabledFromRows, applyPriceTriggerEnabledFromRows, applyAppNotificationEnabledFromRows, applyPriceProviderFromRows, applySupportChatMenuEnabledFromRows, setBannerEnabledFromSettings, setConditionalOrderEnabledFromSettings, setPriceTriggerEnabledFromSettings, setAppNotificationEnabledFromSettings, setPriceProviderFromSettings, setStoreModeFromSettings, setTrackingSystemsEnabledFromSettings } from '@/utils/config' import { EDITABLE_SETTING_GROUPS, SYSTEM_SETTING_GROUPS, @@ -426,12 +426,14 @@ async function initConfig() { applyPriceTriggerEnabledFromRows(baseData.value) applyAppNotificationEnabledFromRows(baseData.value) applyPriceProviderFromRows(baseData.value) + applySupportChatMenuEnabledFromRows(baseData.value) // در صفحهٔ عادی price_provider (can_edit=0) نیست — از کل تنظیمات بخوان if (!baseData.value.some((row) => row.key === 'price_provider')) { try { const { getConfiguration } = await import('@/api/configuration') const all = await getConfiguration() applyPriceProviderFromRows(Array.isArray(all) ? all : []) + applySupportChatMenuEnabledFromRows(Array.isArray(all) ? all : []) } catch { /* کش قبلی می‌ماند */ } @@ -483,6 +485,16 @@ function syncMenuFlagsFromSetting(key: string | undefined, value: string) { } if (key === 'has_page_notification') { setAppNotificationEnabledFromSettings(isSwitchOn(value)) + return + } + if (key === 'show_to_users_chat' || key === 'has_chat' || key === 'chat_api') { + applySupportChatMenuEnabledFromRows([{ key, value }]) + // همگام با settings داخل userInfo تا گیت روت/هدر بعد از رفرش هم درست باشد + const list = userStore.userInfo?.settings + if (Array.isArray(list)) { + const row = list.find((s: any) => s?.key === key) + if (row) row.value = value + } } } diff --git a/src/pages/menuPages/configuration/settingGroups.ts b/src/pages/menuPages/configuration/settingGroups.ts index d65a0c4..ac62b31 100644 --- a/src/pages/menuPages/configuration/settingGroups.ts +++ b/src/pages/menuPages/configuration/settingGroups.ts @@ -21,7 +21,7 @@ export const EDITABLE_SETTING_GROUPS: SettingGroupDef[] = [ title: 'دسترسی مشتری به قابلیت‌ها', icon: 'vuesax-linear:people', match: (k) => - /^(banner_enabled|tracking_systems_enabled|jibit_verification|register_jibit_verify|show_product_details|show_product_trade_status|show_product_card_meta|products_site_link_enabled|products_site_link_url|products_site_link_text)$/.test(k) + /^(banner_enabled|tracking_systems_enabled|jibit_verification|register_jibit_verify|show_product_details|show_product_trade_status|show_product_card_meta|products_site_link_enabled|products_site_link_url|products_site_link_text|has_chat)$/.test(k) }, { id: 'trading_hours', @@ -134,10 +134,17 @@ export const SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [ title: 'دسترسی مشتری به قابلیت‌ها', icon: 'vuesax-linear:people', match: (k) => - /^(enable_conditional_order|has_page_price_trigger|has_page_notification|banner_enabled|jibit_enabled|customer_direct_sms|has_page_earn_money|group_sms|tracking_systems_enabled|permision__enabled)$/.test( + /^(enable_conditional_order|has_page_price_trigger|has_page_notification|banner_enabled|jibit_enabled|customer_direct_sms|has_page_earn_money|group_sms|tracking_systems_enabled|permision__enabled|show_to_users_chat)$/.test( k ) }, + { + id: 'support_chat', + title: 'چت پشتیبانی', + icon: 'vuesax-linear:messages-2', + match: (k) => + /^(chat_api|chat_api_key|chat_upload_url|chat_download_url)$/.test(k) + }, { id: 'general', title: 'عمومی و برند', diff --git a/src/pages/menuPages/configuration/settingTypes.ts b/src/pages/menuPages/configuration/settingTypes.ts index 7dd4da6..a937c60 100644 --- a/src/pages/menuPages/configuration/settingTypes.ts +++ b/src/pages/menuPages/configuration/settingTypes.ts @@ -76,29 +76,27 @@ export function parseSupportMessengers(value: string | undefined | null): Suppor ) } -/** پارس برای UI — ردیف‌های ناقص (بدون id) هم نگه داشته می‌شوند */ +/** پارس برای UI — لیست می‌تواند خالی باشد (چت مستقیم) */ export function parseSupportMessengersDraft( value: string | undefined | null, ): SupportMessengerEntryDraft[] { const raw = (value ?? '').trim() - if (!raw) return [{ messenger: 'bale', id: '' }] + if (!raw) return [] try { const decoded = JSON.parse(raw) if (!Array.isArray(decoded) || decoded.length === 0) { - return [{ messenger: 'bale', id: '' }] + return [] } - const rows = decoded + return decoded .map((row) => ({ messenger: String(row?.messenger ?? '').trim().toLowerCase(), id: String(row?.id ?? '').trim(), })) .filter((row) => isSupportMessengerType(row.messenger)) - - return rows.length > 0 ? rows : [{ messenger: 'bale', id: '' }] } catch { - return [{ messenger: 'bale', id: '' }] + return [] } } @@ -328,6 +326,12 @@ const KEY_TYPE_FALLBACK: Record = { pargar_item_id_rial: 'number', pargar_item_id_gold: 'number', support_messengers: 'longtext', + show_to_users_chat: 'switch', + has_chat: 'switch', + chat_api: 'text', + chat_api_key: 'text', + chat_upload_url: 'text', + chat_download_url: 'text', tracking_systems: 'longtext', tracking_systems_enabled: 'switch', support_messenger_id: 'text', @@ -391,6 +395,13 @@ const SELECT_OPTIONS_BY_KEY: Record = { /** سوییچ‌هایی که مقدار required/خالی دارند (نه true/false) */ export const REFERRAL_REQUIRED_KEY = 'referral_id' +/** سوییچ‌هایی که مقدار activate/deactivate دارند (نه true/false) */ +export const ACTIVATE_DEACTIVATE_SWITCH_KEYS = new Set(['show_to_users_chat']) + +export function isActivateDeactivateSwitch(key: string | undefined): boolean { + return Boolean(key && ACTIVATE_DEACTIVATE_SWITCH_KEYS.has(key)) +} + export function isReferralRequired(value: string | undefined): boolean { return (value ?? '').trim().toLowerCase() === 'required' } @@ -436,7 +447,7 @@ export function parseSelectOptions( export function isSwitchOn(value: string | undefined): boolean { const v = (value ?? '').trim().toLowerCase() - return v === 'true' || v === '1' || v === 'yes' + return v === 'true' || v === '1' || v === 'yes' || v === 'activate' } export function switchToString(checked: boolean): string { @@ -447,6 +458,10 @@ export function resolveSwitchChecked(key: string | undefined, value: string | un if (key === REFERRAL_REQUIRED_KEY) { return isReferralRequired(value) } + if (isActivateDeactivateSwitch(key)) { + const v = (value ?? '').trim().toLowerCase() + return v === 'activate' || v === 'true' || v === '1' || v === 'yes' + } return isSwitchOn(value) } @@ -454,6 +469,9 @@ export function switchValueFromChecked(key: string | undefined, checked: boolean if (key === REFERRAL_REQUIRED_KEY) { return referralRequiredToString(checked) } + if (isActivateDeactivateSwitch(key)) { + return checked ? 'activate' : 'deactivate' + } return switchToString(checked) } @@ -463,7 +481,10 @@ export function formatValueForSave( key?: string ): string { if (type === 'switch') { - return switchValueFromChecked(key, isSwitchOn(value) || (key === REFERRAL_REQUIRED_KEY && isReferralRequired(value))) + return switchValueFromChecked( + key, + resolveSwitchChecked(key, value) + ) } if (type === 'price') { return value === '' ? '0' : String(value).replace(/,/g, '') @@ -666,7 +687,19 @@ const KEY_HELP_FALLBACK: Record = { node_shared_secret: 'کلید مشترک امضای درخواست‌ها به سرویس سفارش خارجی. محرمانه نگه دارید.', support_messengers: - 'فقط مواردی که در این لیست ثبت شوند در منوی شناور پشتیبانی اپ مشتری نمایش داده می‌شوند. برای تماس تلفنی یا پیامک، نوع «تماس تلفنی» / «پیامک» را با شماره اضافه کنید.', + 'فقط مواردی که در این لیست ثبت شوند در منوی شناور پشتیبانی اپ مشتری نمایش داده می‌شوند. برای تماس تلفنی یا پیامک، نوع «تماس تلفنی» / «پیامک» را با شماره اضافه کنید. لیست می‌تواند خالی باشد؛ اگر چت فعال باشد، پشتیبانی مستقیم به صفحه چت می‌رود.', + show_to_users_chat: + 'روشن: قابلیت چت پشتیبانی برای این دیپلوی فعال است (منوی ادمین و پیش‌نیاز نمایش به کاربر). خاموش: چت در ادمین و اپ کاربر مخفی است. سوییچ مشتری (has_chat) جداگانه در تنظیمات عادی است.', + has_chat: + 'روشن: چت پشتیبانی در اپ مشتری نمایش داده می‌شود (اگر نمایش چت به کاربران هم روشن باشد). خاموش: دکمه و صفحه چت برای کاربر مخفی است.', + chat_api: + 'هاست سرور چت جدا (بدون https)، مثلاً chat.example.ir — برای WebSocket و ساخت روم.', + chat_api_key: + 'کلید API سرور چت جدا.', + chat_upload_url: + 'URL کامل آپلود مدیا برای چت پشتیبانی.', + chat_download_url: + 'پایهٔ URL دانلود/نمایش فایل‌های چت پشتیبانی.', tracking_systems: 'لیست سایت‌های ری‌گیری (name/url) — از منوی «سایت‌های ری‌گیری» ویرایش کنید.', tracking_systems_enabled: @@ -906,6 +939,12 @@ export const KEY_LABEL_FALLBACK: Record = { zarin_name_g: 'نام گروه حساب مشتریان زرین', zarin_uid_prefix: 'پیشوند عددی uid زرین', support_messengers: 'پیام‌رسان‌های پشتیبانی', + show_to_users_chat: 'نمایش چت به کاربران', + has_chat: 'چت پشتیبانی', + chat_api: 'API چت', + chat_api_key: 'کلید API چت', + chat_upload_url: 'آدرس آپلود فایل چت', + chat_download_url: 'آدرس دانلود فایل چت', tracking_systems: 'سایت‌های ری‌گیری', tracking_systems_enabled: 'نمایش سایت‌های ری‌گیری', permision__enabled: 'نمایش منوی مجوزها', @@ -974,7 +1013,8 @@ export const SETTING_LOCKED_KEYS = new Set([ 'unit', 'up_price', 'down_price', 'wss_enabled', - 'zaarbox_api_url' + 'zaarbox_api_url', + 'show_to_users_chat', 'chat_api', 'chat_api_key', 'chat_upload_url', 'chat_download_url' ]) /** قابل ویرایش در پنل — هم‌تراز deploy_settings_const.sql */ @@ -1008,6 +1048,7 @@ export const SETTING_EDITABLE_KEYS = new Set([ 'products_site_link_enabled', 'products_site_link_url', 'products_site_link_text', 'startDay', 'support_messengers', 'support_messenger_text', + 'has_chat', 'deposit_support_message_text', 'deposit_account_holder', 'deposit_card_number', 'deposit_sheba', 'text_about', 'text_rule', 'zaarbox_price_key', diff --git a/src/pages/support/components/AdminSupportComposer.vue b/src/pages/support/components/AdminSupportComposer.vue new file mode 100644 index 0000000..7d9072b --- /dev/null +++ b/src/pages/support/components/AdminSupportComposer.vue @@ -0,0 +1,1002 @@ +