change sms option
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import type { Customer } from '@/model/customer'
|
||||
import useAxios from '@core/utils/useAxios'
|
||||
|
||||
export const sendCustomerDirectSms = async (payload: {
|
||||
@@ -6,23 +5,82 @@ export const sendCustomerDirectSms = async (payload: {
|
||||
message: string
|
||||
}) => useAxios.post<{ ok: boolean }>('/admin/messages/send_customer', payload)
|
||||
|
||||
export type DirectSmsSnippet = {
|
||||
key: string
|
||||
label: string
|
||||
insert: string
|
||||
export const DIRECT_SMS_MAX_LENGTH = 700
|
||||
|
||||
export type DirectSmsTemplateContext = {
|
||||
appUrl: string
|
||||
appName: string
|
||||
}
|
||||
|
||||
/** دکمههای آماده برای درج در متن پیامک */
|
||||
export function buildDirectSmsSnippets(customer?: Customer | null, appName = '', appUrl = ''): DirectSmsSnippet[] {
|
||||
const name = (customer?.name ?? '').trim() || 'مشتری'
|
||||
return [
|
||||
{ key: 'name', label: 'نام مشتری', insert: name },
|
||||
{ key: 'app_name', label: 'نام اپ', insert: appName || 'اپ' },
|
||||
{ key: 'app_url', label: 'لینک اپ', insert: appUrl || '' },
|
||||
{ key: 'name_ph', label: '%نام%', insert: '%نام%' },
|
||||
{ key: 'app_name_ph', label: '%نام_اپ%', insert: '%نام_اپ%' },
|
||||
{ key: 'app_url_ph', label: '%لینک_اپ%', insert: '%لینک_اپ%' }
|
||||
].filter((s) => s.insert !== '')
|
||||
export type DirectSmsTemplate = {
|
||||
key: string
|
||||
label: string
|
||||
/** بدنهٔ پیام (بدون نام و نام اپ — آنها خودکار اضافه میشوند) */
|
||||
body: (ctx: DirectSmsTemplateContext) => string
|
||||
/** اگر true باشد و لینک اپ خالی باشد، قالب غیرفعال است */
|
||||
requiresAppUrl?: boolean
|
||||
}
|
||||
|
||||
/** قالبهای آماده — همیشه با «نام عزیز» شروع و با «نام اپ» تمام میشوند */
|
||||
export const DIRECT_SMS_TEMPLATES: DirectSmsTemplate[] = [
|
||||
{
|
||||
key: 'app_login_link',
|
||||
label: 'لینک ورود به اپ',
|
||||
requiresAppUrl: true,
|
||||
body: ({ appUrl }) =>
|
||||
['برای ورود به اپلیکیشن از لینک زیر استفاده کنید:', appUrl].join('\n')
|
||||
},
|
||||
{
|
||||
key: 'invite_app',
|
||||
label: 'دعوت به استفاده از اپ',
|
||||
requiresAppUrl: true,
|
||||
body: ({ appUrl }) =>
|
||||
[
|
||||
'از شما دعوت میکنیم وارد اپلیکیشن شوید و از خدمات خرید و فروش طلا بهرهمند شوید.',
|
||||
'لینک ورود:',
|
||||
appUrl
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
key: 'complete_kyc',
|
||||
label: 'تکمیل احراز هویت',
|
||||
body: () => 'لطفاً برای ادامه فعالیت، احراز هویت خود را در اپلیکیشن تکمیل کنید.'
|
||||
},
|
||||
{
|
||||
key: 'charge_wallet',
|
||||
label: 'یادآوری شارژ حساب',
|
||||
body: () => 'برای شارژ حساب و ادامه معاملات، از بخش کیف پول در اپلیکیشن اقدام کنید.'
|
||||
},
|
||||
{
|
||||
key: 'welcome',
|
||||
label: 'خوشآمدگویی',
|
||||
body: () => 'به جمع کاربران ما خوش آمدید. امیدواریم تجربهٔ خوبی از خدمات ما داشته باشید.'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* فرمت اجباری همهٔ پیامکها:
|
||||
* خط ۱: «نام عزیز»
|
||||
* خطهای میانی: متن
|
||||
* خط آخر: نام اپ
|
||||
*/
|
||||
export function formatDirectSms(params: {
|
||||
customerName: string
|
||||
body: string
|
||||
appName: string
|
||||
}): string {
|
||||
const name = (params.customerName ?? '').trim() || 'مشتری'
|
||||
const app = (params.appName ?? '').trim() || 'اپ'
|
||||
const body = (params.body ?? '').trim()
|
||||
if (!body) return `${name} عزیز\n${app}`
|
||||
return `${name} عزیز\n${body}\n${app}`
|
||||
}
|
||||
|
||||
export function resolveTemplateBody(
|
||||
template: DirectSmsTemplate,
|
||||
ctx: DirectSmsTemplateContext
|
||||
): string {
|
||||
return template.body(ctx).trim()
|
||||
}
|
||||
|
||||
const BLOCKED = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
v-model:visible="visibleProxy"
|
||||
:width="isMobile ? '94vw' : '36rem'"
|
||||
:width="isMobile ? '94vw' : '40rem'"
|
||||
:title="modalTitle"
|
||||
ok-text="ارسال پیامک"
|
||||
:loading="loading"
|
||||
@@ -15,31 +15,96 @@
|
||||
<strong dir="ltr" class="text-primary">{{ customer?.mobile || '—' }}</strong>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a-button
|
||||
v-for="snippet in snippets"
|
||||
:key="snippet.key"
|
||||
size="small"
|
||||
@click="appendSnippet(snippet.insert)"
|
||||
>
|
||||
{{ snippet.label }}
|
||||
</a-button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<a-textarea
|
||||
v-model:value="message"
|
||||
:rows="6"
|
||||
:maxlength="700"
|
||||
show-count
|
||||
placeholder="متن پیامک را بنویسید…"
|
||||
class="customer-sms-modal__textarea"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<p v-if="profanityHit" class="m-0 text-xs text-red-500">
|
||||
متن حاوی عبارت نامناسب است — قبل از ارسال اصلاح کنید.
|
||||
</p>
|
||||
<p class="m-0 text-xs" style="color: var(--admin-text-secondary)">
|
||||
متغیرها در سرور جایگزین میشوند: %نام% · %نام_اپ% · %لینک_اپ%
|
||||
<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">
|
||||
نام اپ در تنظیمات خالی است؛ در پایان پیام از «اپ» استفاده میشود.
|
||||
</p>
|
||||
</div>
|
||||
</BasicModal>
|
||||
@@ -52,9 +117,13 @@ import type { Customer } from '@/model/customer'
|
||||
import { isMobile } from '@core/utils/is'
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import {
|
||||
buildDirectSmsSnippets,
|
||||
DIRECT_SMS_MAX_LENGTH,
|
||||
DIRECT_SMS_TEMPLATES,
|
||||
findProfanity,
|
||||
sendCustomerDirectSms
|
||||
formatDirectSms,
|
||||
resolveTemplateBody,
|
||||
sendCustomerDirectSms,
|
||||
type DirectSmsTemplate
|
||||
} from '@/api/customerDirectSms'
|
||||
import { getConfiguration } from '@/api/configuration'
|
||||
|
||||
@@ -74,29 +143,89 @@ const visibleProxy = computed({
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const message = ref('')
|
||||
const activeTab = ref<'templates' | 'manual'>('templates')
|
||||
const selectedTemplateKey = ref(DIRECT_SMS_TEMPLATES[0]?.key ?? '')
|
||||
const manualBody = ref('')
|
||||
const appName = ref('')
|
||||
const appUrl = ref('')
|
||||
|
||||
const templates = DIRECT_SMS_TEMPLATES
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
props.customer?.name ? `پیامک به ${props.customer.name}` : 'ارسال پیامک به مشتری'
|
||||
)
|
||||
|
||||
const snippets = computed(() =>
|
||||
buildDirectSmsSnippets(props.customer, appName.value, appUrl.value)
|
||||
const customerDisplayName = computed(
|
||||
() => (props.customer?.name ?? '').trim() || 'مشتری'
|
||||
)
|
||||
|
||||
const profanityHit = computed(() => findProfanity(message.value))
|
||||
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))
|
||||
|
||||
const canSubmit = computed(
|
||||
() => message.value.trim().length > 0 && !profanityHit.value && !loading.value
|
||||
() =>
|
||||
previewMessage.value.trim().length > 0 &&
|
||||
Boolean(bodyText.value) &&
|
||||
!profanityHit.value &&
|
||||
!previewOverLimit.value &&
|
||||
!loading.value
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (open) => {
|
||||
if (!open) return
|
||||
message.value = ''
|
||||
activeTab.value = 'templates'
|
||||
manualBody.value = ''
|
||||
selectedTemplateKey.value = DIRECT_SMS_TEMPLATES[0]?.key ?? ''
|
||||
try {
|
||||
const rows = await getConfiguration()
|
||||
const list = Array.isArray(rows) ? rows : []
|
||||
@@ -106,23 +235,20 @@ watch(
|
||||
appName.value = ''
|
||||
appUrl.value = ''
|
||||
}
|
||||
// اگر قالب اول به لینک نیاز دارد و لینک نیست، اولین قالب قابلاستفاده را انتخاب کن
|
||||
const firstOk = templates.find((t) => !isTemplateDisabled(t))
|
||||
if (firstOk) selectedTemplateKey.value = firstOk.key
|
||||
}
|
||||
)
|
||||
|
||||
function appendSnippet(text: string) {
|
||||
if (!text) return
|
||||
const current = message.value
|
||||
const needsSpace = current.length > 0 && !/\s$/.test(current)
|
||||
message.value = current + (needsSpace ? ' ' : '') + text
|
||||
}
|
||||
|
||||
function close() {
|
||||
visibleProxy.value = false
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!props.customer?.id || !canSubmit.value) return
|
||||
const hit = findProfanity(message.value)
|
||||
const finalMessage = previewMessage.value.trim()
|
||||
const hit = findProfanity(finalMessage)
|
||||
if (hit) {
|
||||
antMessage.error('متن حاوی محتوای نامناسب است')
|
||||
return
|
||||
@@ -131,7 +257,7 @@ async function submit() {
|
||||
loading.value = true
|
||||
await sendCustomerDirectSms({
|
||||
customer_id: Number(props.customer.id),
|
||||
message: message.value.trim()
|
||||
message: finalMessage
|
||||
})
|
||||
antMessage.success('پیامک ارسال شد')
|
||||
emit('sent')
|
||||
|
||||
Reference in New Issue
Block a user