2026-08-24 09:48:27 +03:30
|
|
|
|
<template>
|
|
|
|
|
|
<a-form
|
|
|
|
|
|
ref="formRef"
|
|
|
|
|
|
:model="form"
|
|
|
|
|
|
layout="vertical"
|
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
class="auto-editor"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="auto-editor__grid">
|
|
|
|
|
|
<div class="auto-editor__col">
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="مخاطب" :help="automationAudienceHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<a-radio-group
|
|
|
|
|
|
:value="audienceMode"
|
|
|
|
|
|
option-type="button"
|
|
|
|
|
|
button-style="solid"
|
|
|
|
|
|
@update:value="setAudienceMode"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-radio-button value="all">همه کاربران</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="filter">با شرط</a-radio-button>
|
|
|
|
|
|
</a-radio-group>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-if="audienceMode === 'filter'">
|
|
|
|
|
|
<div class="auto-editor__conds">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(row, idx) in form.conditions"
|
|
|
|
|
|
:key="idx"
|
|
|
|
|
|
class="auto-editor__cond"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div v-if="idx > 0" class="auto-editor__join">
|
|
|
|
|
|
<a-radio-group
|
|
|
|
|
|
:value="row.cr || 'and'"
|
|
|
|
|
|
option-type="button"
|
|
|
|
|
|
button-style="solid"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
@update:value="(v) => (row.cr = v)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-radio-button value="and">و</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="or">یا</a-radio-button>
|
|
|
|
|
|
</a-radio-group>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="auto-editor__cond-row">
|
|
|
|
|
|
<BasicSelect
|
|
|
|
|
|
class="auto-editor__cond-select"
|
|
|
|
|
|
:value="row.field"
|
|
|
|
|
|
:items="fieldItems"
|
|
|
|
|
|
key-name="key"
|
|
|
|
|
|
value-name="name"
|
|
|
|
|
|
placeholder="نوع شرط"
|
|
|
|
|
|
:has-tooltip="false"
|
|
|
|
|
|
:dropdown-match-select-width="false"
|
|
|
|
|
|
:dropdown-style="wideDropdownStyle"
|
|
|
|
|
|
@update:value="(v) => onFieldChange(row, v)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<template v-for="inp in inputsOf(row.field)" :key="inp.key">
|
|
|
|
|
|
<BasicSelect
|
|
|
|
|
|
v-if="inp.type === 'operator'"
|
|
|
|
|
|
class="auto-editor__cond-select"
|
|
|
|
|
|
:value="row.op || 'eq'"
|
|
|
|
|
|
:items="catalog?.operators || []"
|
|
|
|
|
|
key-name="key"
|
|
|
|
|
|
value-name="name"
|
|
|
|
|
|
:placeholder="inp.label"
|
|
|
|
|
|
:has-tooltip="false"
|
|
|
|
|
|
@update:value="(v) => (row.op = v)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<BasicSelect
|
|
|
|
|
|
v-else-if="inp.type === 'select'"
|
|
|
|
|
|
class="auto-editor__cond-select"
|
|
|
|
|
|
:value="row.value"
|
|
|
|
|
|
:items="inp.options || []"
|
|
|
|
|
|
key-name="key"
|
|
|
|
|
|
value-name="name"
|
|
|
|
|
|
:placeholder="inp.label"
|
|
|
|
|
|
:has-tooltip="false"
|
|
|
|
|
|
@update:value="(v) => (row.value = v)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<BasicInput
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="auto-editor__cond-input"
|
|
|
|
|
|
:value="(row.value as any)"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
:place-holder="inp.label"
|
|
|
|
|
|
@update:value="(v) => (row.value = v)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BaseButton danger size="small" @click="removeCondition(idx)">حذف</BaseButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<BaseButton size="small" type="dashed" @click="addCondition">افزودن شرط</BaseButton>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="auto-editor__hint">مخاطب تقریبی: {{ previewText }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="auto-editor__col">
|
|
|
|
|
|
<div class="auto-editor__pair">
|
|
|
|
|
|
<a-form-item name="name" :rules="inputRules()">
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="نام" :help="automationNameHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BasicInput v-model:value="form.name" placeholder="نام اتوماسیون" />
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item name="title" :rules="inputRules()">
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="عنوان پیام" :help="automationTitleHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BasicInput v-model:value="form.title" placeholder="عنوان پیامک / اعلان" />
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<a-form-item name="body" :rules="inputRules()" class="auto-editor__body">
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="متن" :help="automationBodyHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<a-textarea
|
|
|
|
|
|
ref="bodyAreaRef"
|
|
|
|
|
|
v-model:value="form.body"
|
|
|
|
|
|
class="auto-editor__textarea"
|
|
|
|
|
|
:rows="4"
|
|
|
|
|
|
placeholder="متن پیام را بنویسید…"
|
|
|
|
|
|
@blur="rememberBodyCursor"
|
|
|
|
|
|
@click="rememberBodyCursor"
|
|
|
|
|
|
@keyup="rememberBodyCursor"
|
|
|
|
|
|
@select="rememberBodyCursor"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="auto-editor__vars">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="v in bodyVars"
|
|
|
|
|
|
:key="v.token"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="auto-editor__var-chip"
|
|
|
|
|
|
:title="v.help"
|
|
|
|
|
|
@mousedown.prevent="insertBodyToken(v.token)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="auto-editor__var-token">%{{ v.token }}</span>
|
|
|
|
|
|
<span>{{ v.label }}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<a-form-item name="trigger_type" :rules="inputRules()">
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="نوع اجرا" :help="automationTriggerTypeHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<a-radio-group
|
|
|
|
|
|
v-model:value="form.trigger_type"
|
|
|
|
|
|
option-type="button"
|
|
|
|
|
|
button-style="solid"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-radio-button
|
|
|
|
|
|
v-for="item in catalog?.triggers || []"
|
|
|
|
|
|
:key="String(item.key)"
|
|
|
|
|
|
:value="item.key"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item.name }}
|
|
|
|
|
|
</a-radio-button>
|
|
|
|
|
|
</a-radio-group>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<a-form-item v-if="form.trigger_type === 'schedule'">
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="هر چند روز یکبار" :help="automationEveryDaysHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BasicInput
|
|
|
|
|
|
v-model:value="form.trigger_config.every_days"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
placeholder="مثلاً ۲۰"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-if="form.trigger_type === 'event'">
|
|
|
|
|
|
<div class="auto-editor__pair">
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="رویداد" :help="automationEventHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BasicSelect
|
|
|
|
|
|
v-model:value="form.trigger_config.event"
|
|
|
|
|
|
:items="catalog?.events || []"
|
|
|
|
|
|
key-name="key"
|
|
|
|
|
|
value-name="name"
|
|
|
|
|
|
placeholder="انتخاب رویداد"
|
|
|
|
|
|
:has-tooltip="false"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="زمان ارسال" :help="automationEventTimingHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<a-radio-group
|
|
|
|
|
|
:value="eventTiming"
|
|
|
|
|
|
option-type="button"
|
|
|
|
|
|
button-style="solid"
|
|
|
|
|
|
@update:value="setEventTiming"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-radio-button value="immediate">سر ضرب</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="delayed">با تأخیر</a-radio-button>
|
|
|
|
|
|
</a-radio-group>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<a-form-item v-if="eventTiming === 'delayed'">
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="تأخیر (ساعت)" :help="automationDelayHoursHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BasicInput
|
|
|
|
|
|
v-model:value="form.trigger_config.delay_hours"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
placeholder="۲۴"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-if="form.trigger_type === 'price'">
|
|
|
|
|
|
<div class="auto-editor__pair">
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="بازه" :help="automationPriceWindowHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<a-radio-group
|
|
|
|
|
|
v-model:value="form.trigger_config.price_window"
|
|
|
|
|
|
option-type="button"
|
|
|
|
|
|
button-style="solid"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-radio-button value="calendar_day">در یک روز</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="from_last">از آخرین ارسال</a-radio-button>
|
|
|
|
|
|
</a-radio-group>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="جهت" :help="automationPriceDirectionHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<a-radio-group
|
|
|
|
|
|
v-model:value="form.trigger_config.direction"
|
|
|
|
|
|
option-type="button"
|
|
|
|
|
|
button-style="solid"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-radio-button value="up">بالا</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="down">پایین</a-radio-button>
|
|
|
|
|
|
<a-radio-button value="both">هر دو</a-radio-button>
|
|
|
|
|
|
</a-radio-group>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="auto-editor__pair">
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="رقم تغییر (تومان)" :help="automationPriceStepHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BasicInput
|
|
|
|
|
|
v-model:value="form.trigger_config.step_toman"
|
|
|
|
|
|
type="price"
|
|
|
|
|
|
addon-before="تومان"
|
|
|
|
|
|
placeholder="۱۰۰۰۰۰"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="محصول" :help="automationPriceProductHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BasicSelect
|
|
|
|
|
|
v-model:value="form.trigger_config.product_id"
|
|
|
|
|
|
:items="productItems"
|
|
|
|
|
|
key-name="id"
|
|
|
|
|
|
value-name="name"
|
|
|
|
|
|
placeholder="خالی = قیمت تابلو"
|
|
|
|
|
|
allow-clear
|
|
|
|
|
|
:has-tooltip="false"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="auto-editor__pair">
|
|
|
|
|
|
<a-form-item v-if="form.trigger_type !== 'schedule'">
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp
|
|
|
|
|
|
label="فاصلهٔ تکرار برای هر نفر (ساعت)"
|
|
|
|
|
|
:help="automationCooldownHelp"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BasicInput v-model:value="form.cooldown_hours" type="number" placeholder="۲۴" />
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="حداکثر ارسال به هر نفر" :help="automationMaxSendsHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BasicInput v-model:value="form.max_sends" type="number" placeholder="خالی = نامحدود" />
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="auto-editor__pair">
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="پیامک" :help="automationSmsHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<div class="auto-editor__channel">
|
|
|
|
|
|
<a-switch :checked="hasSms" @change="(v) => toggleChannel('sms', !!v)" />
|
2026-08-24 22:13:06 +03:30
|
|
|
|
<a-time-picker
|
2026-08-24 09:48:27 +03:30
|
|
|
|
v-if="hasSms && showChannelHours"
|
2026-08-24 22:13:06 +03:30
|
|
|
|
class="auto-editor__hour-picker"
|
|
|
|
|
|
:value="smsHourValue"
|
|
|
|
|
|
format="HH:mm"
|
|
|
|
|
|
:minute-step="1"
|
|
|
|
|
|
:show-second="false"
|
2026-08-24 09:48:27 +03:30
|
|
|
|
placeholder="ساعت"
|
2026-08-24 22:13:06 +03:30
|
|
|
|
allow-clear
|
|
|
|
|
|
@update:value="setSmsTime"
|
2026-08-24 09:48:27 +03:30
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="اعلان داخل اپ" :help="automationInAppHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<div class="auto-editor__channel">
|
|
|
|
|
|
<a-switch :checked="hasInApp" @change="(v) => toggleChannel('in_app', !!v)" />
|
2026-08-24 22:13:06 +03:30
|
|
|
|
<a-time-picker
|
2026-08-24 09:48:27 +03:30
|
|
|
|
v-if="hasInApp && showChannelHours"
|
2026-08-24 22:13:06 +03:30
|
|
|
|
class="auto-editor__hour-picker"
|
|
|
|
|
|
:value="inAppHourValue"
|
|
|
|
|
|
format="HH:mm"
|
|
|
|
|
|
:minute-step="1"
|
|
|
|
|
|
:show-second="false"
|
2026-08-24 09:48:27 +03:30
|
|
|
|
placeholder="ساعت"
|
2026-08-24 22:13:06 +03:30
|
|
|
|
allow-clear
|
|
|
|
|
|
@update:value="setInAppTime"
|
2026-08-24 09:48:27 +03:30
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="auto-editor__pair">
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="شروع از" :help="automationStartsAtHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BaseDatePicker
|
|
|
|
|
|
v-model:value="form.starts_at"
|
|
|
|
|
|
show-time
|
|
|
|
|
|
format="YYYY-MM-DD HH:mm:ss"
|
|
|
|
|
|
class="w-full"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="پایان در" :help="automationEndsAtHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<BaseDatePicker
|
|
|
|
|
|
v-model:value="form.ends_at"
|
|
|
|
|
|
show-time
|
|
|
|
|
|
format="YYYY-MM-DD HH:mm:ss"
|
|
|
|
|
|
class="w-full"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<a-form-item>
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<AdminLabelWithHelp label="وضعیت" :help="automationStatusHelp" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<a-switch
|
|
|
|
|
|
v-model:checked="form.is_active"
|
|
|
|
|
|
checked-children="فعال"
|
|
|
|
|
|
un-checked-children="غیرفعال"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-form>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
|
|
|
|
import type { FormInstance } from 'ant-design-vue'
|
2026-08-24 22:13:06 +03:30
|
|
|
|
import dayjs, { type Dayjs } from 'dayjs'
|
|
|
|
|
|
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
2026-08-24 09:48:27 +03:30
|
|
|
|
import { cloneDeep, debounce, isEmpty } from 'lodash'
|
2026-08-24 22:13:06 +03:30
|
|
|
|
|
|
|
|
|
|
dayjs.extend(customParseFormat)
|
2026-08-24 09:48:27 +03:30
|
|
|
|
import { inputRules } from '@/utils/inputRules'
|
|
|
|
|
|
import { getAutomationCatalog, previewAutomationAudience } from '@/api/automations'
|
|
|
|
|
|
import BaseDatePicker from '@core/components/BaseDatePicker'
|
|
|
|
|
|
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
automationAudienceHelp,
|
|
|
|
|
|
automationBodyHelp,
|
|
|
|
|
|
automationCooldownHelp,
|
|
|
|
|
|
automationDelayHoursHelp,
|
|
|
|
|
|
automationEndsAtHelp,
|
|
|
|
|
|
automationEventHelp,
|
|
|
|
|
|
automationEventTimingHelp,
|
|
|
|
|
|
automationEveryDaysHelp,
|
|
|
|
|
|
automationInAppHelp,
|
|
|
|
|
|
automationMaxSendsHelp,
|
|
|
|
|
|
automationNameHelp,
|
|
|
|
|
|
automationPriceDirectionHelp,
|
|
|
|
|
|
automationPriceProductHelp,
|
|
|
|
|
|
automationPriceStepHelp,
|
|
|
|
|
|
automationPriceWindowHelp,
|
|
|
|
|
|
automationSmsHelp,
|
|
|
|
|
|
automationStartsAtHelp,
|
|
|
|
|
|
automationStatusHelp,
|
|
|
|
|
|
automationTitleHelp,
|
|
|
|
|
|
automationTriggerTypeHelp,
|
|
|
|
|
|
} from '@/utils/adminHelpCopy'
|
|
|
|
|
|
import type {
|
|
|
|
|
|
Automation,
|
|
|
|
|
|
AutomationCatalog,
|
|
|
|
|
|
AutomationChannel,
|
|
|
|
|
|
AutomationCondition,
|
|
|
|
|
|
AutomationField,
|
|
|
|
|
|
} from '@/model/automations'
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
form: Automation
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const formRef = ref<FormInstance>()
|
|
|
|
|
|
const catalog = ref<AutomationCatalog | null>(null)
|
|
|
|
|
|
const previewCount = ref<number | null>(null)
|
|
|
|
|
|
const bodyAreaRef = ref<{ $el?: HTMLElement } | null>(null)
|
|
|
|
|
|
let bodyCursorStart = 0
|
|
|
|
|
|
let bodyCursorEnd = 0
|
|
|
|
|
|
|
|
|
|
|
|
const bodyVars = [
|
|
|
|
|
|
{ token: 'نام', label: 'نام کاربر', help: 'نام کامل مشتری' },
|
|
|
|
|
|
{ token: 'نام_کوچک', label: 'نام کوچک', help: 'نام کوچک مشتری' },
|
|
|
|
|
|
{ token: 'موبایل', label: 'موبایل', help: 'شماره موبایل مشتری' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
function getBodyTextarea(): HTMLTextAreaElement | null {
|
|
|
|
|
|
const root = bodyAreaRef.value?.$el
|
|
|
|
|
|
if (!root) return null
|
|
|
|
|
|
if (root instanceof HTMLTextAreaElement) return root
|
|
|
|
|
|
return root.querySelector?.('textarea') ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rememberBodyCursor() {
|
|
|
|
|
|
const ta = getBodyTextarea()
|
|
|
|
|
|
if (!ta) return
|
|
|
|
|
|
bodyCursorStart = ta.selectionStart ?? 0
|
|
|
|
|
|
bodyCursorEnd = ta.selectionEnd ?? bodyCursorStart
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function insertBodyToken(token: string) {
|
|
|
|
|
|
rememberBodyCursor()
|
|
|
|
|
|
const insert = `%${token}`
|
|
|
|
|
|
const text = props.form.body ?? ''
|
|
|
|
|
|
const start = bodyCursorStart
|
|
|
|
|
|
const end = bodyCursorEnd
|
|
|
|
|
|
props.form.body = text.slice(0, start) + insert + text.slice(end)
|
|
|
|
|
|
const nextPos = start + insert.length
|
|
|
|
|
|
bodyCursorStart = nextPos
|
|
|
|
|
|
bodyCursorEnd = nextPos
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
const ta = getBodyTextarea()
|
|
|
|
|
|
if (!ta) return
|
|
|
|
|
|
ta.focus()
|
|
|
|
|
|
ta.setSelectionRange(nextPos, nextPos)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fieldItems = computed(() =>
|
|
|
|
|
|
(catalog.value?.fields || []).map((f) => ({
|
|
|
|
|
|
key: f.key,
|
|
|
|
|
|
name: `${f.group} › ${f.name}`,
|
|
|
|
|
|
}))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const productItems = computed(() => catalog.value?.products || [])
|
|
|
|
|
|
|
|
|
|
|
|
const wideDropdownStyle = {
|
|
|
|
|
|
minWidth: '22rem',
|
|
|
|
|
|
maxWidth: 'min(40rem, 94vw)',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const eventTiming = computed<'immediate' | 'delayed'>(() =>
|
|
|
|
|
|
Number(props.form.trigger_config?.delay_hours ?? 0) > 0 ? 'delayed' : 'immediate'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const audienceMode = computed<'all' | 'filter'>(() =>
|
|
|
|
|
|
props.form.audience_mode === 'filter' || (props.form.conditions || []).some((r) => r.field)
|
|
|
|
|
|
? 'filter'
|
|
|
|
|
|
: 'all'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const showChannelHours = computed(() => {
|
|
|
|
|
|
if (props.form.trigger_type === 'event') return eventTiming.value === 'delayed'
|
|
|
|
|
|
if (props.form.trigger_type === 'price') {
|
|
|
|
|
|
return (props.form.trigger_config?.price_window || 'calendar_day') === 'from_last'
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const previewText = computed(() => {
|
|
|
|
|
|
if (previewCount.value == null) return '—'
|
|
|
|
|
|
return `${previewCount.value.toLocaleString('fa-IR')} نفر`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const hasSms = computed(() => (props.form.channels || []).includes('sms'))
|
|
|
|
|
|
const hasInApp = computed(() => (props.form.channels || []).includes('in_app'))
|
|
|
|
|
|
|
2026-08-24 22:13:06 +03:30
|
|
|
|
function timeToPicker(hour?: number | null, minute?: number | null): Dayjs | undefined {
|
|
|
|
|
|
if (hour == null || Number.isNaN(Number(hour))) return undefined
|
|
|
|
|
|
return dayjs()
|
|
|
|
|
|
.hour(Number(hour))
|
|
|
|
|
|
.minute(Number(minute ?? 0))
|
|
|
|
|
|
.second(0)
|
|
|
|
|
|
.millisecond(0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pickerToTime(val: Dayjs | string | null | undefined): { hour: number | null; minute: number | null } {
|
|
|
|
|
|
if (val == null || val === '') return { hour: null, minute: null }
|
|
|
|
|
|
const parsed = dayjs.isDayjs(val) ? val : dayjs(String(val), 'HH:mm', true)
|
|
|
|
|
|
if (!parsed.isValid()) return { hour: null, minute: null }
|
|
|
|
|
|
return { hour: parsed.hour(), minute: parsed.minute() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const smsHourValue = computed(() => timeToPicker(props.form.sms_send_hour, props.form.sms_send_minute))
|
|
|
|
|
|
const inAppHourValue = computed(() => timeToPicker(props.form.in_app_send_hour, props.form.in_app_send_minute))
|
|
|
|
|
|
|
|
|
|
|
|
function setSmsTime(val: Dayjs | string | null) {
|
|
|
|
|
|
const time = pickerToTime(val)
|
|
|
|
|
|
props.form.sms_send_hour = time.hour
|
|
|
|
|
|
props.form.sms_send_minute = time.hour == null ? null : time.minute
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setInAppTime(val: Dayjs | string | null) {
|
|
|
|
|
|
const time = pickerToTime(val)
|
|
|
|
|
|
props.form.in_app_send_hour = time.hour
|
|
|
|
|
|
props.form.in_app_send_minute = time.hour == null ? null : time.minute
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 09:48:27 +03:30
|
|
|
|
function ensureTriggerConfig() {
|
|
|
|
|
|
if (!props.form.trigger_config) {
|
|
|
|
|
|
props.form.trigger_config = {
|
|
|
|
|
|
delay_hours: 0,
|
|
|
|
|
|
every_days: 1,
|
|
|
|
|
|
direction: 'both',
|
|
|
|
|
|
price_window: 'calendar_day',
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setAudienceMode(mode: 'all' | 'filter') {
|
|
|
|
|
|
props.form.audience_mode = mode
|
|
|
|
|
|
if (mode === 'all') {
|
|
|
|
|
|
props.form.conditions = []
|
|
|
|
|
|
} else if (!props.form.conditions?.length) {
|
|
|
|
|
|
addCondition()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setEventTiming(mode: 'immediate' | 'delayed') {
|
|
|
|
|
|
ensureTriggerConfig()
|
|
|
|
|
|
if (mode === 'immediate') {
|
|
|
|
|
|
props.form.trigger_config!.delay_hours = 0
|
|
|
|
|
|
} else if (!(Number(props.form.trigger_config!.delay_hours) > 0)) {
|
|
|
|
|
|
props.form.trigger_config!.delay_hours = 24
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fieldByKey(key?: string): AutomationField | undefined {
|
|
|
|
|
|
return (catalog.value?.fields || []).find((f) => f.key === key)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function inputsOf(field?: string) {
|
|
|
|
|
|
return fieldByKey(field)?.inputs || []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onFieldChange(row: AutomationCondition, key: string) {
|
|
|
|
|
|
row.field = key
|
|
|
|
|
|
row.op = inputsOf(key).some((i) => i.type === 'operator') ? 'eq' : null
|
|
|
|
|
|
row.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addCondition() {
|
|
|
|
|
|
if (!props.form.conditions) props.form.conditions = []
|
|
|
|
|
|
props.form.audience_mode = 'filter'
|
|
|
|
|
|
props.form.conditions.push({
|
|
|
|
|
|
cr: 'and',
|
|
|
|
|
|
field: '',
|
|
|
|
|
|
op: null,
|
|
|
|
|
|
value: null,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removeCondition(idx: number) {
|
|
|
|
|
|
props.form.conditions?.splice(idx, 1)
|
|
|
|
|
|
if (!props.form.conditions?.length) {
|
|
|
|
|
|
props.form.audience_mode = 'all'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleChannel(ch: AutomationChannel, on: boolean) {
|
|
|
|
|
|
const cur = props.form.channels || []
|
|
|
|
|
|
if (on) {
|
|
|
|
|
|
if (!cur.includes(ch)) props.form.channels = [...cur, ch]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
props.form.channels = cur.filter((c) => c !== ch)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const runPreview = debounce(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cond =
|
|
|
|
|
|
audienceMode.value === 'all'
|
|
|
|
|
|
? []
|
|
|
|
|
|
: (props.form.conditions || []).filter((r) => !isEmpty(r.field))
|
|
|
|
|
|
const data = await previewAutomationAudience(cloneDeep(cond))
|
|
|
|
|
|
previewCount.value = data?.count ?? 0
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
previewCount.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 400)
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => cloneDeep(props.form.conditions),
|
|
|
|
|
|
() => runPreview(),
|
|
|
|
|
|
{ deep: true }
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
watch(audienceMode, () => runPreview())
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
catalog.value = await getAutomationCatalog()
|
|
|
|
|
|
ensureTriggerConfig()
|
|
|
|
|
|
if (props.form.trigger_config!.every_days == null) {
|
|
|
|
|
|
props.form.trigger_config!.every_days = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!props.form.trigger_config!.price_window) {
|
|
|
|
|
|
props.form.trigger_config!.price_window = 'calendar_day'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!props.form.audience_mode) {
|
|
|
|
|
|
props.form.audience_mode = (props.form.conditions || []).some((r) => r.field)
|
|
|
|
|
|
? 'filter'
|
|
|
|
|
|
: 'all'
|
|
|
|
|
|
}
|
|
|
|
|
|
runPreview()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
|
validateFields: () => formRef.value?.validate(),
|
|
|
|
|
|
resetFields: () => formRef.value?.resetFields(),
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.auto-editor {
|
|
|
|
|
|
@apply flex flex-col;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__grid {
|
|
|
|
|
|
direction: ltr;
|
|
|
|
|
|
@apply grid grid-cols-1 items-start gap-x-8 gap-y-6 lg:grid-cols-2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__col {
|
|
|
|
|
|
direction: rtl;
|
|
|
|
|
|
@apply flex min-w-0 flex-col gap-6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__pair {
|
|
|
|
|
|
@apply grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__body :deep(textarea),
|
|
|
|
|
|
.auto-editor__textarea {
|
|
|
|
|
|
min-height: 4.5rem;
|
|
|
|
|
|
max-height: 7rem;
|
|
|
|
|
|
background: var(--admin-bg-elevated) !important;
|
|
|
|
|
|
border-color: var(--admin-border) !important;
|
|
|
|
|
|
color: var(--admin-text) !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__vars {
|
|
|
|
|
|
@apply mt-2 flex flex-wrap gap-2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__var-chip {
|
|
|
|
|
|
@apply inline-flex items-center gap-1.5 rounded-lg border border-solid px-2.5 py-1 text-xs;
|
|
|
|
|
|
border-color: var(--admin-border);
|
|
|
|
|
|
background: var(--admin-bg-elevated);
|
|
|
|
|
|
color: var(--admin-text);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__var-chip:hover {
|
|
|
|
|
|
border-color: var(--admin-border-strong, var(--admin-border));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__var-token {
|
|
|
|
|
|
@apply font-bold;
|
|
|
|
|
|
color: var(--admin-text-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor :deep(.ant-form-item) {
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor :deep(.ant-form-item-label) {
|
|
|
|
|
|
padding-bottom: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor :deep(.ant-form-item-label > label) {
|
|
|
|
|
|
color: var(--admin-text);
|
|
|
|
|
|
height: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__conds {
|
|
|
|
|
|
@apply mb-3 flex flex-col gap-3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__cond {
|
|
|
|
|
|
@apply flex flex-col gap-2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__join {
|
|
|
|
|
|
@apply flex;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__cond-row {
|
|
|
|
|
|
@apply flex flex-wrap items-center gap-2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__cond-select,
|
|
|
|
|
|
.auto-editor__cond-input {
|
|
|
|
|
|
width: min(100%, 22rem);
|
|
|
|
|
|
min-width: 12rem;
|
|
|
|
|
|
flex: 0 1 22rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__channel {
|
|
|
|
|
|
@apply flex items-center gap-3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 22:13:06 +03:30
|
|
|
|
.auto-editor__hour-picker {
|
2026-08-24 09:48:27 +03:30
|
|
|
|
min-width: 7rem;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-24 22:13:06 +03:30
|
|
|
|
.auto-editor__hour-picker :deep(.ant-picker) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
border-radius: 10px;
|
2026-08-24 09:48:27 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.auto-editor__hint {
|
|
|
|
|
|
@apply mt-3 text-xs;
|
|
|
|
|
|
color: var(--admin-text-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|