2026-07-18 22:22:21 +03:30
|
|
|
|
<template>
|
|
|
|
|
|
<BasicModal
|
|
|
|
|
|
v-model:visible="visibleProxy"
|
2026-07-19 01:11:37 +03:30
|
|
|
|
:width="isMobile ? '94vw' : '40rem'"
|
2026-07-18 22:22:21 +03:30
|
|
|
|
:title="modalTitle"
|
|
|
|
|
|
ok-text="ارسال پیامک"
|
|
|
|
|
|
:loading="loading"
|
|
|
|
|
|
:ok-button-props="{ disabled: !canSubmit }"
|
|
|
|
|
|
@ok="submit"
|
|
|
|
|
|
@close="close"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="customer-sms-modal flex flex-col gap-3">
|
|
|
|
|
|
<p class="m-0 text-xs" style="color: var(--admin-text-secondary)">
|
|
|
|
|
|
گیرنده:
|
|
|
|
|
|
<strong dir="ltr" class="text-primary">{{ customer?.mobile || '—' }}</strong>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
2026-07-19 01:11:37 +03:30
|
|
|
|
<a-tabs v-model:activeKey="activeTab" size="small" class="customer-sms-modal__tabs">
|
|
|
|
|
|
<a-tab-pane key="templates" tab="پیامهای پیشفرض">
|
|
|
|
|
|
<div class="flex flex-col gap-3 pt-1">
|
|
|
|
|
|
<a-radio-group v-model:value="selectedTemplateKey" class="flex flex-col gap-2">
|
|
|
|
|
|
<a-radio
|
|
|
|
|
|
v-for="tpl in templates"
|
|
|
|
|
|
:key="tpl.key"
|
|
|
|
|
|
:value="tpl.key"
|
|
|
|
|
|
:disabled="isTemplateDisabled(tpl)"
|
|
|
|
|
|
class="!ms-0 whitespace-normal"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{{ tpl.label }}</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="isTemplateDisabled(tpl)"
|
|
|
|
|
|
class="ms-1 text-xs"
|
|
|
|
|
|
style="color: var(--admin-text-secondary)"
|
|
|
|
|
|
>
|
|
|
|
|
|
(لینک اپ تنظیم نشده)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</a-radio>
|
|
|
|
|
|
</a-radio-group>
|
2026-07-18 22:22:21 +03:30
|
|
|
|
|
2026-07-19 01:11:37 +03:30
|
|
|
|
<p
|
|
|
|
|
|
v-if="!appUrl && needsAppUrlHint"
|
|
|
|
|
|
class="m-0 text-xs text-amber-600"
|
|
|
|
|
|
>
|
|
|
|
|
|
برای قالبهای دارای لینک، مقدار «لینک اپ» را در تنظیمات پر کنید.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-tab-pane>
|
|
|
|
|
|
|
|
|
|
|
|
<a-tab-pane key="manual" tab="نوشتن دستی">
|
|
|
|
|
|
<div class="flex flex-col gap-2 pt-1">
|
|
|
|
|
|
<p class="m-0 text-xs" style="color: var(--admin-text-secondary)">
|
|
|
|
|
|
فقط متن میانی را بنویسید. نام مشتری و نام اپ بهصورت خودکار اضافه میشوند.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="rounded border px-3 py-2 text-sm"
|
|
|
|
|
|
style="border-color: var(--admin-border, #434343); color: var(--admin-text-secondary)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ customerDisplayName }} عزیز
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<a-textarea
|
|
|
|
|
|
v-model:value="manualBody"
|
|
|
|
|
|
:rows="5"
|
|
|
|
|
|
:maxlength="manualMaxLength"
|
|
|
|
|
|
placeholder="متن پیامک را بنویسید…"
|
|
|
|
|
|
class="customer-sms-modal__textarea"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="rounded border px-3 py-2 text-sm"
|
|
|
|
|
|
style="border-color: var(--admin-border, #434343); color: var(--admin-text-secondary)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ appDisplayName }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-tab-pane>
|
|
|
|
|
|
</a-tabs>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="customer-sms-modal__preview flex flex-col gap-1.5">
|
|
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
|
|
|
|
<span class="text-xs font-medium">پیشنمایش پیامک ارسالی</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-xs tabular-nums"
|
|
|
|
|
|
:class="previewOverLimit ? 'text-red-500' : ''"
|
|
|
|
|
|
style="color: var(--admin-text-secondary)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ previewLength }} / {{ DIRECT_SMS_MAX_LENGTH }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="customer-sms-modal__preview-box whitespace-pre-wrap break-words rounded border px-3 py-2.5 text-sm leading-relaxed"
|
|
|
|
|
|
style="border-color: var(--admin-border, #434343); background: rgba(255, 255, 255, 0.03); min-height: 5.5rem"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template v-if="previewMessage">{{ previewMessage }}</template>
|
|
|
|
|
|
<span v-else style="color: var(--admin-text-secondary)">متن پیامک اینجا نمایش داده میشود…</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="m-0 text-xs" style="color: var(--admin-text-secondary)">
|
|
|
|
|
|
همین متن دقیقاً برای گیرنده ارسال میشود. قبل از ارسال پیشنمایش را بررسی کنید.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-07-18 22:22:21 +03:30
|
|
|
|
|
|
|
|
|
|
<p v-if="profanityHit" class="m-0 text-xs text-red-500">
|
|
|
|
|
|
متن حاوی عبارت نامناسب است — قبل از ارسال اصلاح کنید.
|
|
|
|
|
|
</p>
|
2026-07-19 01:11:37 +03:30
|
|
|
|
<p v-if="previewOverLimit" class="m-0 text-xs text-red-500">
|
|
|
|
|
|
طول پیامک بیش از {{ DIRECT_SMS_MAX_LENGTH }} کاراکتر است.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p v-if="!appName" class="m-0 text-xs text-amber-600">
|
|
|
|
|
|
نام اپ در تنظیمات خالی است؛ در پایان پیام از «اپ» استفاده میشود.
|
2026-07-18 22:22:21 +03:30
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</BasicModal>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
|
|
import { message as antMessage } from 'ant-design-vue'
|
|
|
|
|
|
import type { Customer } from '@/model/customer'
|
|
|
|
|
|
import { isMobile } from '@core/utils/is'
|
|
|
|
|
|
import BasicModal from '@/components/BasicModal.vue'
|
|
|
|
|
|
import {
|
2026-07-19 01:11:37 +03:30
|
|
|
|
DIRECT_SMS_MAX_LENGTH,
|
|
|
|
|
|
DIRECT_SMS_TEMPLATES,
|
2026-07-18 22:22:21 +03:30
|
|
|
|
findProfanity,
|
2026-07-19 01:11:37 +03:30
|
|
|
|
formatDirectSms,
|
|
|
|
|
|
resolveTemplateBody,
|
|
|
|
|
|
sendCustomerDirectSms,
|
|
|
|
|
|
type DirectSmsTemplate
|
2026-07-18 22:22:21 +03:30
|
|
|
|
} from '@/api/customerDirectSms'
|
|
|
|
|
|
import { getConfiguration } from '@/api/configuration'
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
visible: boolean
|
|
|
|
|
|
customer: Customer | null
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
(e: 'update:visible', value: boolean): void
|
|
|
|
|
|
(e: 'sent'): void
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const visibleProxy = computed({
|
|
|
|
|
|
get: () => props.visible,
|
|
|
|
|
|
set: (v: boolean) => emit('update:visible', v)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const loading = ref(false)
|
2026-07-19 01:11:37 +03:30
|
|
|
|
const activeTab = ref<'templates' | 'manual'>('templates')
|
|
|
|
|
|
const selectedTemplateKey = ref(DIRECT_SMS_TEMPLATES[0]?.key ?? '')
|
|
|
|
|
|
const manualBody = ref('')
|
2026-07-18 22:22:21 +03:30
|
|
|
|
const appName = ref('')
|
|
|
|
|
|
const appUrl = ref('')
|
|
|
|
|
|
|
2026-07-19 01:11:37 +03:30
|
|
|
|
const templates = DIRECT_SMS_TEMPLATES
|
|
|
|
|
|
|
2026-07-18 22:22:21 +03:30
|
|
|
|
const modalTitle = computed(() =>
|
|
|
|
|
|
props.customer?.name ? `پیامک به ${props.customer.name}` : 'ارسال پیامک به مشتری'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-19 01:11:37 +03:30
|
|
|
|
const customerDisplayName = computed(
|
|
|
|
|
|
() => (props.customer?.name ?? '').trim() || 'مشتری'
|
2026-07-18 22:22:21 +03:30
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-19 01:11:37 +03:30
|
|
|
|
const appDisplayName = computed(() => appName.value.trim() || 'اپ')
|
|
|
|
|
|
|
|
|
|
|
|
const templateCtx = computed(() => ({
|
|
|
|
|
|
appUrl: appUrl.value.trim(),
|
|
|
|
|
|
appName: appDisplayName.value
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
function isTemplateDisabled(tpl: DirectSmsTemplate): boolean {
|
|
|
|
|
|
return Boolean(tpl.requiresAppUrl && !appUrl.value.trim())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const needsAppUrlHint = computed(() =>
|
|
|
|
|
|
templates.some((t) => t.requiresAppUrl)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const selectedTemplate = computed(
|
|
|
|
|
|
() => templates.find((t) => t.key === selectedTemplateKey.value) ?? null
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const bodyText = computed(() => {
|
|
|
|
|
|
if (activeTab.value === 'manual') return manualBody.value.trim()
|
|
|
|
|
|
const tpl = selectedTemplate.value
|
|
|
|
|
|
if (!tpl || isTemplateDisabled(tpl)) return ''
|
|
|
|
|
|
return resolveTemplateBody(tpl, templateCtx.value)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const previewMessage = computed(() => {
|
|
|
|
|
|
if (!bodyText.value) return ''
|
|
|
|
|
|
return formatDirectSms({
|
|
|
|
|
|
customerName: customerDisplayName.value,
|
|
|
|
|
|
body: bodyText.value,
|
|
|
|
|
|
appName: appDisplayName.value
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const previewLength = computed(() => previewMessage.value.length)
|
|
|
|
|
|
|
|
|
|
|
|
const previewOverLimit = computed(() => previewLength.value > DIRECT_SMS_MAX_LENGTH)
|
|
|
|
|
|
|
|
|
|
|
|
/** سقف تقریبی برای بدنهٔ دستی تا کل پیام از ۷۰۰ رد نشود */
|
|
|
|
|
|
const manualMaxLength = computed(() => {
|
|
|
|
|
|
const wrapperLen = formatDirectSms({
|
|
|
|
|
|
customerName: customerDisplayName.value,
|
|
|
|
|
|
body: '',
|
|
|
|
|
|
appName: appDisplayName.value
|
|
|
|
|
|
}).length
|
|
|
|
|
|
return Math.max(50, DIRECT_SMS_MAX_LENGTH - wrapperLen - 1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const profanityHit = computed(() => findProfanity(previewMessage.value))
|
2026-07-18 22:22:21 +03:30
|
|
|
|
|
|
|
|
|
|
const canSubmit = computed(
|
2026-07-19 01:11:37 +03:30
|
|
|
|
() =>
|
|
|
|
|
|
previewMessage.value.trim().length > 0 &&
|
|
|
|
|
|
Boolean(bodyText.value) &&
|
|
|
|
|
|
!profanityHit.value &&
|
|
|
|
|
|
!previewOverLimit.value &&
|
|
|
|
|
|
!loading.value
|
2026-07-18 22:22:21 +03:30
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => props.visible,
|
|
|
|
|
|
async (open) => {
|
|
|
|
|
|
if (!open) return
|
2026-07-19 01:11:37 +03:30
|
|
|
|
activeTab.value = 'templates'
|
|
|
|
|
|
manualBody.value = ''
|
|
|
|
|
|
selectedTemplateKey.value = DIRECT_SMS_TEMPLATES[0]?.key ?? ''
|
2026-07-18 22:22:21 +03:30
|
|
|
|
try {
|
|
|
|
|
|
const rows = await getConfiguration()
|
|
|
|
|
|
const list = Array.isArray(rows) ? rows : []
|
|
|
|
|
|
appName.value = String(list.find((r) => r.key === 'name_fa')?.value ?? '').trim()
|
|
|
|
|
|
appUrl.value = String(list.find((r) => r.key === 'app_url')?.value ?? '').trim()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
appName.value = ''
|
|
|
|
|
|
appUrl.value = ''
|
|
|
|
|
|
}
|
2026-07-19 01:11:37 +03:30
|
|
|
|
// اگر قالب اول به لینک نیاز دارد و لینک نیست، اولین قالب قابلاستفاده را انتخاب کن
|
|
|
|
|
|
const firstOk = templates.find((t) => !isTemplateDisabled(t))
|
|
|
|
|
|
if (firstOk) selectedTemplateKey.value = firstOk.key
|
2026-07-18 22:22:21 +03:30
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
function close() {
|
|
|
|
|
|
visibleProxy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submit() {
|
|
|
|
|
|
if (!props.customer?.id || !canSubmit.value) return
|
2026-07-19 01:11:37 +03:30
|
|
|
|
const finalMessage = previewMessage.value.trim()
|
|
|
|
|
|
const hit = findProfanity(finalMessage)
|
2026-07-18 22:22:21 +03:30
|
|
|
|
if (hit) {
|
|
|
|
|
|
antMessage.error('متن حاوی محتوای نامناسب است')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
await sendCustomerDirectSms({
|
|
|
|
|
|
customer_id: Number(props.customer.id),
|
2026-07-19 01:11:37 +03:30
|
|
|
|
message: finalMessage
|
2026-07-18 22:22:21 +03:30
|
|
|
|
})
|
|
|
|
|
|
antMessage.success('پیامک ارسال شد')
|
|
|
|
|
|
emit('sent')
|
|
|
|
|
|
close()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|