Files
boursecu_admin/src/pages/menuPages/automations/components/AutomationEditorFormBody.vue
T
mohmdsdgandCursor 7a2254bc2d feat(automations): use Ant TimePicker with minute precision and Jalali dates
Replace hour-only select with HH:mm picker, show last run and send logs in Jalali, and display scheduled times as HH:mm in the table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 22:13:06 +03:30

755 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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
v-if="hasSms && showChannelHours"
class="auto-editor__hour-picker"
:value="smsHourValue"
format="HH:mm"
:minute-step="1"
:show-second="false"
placeholder="ساعت"
allow-clear
@update:value="setSmsTime"
/>
</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
v-if="hasInApp && showChannelHours"
class="auto-editor__hour-picker"
:value="inAppHourValue"
format="HH:mm"
:minute-step="1"
:show-second="false"
placeholder="ساعت"
allow-clear
@update:value="setInAppTime"
/>
</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'
import { cloneDeep, debounce, isEmpty } from 'lodash'
dayjs.extend(customParseFormat)
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
}
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 {
min-width: 7rem;
flex: 1;
}
.auto-editor__hour-picker :deep(.ant-picker) {
width: 100%;
border-radius: 10px;
}
.auto-editor__hint {
@apply mt-3 text-xs;
color: var(--admin-text-secondary);
}
</style>