Files
boursecu_admin/src/pages/menuPages/automations/components/AutomationEditorFormBody.vue
T

755 lines
24 KiB
Vue
Raw Normal View History

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)" />
<a-time-picker
2026-08-24 09:48:27 +03:30
v-if="hasSms && showChannelHours"
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="ساعت"
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)" />
<a-time-picker
2026-08-24 09:48:27 +03:30
v-if="hasInApp && showChannelHours"
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="ساعت"
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'
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'
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'))
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;
}
.auto-editor__hour-picker {
2026-08-24 09:48:27 +03:30
min-width: 7rem;
flex: 1;
}
.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>