change sms option

This commit is contained in:
2026-07-19 01:11:37 +03:30
parent a2f3e320d5
commit b446c21afc
2 changed files with 238 additions and 54 deletions
+74 -16
View File
@@ -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')