setting pushed

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 09:48:27 +03:30
co-authored by Cursor
parent a75c4d915d
commit 6bbd8704b8
17 changed files with 2338 additions and 14 deletions
+24
View File
@@ -0,0 +1,24 @@
import type { TableInput } from '@core/type/baseTable'
import useAxios from '@core/utils/useAxios'
import type { Automation, AutomationCatalog, AutomationCondition } from '@/model/automations'
export const getAutomationsTable = async (params: TableInput) =>
await useAxios.post<Array<Automation>>('/admin/automations/table', params)
export const getAutomationCatalog = async () =>
await useAxios.get<AutomationCatalog>('/admin/automations/catalog')
export const previewAutomationAudience = async (conditions: AutomationCondition[]) =>
await useAxios.post<{ count: number }>('/admin/automations/preview', { conditions })
export const createAutomation = async (payload: Partial<Automation>) =>
await useAxios.post<Automation>('/admin/automations', payload)
export const updateAutomation = async (id: number, payload: Partial<Automation>) =>
await useAxios.put(`/admin/automations/${id}`, payload)
export const toggleAutomationActive = async (id: number) =>
await useAxios.put<{ is_active: boolean }>(`/admin/automations/${id}/toggle`)
export const deleteAutomation = async (id: number) =>
await useAxios.delete(`/admin/automations/${id}`)
+2 -2
View File
@@ -32,9 +32,9 @@
<template #title>
<span>{{ val[props.valueName] }}</span>
</template>
<div class="truncate max-w-40">{{ val[props.valueName] }}</div>
<div class="whitespace-normal break-words leading-snug">{{ val[props.valueName] }}</div>
</a-tooltip>
<div v-else>{{ val[props.valueName] }}</div>
<div v-else class="whitespace-normal break-words leading-snug">{{ val[props.valueName] }}</div>
</a-select-option>
</a-select>
</div>
+100
View File
@@ -0,0 +1,100 @@
export type AutomationTriggerType = 'schedule' | 'event' | 'price'
export type AutomationChannel = 'sms' | 'in_app'
export type AutomationConditionCr = 'and' | 'or'
export interface AutomationCondition {
cr?: AutomationConditionCr
field: string
op?: string | null
value?: unknown
}
export type AutomationAudienceMode = 'all' | 'filter'
export type AutomationPriceWindow = 'calendar_day' | 'from_last'
export interface AutomationTriggerConfig {
event?: string
delay_hours?: number
every_days?: number
step_toman?: number | string | null
direction?: 'up' | 'down' | 'both'
product_id?: number | null
price_window?: AutomationPriceWindow
}
export interface Automation {
id?: number
name: string
code?: string
is_active: boolean
starts_at?: string | null
ends_at?: string | null
sms_send_hour?: number | null
in_app_send_hour?: number | null
trigger_type: AutomationTriggerType
trigger_config?: AutomationTriggerConfig
audience_mode?: AutomationAudienceMode
conditions?: AutomationCondition[]
channels: AutomationChannel[]
title: string
body: string
cooldown_hours?: number
max_sends?: number | null
sent_count?: number
failed_count?: number
}
export interface AutomationCatalogOption {
key: string | number
name: string
}
export interface AutomationFieldInput {
key: string
type: 'number' | 'select' | 'operator'
label: string
options?: AutomationCatalogOption[]
}
export interface AutomationField {
key: string
group: string
name: string
inputs: AutomationFieldInput[]
}
export interface AutomationCatalog {
triggers: AutomationCatalogOption[]
events: AutomationCatalogOption[]
channels: AutomationCatalogOption[]
operators: AutomationCatalogOption[]
fields: AutomationField[]
products: Array<{ id: number; name: string }>
hours: AutomationCatalogOption[]
price_windows?: AutomationCatalogOption[]
audience_modes?: AutomationCatalogOption[]
}
export function emptyAutomation(): Automation {
return {
name: '',
title: '',
body: '',
is_active: true,
trigger_type: 'schedule',
audience_mode: 'all',
trigger_config: {
delay_hours: 0,
every_days: 1,
direction: 'both',
price_window: 'calendar_day',
},
conditions: [],
channels: ['sms'],
sms_send_hour: 10,
in_app_send_hour: 10,
cooldown_hours: 24,
starts_at: null,
ends_at: null,
}
}
@@ -0,0 +1,736 @@
<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)" />
<BasicSelect
v-if="hasSms && showChannelHours"
class="auto-editor__hour-select"
:value="form.sms_send_hour"
:items="catalog?.hours || []"
key-name="key"
value-name="name"
placeholder="ساعت"
:has-tooltip="false"
@update:value="(v) => (form.sms_send_hour = v)"
/>
</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)" />
<BasicSelect
v-if="hasInApp && showChannelHours"
class="auto-editor__hour-select"
:value="form.in_app_send_hour"
:items="catalog?.hours || []"
key-name="key"
value-name="name"
placeholder="ساعت"
:has-tooltip="false"
@update:value="(v) => (form.in_app_send_hour = v)"
/>
</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 { cloneDeep, debounce, isEmpty } from 'lodash'
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 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-select {
min-width: 7rem;
flex: 1;
}
.auto-editor__hour-select :deep(.ant-select-selector) {
display: flex !important;
align-items: center !important;
height: 32px !important;
}
.auto-editor__hour-select :deep(.ant-select-selection-search),
.auto-editor__hour-select :deep(.ant-select-selection-item),
.auto-editor__hour-select :deep(.ant-select-selection-placeholder) {
display: flex !important;
align-items: center !important;
line-height: 30px !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.auto-editor__hour-select :deep(.ant-select-selection-item > div) {
line-height: 30px;
white-space: nowrap;
padding: 0;
}
.auto-editor__hint {
@apply mt-3 text-xs;
color: var(--admin-text-secondary);
}
</style>
@@ -0,0 +1,121 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="اتوماسیون">
<template #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:add" @click="emits('add')">
افزودن
</BaseButton>
</template>
<div v-if="isMobile" class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-1">
<div
v-for="item in mobileItems"
:key="item.id"
class="flex flex-col gap-2 rounded-xl border border-solid border-gray-100 bg-white p-3 dark:border-slate-700 dark:bg-slate-800/40"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="truncate font-bold text-slate-800 dark:text-slate-100">{{ item.name }}</div>
<div class="text-xs text-gray-500">{{ item.code }}</div>
</div>
<a-switch :checked="item.is_active" size="small" @change="emits('toggle', item)" />
</div>
<div class="line-clamp-2 text-sm text-slate-600 dark:text-slate-300">{{ item.body }}</div>
<div class="flex items-center justify-between text-xs text-gray-500">
<span>ارسال {{ Number(item.sent_count ?? 0) }} · ناموفق {{ Number(item.failed_count ?? 0) }}</span>
<div class="flex gap-3">
<Icon
icon="vuesax-linear:edit"
color="#4572E9"
class="cursor-pointer"
@click="emits('edit', item)"
/>
<Icon
icon="vuesax-linear:trash"
color="#C53030"
class="cursor-pointer"
@click="emits('delete', item.id)"
/>
</div>
</div>
</div>
<div v-if="!mobileLoading && mobileItems.length === 0" class="py-8 text-center text-gray-500">
اتوماسیونی نیست
</div>
</div>
<BaseTable v-else class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #is_active="{ record }">
<a-switch :checked="record.is_active" size="small" @change="emits('toggle', record)" />
</template>
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom">
<template #title>ویرایش</template>
<Icon
icon="vuesax-linear:edit"
color="#4572E9"
class="cursor-pointer"
@click="emits('edit', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>حذف</template>
<Icon
icon="vuesax-linear:trash"
color="#C53030"
class="cursor-pointer"
@click="emits('delete', record.id)"
/>
</a-tooltip>
</div>
</template>
</BaseTable>
</BasicShowContext>
</div>
</template>
<script lang="tsx" setup>
import { onMounted, ref } from 'vue'
import type { TableInput } from '@core/type/baseTable'
import useBaseTable from '@core/components/BaseTableOld/hooks/useBaseTable'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { isMobile } from '@core/utils/is'
import { cols } from '../hooks/automationsColumns'
import { getAutomationsTable } from '@/api/automations'
import type { Automation } from '@/model/automations'
import { useMobileTableList } from '@/components/mobile/composables/useMobileTableList'
const [submit, reload] = useBaseTable()
const emits = defineEmits<{
add: []
edit: [record: Automation]
toggle: [record: Automation]
delete: [id: number]
}>()
const tblProps = {
columns: cols,
api: (params: TableInput) => getAutomationsTable(params),
}
const {
loading: mobileLoading,
items: mobileItems,
reload: reloadMobile,
} = useMobileTableList<Automation>({
fetchApi: getAutomationsTable,
})
onMounted(() => {
if (isMobile.value) reloadMobile()
})
function fetch() {
if (isMobile.value) reloadMobile()
else reload()
}
defineExpose({ fetch })
</script>
@@ -0,0 +1,93 @@
import type { BaseTableColumn } from '@core/type/baseTable'
const triggerLabel: Record<string, string> = {
schedule: 'زمان‌دار',
event: 'رویداد',
price: 'حرکت قیمت',
}
function channelText(record: any): string {
const ch = Array.isArray(record.channels) ? record.channels : []
const parts: string[] = []
if (ch.includes('sms')) parts.push('پیامک')
if (ch.includes('in_app')) parts.push('اعلان')
return parts.join(' / ') || '—'
}
function previewBody(text: unknown): string {
const s = String(text || '').replace(/\s+/g, ' ').trim()
if (!s) return '—'
return s.length > 48 ? `${s.slice(0, 48)}…` : s
}
export const cols: BaseTableColumn[] = [
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
customRender: (record: any) => record.index + 1,
},
{
title: 'نام',
dataIndex: 'name',
key: 'name',
type: 'Text',
},
{
title: 'کد',
dataIndex: 'code',
key: 'code',
},
{
title: 'عنوان',
dataIndex: 'title',
key: 'title',
type: 'Text',
},
{
title: 'متن',
dataIndex: 'body',
key: 'body',
customRender: ({ record }) => previewBody(record.body),
},
{
title: 'نوع',
dataIndex: 'trigger_type',
key: 'trigger_type',
customRender: ({ record }) => triggerLabel[record.trigger_type] || record.trigger_type,
},
{
title: 'کانال',
dataIndex: 'channels',
key: 'channels',
customRender: ({ record }) => channelText(record),
},
{
title: 'فعال',
dataIndex: 'is_active',
key: 'is_active',
slot: 'is_active',
width: 90,
},
{
title: 'ارسال شده',
dataIndex: 'sent_count',
key: 'sent_count',
customRender: ({ record }) => Number(record.sent_count ?? 0),
},
{
title: 'ناموفق',
dataIndex: 'failed_count',
key: 'failed_count',
customRender: ({ record }) => Number(record.failed_count ?? 0),
},
{
title: 'عملیات',
dataIndex: 'action',
key: 'action',
slot: 'action',
fixed: 'right',
filterable: false,
width: 120,
},
]
+132
View File
@@ -0,0 +1,132 @@
<template>
<div class="flex h-full w-full">
<tbl
ref="tableRef"
class="h-full w-full"
@add="openAdd"
@edit="openEdit"
@toggle="toggleActive"
@delete="deleteItem"
/>
<BasicActionSheet
v-model:visible="editorVisible"
:title="sheetTitle"
modal-width="80rem"
drawer-height="80vh"
:loading="loading"
:show-button="true"
:show-cancel="true"
:show-ok="true"
ok-text="ذخیره"
cancel-text="انصراف"
@ok="submitForm"
@close="closeEditor"
@cancel="closeEditor"
>
<div v-if="isMobile" class="mb-3 text-base font-bold">{{ sheetTitle }}</div>
<AutomationEditorFormBody ref="editorRef" :form="form" />
</BasicActionSheet>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { message } from 'ant-design-vue'
import { cloneDeep } from 'lodash'
import tbl from './components/table.vue'
import AutomationEditorFormBody from './components/AutomationEditorFormBody.vue'
import { emptyAutomation, type Automation } from '@/model/automations'
import {
createAutomation,
deleteAutomation,
toggleAutomationActive,
updateAutomation,
} from '@/api/automations'
import { confirmDeleteItem } from '@/utils/confirmModal'
import { isMobile } from '@core/utils/is'
const tableRef = ref<null | { fetch: () => void }>(null)
const editorRef = ref<{
validateFields?: () => Promise<unknown>
resetFields?: () => void
}>()
const loading = ref(false)
const editorVisible = ref(false)
const editId = ref<number | undefined>()
const form = ref<Automation>(emptyAutomation())
const sheetTitle = computed(() => (editId.value ? 'ویرایش اتوماسیون' : 'افزودن اتوماسیون'))
function refreshTable() {
tableRef.value?.fetch()
}
function closeEditor() {
editorVisible.value = false
}
function openAdd() {
editId.value = undefined
form.value = emptyAutomation()
editorRef.value?.resetFields?.()
editorVisible.value = true
}
function openEdit(record: Automation) {
editId.value = record.id
const conditions = record.conditions || []
form.value = cloneDeep({
...emptyAutomation(),
...record,
audience_mode: conditions.length ? 'filter' : 'all',
trigger_config: {
delay_hours: 0,
every_days: 1,
direction: 'both',
price_window: 'calendar_day',
...(record.trigger_config || {}),
},
conditions,
channels: record.channels || [],
})
editorVisible.value = true
}
async function submitForm() {
try {
await editorRef.value?.validateFields?.()
loading.value = true
const payload = cloneDeep(form.value)
if (editId.value) {
await updateAutomation(editId.value, payload)
} else {
await createAutomation(payload)
}
message.success('اتوماسیون ذخیره شد')
editorVisible.value = false
refreshTable()
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function toggleActive(record: Automation) {
if (!record.id) return
try {
await toggleAutomationActive(record.id)
message.success(record.is_active ? 'غیرفعال شد' : 'فعال شد')
refreshTable()
} catch (e) {
console.error(e)
message.error('تغییر وضعیت انجام نشد')
}
}
function deleteItem(id: number) {
confirmDeleteItem('اتوماسیون', id, deleteAutomation, refreshTable)
}
</script>
@@ -215,12 +215,14 @@ import {
helpText,
isSettingVisible,
isCodeManagedSettingKey,
isSuperAdminOwnedSettingKey,
isSettingLocked,
isSettingActive,
isSwitchOn,
normalizeSettingRow,
resolveSettingFieldType
} from './settingTypes'
import { TRADING_HOURS_SETTING_KEY_SET } from './tradingHoursKeys'
const props = withDefaults(defineProps<{ mode?: 'editable' | 'system' }>(), {
mode: 'editable'
@@ -309,6 +311,7 @@ const filteredRows = computed(() =>
(baseData.value || []).filter((row) => {
if (row.id == null || !matchesFilter(row)) return false
if (!isSettingVisible(row.key)) return false
if (!isSystemMode.value && TRADING_HOURS_SETTING_KEY_SET.has(row.key || '')) return false
if (isSystemMode.value) return true
return isEditable(row)
})
@@ -429,6 +432,7 @@ function rowColSpan(row: Const): number {
function isReadonly(row: Const): boolean {
if (isCodeManagedSettingKey(row.key)) return true
if (isSuperAdminOwnedSettingKey(row.key)) return true
if (isSystemMode.value) return false
return isSettingLocked(row.can_edit)
}
@@ -31,15 +31,6 @@ export const EDITABLE_SETTING_GROUPS: SettingGroupDef[] = [
match: (k) =>
/^(banner_enabled|tracking_systems_enabled|jibit_verification|register_jibit_verify|show_product_details|show_product_trade_status|show_product_card_meta|products_site_link_enabled|products_site_link_url|products_site_link_text|has_chat)$/.test(k)
},
{
id: 'trading_hours',
title: 'ساعت کاری و نمایش قیمت',
icon: 'vuesax-linear:clock',
match: (k) =>
/^(close_price|startDay|endDay|show_last_price|show_home_price_when_closed|show_gold_ounce_price)$/.test(
k
)
},
{
id: 'fees',
title: 'کارمزدها',
@@ -199,6 +199,31 @@ export function isCodeManagedSettingKey(key?: string | null): boolean {
return SETTING_CODE_MANAGED_KEYS.has(String(key ?? '').trim())
}
/**
* دسترسی مشتری به قابلیت‌ها — مالک: سوپرادمین.
* در پنل خانگی دیده می‌شوند ولی ذخیره‌شان ۴۰۳ می‌گیرد، پس فقط‌خواندنی‌اند.
* هم‌تراز SuperAdminProgramSettingsOverlay::SUPERADMIN_OWNED_KEYS در سرور.
*/
export const SUPERADMIN_OWNED_SETTING_KEYS = new Set([
'customer_direct_sms',
'customer_special_pricing',
'enable_conditional_order',
'enable_customer_level_profit',
'enable_product_funds',
'group_sms',
'has_page_earn_money',
'has_page_gift_card',
'has_page_notification',
'has_page_price_trigger',
'jibit_enabled',
'permision__enabled',
'show_to_users_chat',
])
export function isSuperAdminOwnedSettingKey(key?: string | null): boolean {
return SUPERADMIN_OWNED_SETTING_KEYS.has(String(key ?? '').trim())
}
/** کلیدهای legacy — در پنل نمایش داده نمی‌شوند */
export const HIDDEN_LEGACY_SETTING_KEYS = new Set([
'main_product_price',
@@ -0,0 +1,63 @@
/** کلیدهای تنظیمات ساعت کاری — فقط در صفحه «ساعت کاری» ویرایش می‌شوند */
export const TRADING_HOURS_SETTING_KEYS = [
'close_price',
'startDay',
'endDay',
'show_last_price',
'show_home_price_when_closed',
'show_gold_ounce_price',
'trading_hours_by_day',
] as const
export type TradingHoursSettingKey = (typeof TRADING_HOURS_SETTING_KEYS)[number]
export const TRADING_HOURS_SETTING_KEY_SET = new Set<string>(TRADING_HOURS_SETTING_KEYS)
export type WeekdayKey = 'sat' | 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri'
export type DaySchedule = {
enabled: boolean
start: string
end: string
}
export type WeeklyScheduleDraft = {
mode: 'uniform' | 'per_day'
days: Record<WeekdayKey, DaySchedule>
}
export const WEEKDAY_DEFS: { key: WeekdayKey; label: string; short: string }[] = [
{ key: 'sat', label: 'شنبه', short: 'ش' },
{ key: 'sun', label: 'یکشنبه', short: 'ی' },
{ key: 'mon', label: 'دوشنبه', short: 'د' },
{ key: 'tue', label: 'سه‌شنبه', short: 'س' },
{ key: 'wed', label: 'چهارشنبه', short: 'چ' },
{ key: 'thu', label: 'پنجشنبه', short: 'پ' },
{ key: 'fri', label: 'جمعه', short: 'ج' },
]
export const DEFAULT_DAY_START = '09:00:00'
export const DEFAULT_DAY_END = '22:00:00'
export function defaultDaySchedule(start = DEFAULT_DAY_START, end = DEFAULT_DAY_END): DaySchedule {
return { enabled: true, start, end }
}
export function defaultWeeklySchedule(
start = DEFAULT_DAY_START,
end = DEFAULT_DAY_END
): WeeklyScheduleDraft {
const day = defaultDaySchedule(start, end)
return {
mode: 'uniform',
days: {
sat: { ...day },
sun: { ...day },
mon: { ...day },
tue: { ...day },
wed: { ...day },
thu: { ...day },
fri: { ...day },
},
}
}
@@ -0,0 +1,196 @@
<template>
<div
class="th-day-row"
:class="{
'th-day-row--disabled': !day.enabled,
'th-day-row--compact': compact,
}"
>
<div class="th-day-row__label">
<span class="th-day-row__badge">{{ weekday.short }}</span>
<span class="th-day-row__name">{{ weekday.label }}</span>
</div>
<div v-if="!compact" class="th-day-row__toggle">
<a-switch
:checked="day.enabled"
:disabled="disabled"
checked-children="باز"
un-checked-children="تعطیل"
@change="(v) => emit('update', { enabled: !!v })"
/>
</div>
<div class="th-day-row__times">
<TradingHoursTimeField
:model-value="day.start"
:disabled="disabled || !day.enabled"
@update:model-value="(v) => emit('update', { start: v })"
/>
<span class="th-day-row__sep">تا</span>
<TradingHoursTimeField
:model-value="day.end"
:disabled="disabled || !day.enabled"
@update:model-value="(v) => emit('update', { end: v })"
/>
</div>
<div v-if="showBar" class="th-day-row__bar-wrap">
<div class="th-day-row__bar">
<div class="th-day-row__bar-fill" :style="barStyle" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import TradingHoursTimeField from './TradingHoursTimeField.vue'
import type { DaySchedule } from '@/pages/menuPages/configuration/tradingHoursKeys'
const props = withDefaults(
defineProps<{
weekday: { key: string; label: string; short: string }
day: DaySchedule
disabled?: boolean
compact?: boolean
showBar?: boolean
}>(),
{
disabled: false,
compact: false,
showBar: true,
}
)
const emit = defineEmits<{
update: [patch: Partial<DaySchedule>]
}>()
function timeToMinutes(raw: string): number {
const parts = raw.split(':')
const h = Number(parts[0] ?? 0)
const m = Number(parts[1] ?? 0)
return h * 60 + m
}
const barStyle = computed(() => {
if (!props.day.enabled) {
return { left: '0%', width: '0%' }
}
const start = timeToMinutes(props.day.start)
const end = timeToMinutes(props.day.end)
const dayMinutes = 24 * 60
const left = Math.max(0, Math.min(100, (start / dayMinutes) * 100))
const width = Math.max(0, Math.min(100 - left, ((Math.max(end, start) - start) / dayMinutes) * 100))
return {
left: `${left}%`,
width: `${width || 2}%`,
}
})
</script>
<style scoped>
.th-day-row {
display: grid;
grid-template-columns: minmax(6.5rem, 8rem) minmax(0, 1fr);
gap: 0.75rem 1rem;
align-items: center;
padding: 0.85rem 1rem;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-bg-elevated);
}
.th-day-row--compact {
grid-template-columns: minmax(5rem, 6rem) minmax(0, 1fr);
}
.th-day-row--disabled {
opacity: 0.72;
}
.th-day-row__label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.th-day-row__badge {
display: inline-flex;
width: 1.75rem;
height: 1.75rem;
align-items: center;
justify-content: center;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
background: color-mix(in srgb, var(--admin-accent, #d4af37) 18%, transparent);
color: var(--admin-text);
}
.th-day-row__name {
font-size: 0.875rem;
font-weight: 600;
color: var(--admin-text);
}
.th-day-row__toggle {
grid-column: 1 / -1;
}
@media (min-width: 768px) {
.th-day-row {
grid-template-columns: minmax(7rem, 8.5rem) auto minmax(0, 1fr);
grid-template-rows: auto auto;
}
.th-day-row__toggle {
grid-column: auto;
}
.th-day-row__times {
grid-column: 3;
}
.th-day-row__bar-wrap {
grid-column: 1 / -1;
}
}
.th-day-row__times {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 0.5rem;
align-items: center;
}
.th-day-row__sep {
font-size: 0.75rem;
color: var(--admin-text-secondary);
}
.th-day-row__bar-wrap {
grid-column: 1 / -1;
}
.th-day-row__bar {
position: relative;
height: 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, var(--admin-border) 65%, transparent);
overflow: hidden;
}
.th-day-row__bar-fill {
position: absolute;
top: 0;
bottom: 0;
border-radius: 999px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--admin-accent, #d4af37) 70%, #fff),
var(--admin-accent, #d4af37)
);
}
</style>
@@ -0,0 +1,56 @@
<template>
<a-time-picker
:value="pickerValue"
format="HH:mm"
value-format="HH:mm:ss"
:minute-step="5"
:disabled="disabled"
placeholder="انتخاب ساعت"
class="th-time-field w-full"
@change="onChange"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import dayjs, { type Dayjs } from 'dayjs'
import { normalizeTimeValue } from '@/pages/menuPages/configuration/settingTypes'
const props = withDefaults(
defineProps<{
modelValue?: string
disabled?: boolean
}>(),
{
modelValue: '',
disabled: false,
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const pickerValue = computed(() => {
const normalized = normalizeTimeValue(props.modelValue)
if (!normalized) return undefined
const parsed = dayjs(normalized, 'HH:mm:ss', true)
return parsed.isValid() ? parsed : undefined
})
function onChange(val: Dayjs | string | null) {
if (val == null || val === '') {
emit('update:modelValue', '')
return
}
const parsed = dayjs.isDayjs(val) ? val : dayjs(String(val), 'HH:mm:ss', true)
emit('update:modelValue', parsed.isValid() ? normalizeTimeValue(parsed.format('HH:mm:ss')) : '')
}
</script>
<style scoped>
.th-time-field :deep(.ant-picker) {
width: 100%;
border-radius: 10px;
}
</style>
+410
View File
@@ -0,0 +1,410 @@
<template>
<div class="th-page h-full min-h-0">
<BasicShowContent title="ساعت کاری و نمایش قیمت" class="h-full">
<header class="th-page__header">
<p class="th-page__intro">
ساعت باز و بسته بودن فروشگاه و رفتار نمایش قیمت در اپ مشتری
</p>
<AdminFieldHelp :text="tradingHoursPageIntro" size="sm" />
<a-button class="th-page__refresh" :loading="loading" @click="load">بروزرسانی</a-button>
</header>
<a-spin :spinning="loading">
<div class="th-page__sections">
<section class="th-card">
<div class="th-card__head">
<div class="th-card__title-row">
<BaseIcon icon="vuesax-linear:clock" size="20" class="th-card__icon" />
<div>
<h2 class="th-card__title">ساعت کاری فروشگاه</h2>
<p class="th-card__desc">کنترل باز و بسته بودن خرید و فروش در طول روز</p>
</div>
</div>
<div v-if="isSaving('close_price')" class="th-card__status">در حال ذخیره…</div>
<div v-else-if="isSavedFlash('close_price')" class="th-card__status th-card__status--ok">
ذخیره شد
</div>
</div>
<div class="th-card__switch-row">
<div class="th-card__switch-copy">
<AdminLabelWithHelp
:label="rowMeta('close_price').title"
:help="rowMeta('close_price').help"
/>
</div>
<a-switch
:checked="closePriceEnabled"
checked-children="روشن"
un-checked-children="خاموش"
@change="(v) => setSwitch('close_price', !!v)"
/>
</div>
<div class="th-card__mode" :class="{ 'th-card__mode--disabled': !closePriceEnabled }">
<div class="th-card__mode-head">
<span class="th-card__mode-label">نوع برنامه</span>
<a-radio-group
:value="weekly.mode"
option-type="button"
button-style="solid"
:disabled="!closePriceEnabled"
@update:value="setScheduleMode"
>
<a-radio-button value="uniform">یکسان برای همه روزها</a-radio-button>
<a-radio-button value="per_day">ساعت جدا برای هر روز</a-radio-button>
</a-radio-group>
</div>
<div v-if="weekly.mode === 'uniform'" class="th-uniform">
<div class="th-uniform__pickers">
<label class="th-field">
<span class="th-field__label">
<AdminLabelWithHelp
:label="rowMeta('startDay').title"
:help="rowMeta('startDay').help"
/>
</span>
<TradingHoursTimeField
:model-value="uniformStart"
:disabled="!closePriceEnabled"
@update:model-value="(v) => setUniformTimes(v, uniformEnd)"
/>
</label>
<label class="th-field">
<span class="th-field__label">
<AdminLabelWithHelp
:label="rowMeta('endDay').title"
:help="rowMeta('endDay').help"
/>
</span>
<TradingHoursTimeField
:model-value="uniformEnd"
:disabled="!closePriceEnabled"
@update:model-value="(v) => setUniformTimes(uniformStart, v)"
/>
</label>
</div>
<div class="th-uniform__preview">
<p class="th-uniform__preview-title">پیش‌نمایش هفته</p>
<div class="th-uniform__days">
<TradingHoursDayRow
v-for="def in WEEKDAY_DEFS"
:key="def.key"
:weekday="def"
:day="weekly.days[def.key]"
compact
disabled
@update="() => {}"
/>
</div>
</div>
</div>
<div v-else class="th-weekly">
<p class="th-weekly__hint">
برای هر روز می‌توانید ساعت جدا بگذارید یا آن روز را کاملاً تعطیل کنید. فعلاً
اپ از کوچک‌ترین بازهٔ ذخیره‌شده برای سازگاری استفاده می‌کند؛ اعمال دقیق
روزانه در سرور به‌زودی فعال می‌شود.
</p>
<div class="th-weekly__list">
<TradingHoursDayRow
v-for="def in WEEKDAY_DEFS"
:key="def.key"
:weekday="def"
:day="weekly.days[def.key]"
:disabled="!closePriceEnabled"
@update="(patch) => setDaySchedule(def.key, patch)"
/>
</div>
</div>
</div>
</section>
<section class="th-card">
<div class="th-card__head">
<div class="th-card__title-row">
<BaseIcon icon="vuesax-linear:eye" size="20" class="th-card__icon" />
<div>
<h2 class="th-card__title">نمایش قیمت</h2>
<p class="th-card__desc">رفتار اپ وقتی قیمت زنده نیست یا فروشگاه بسته است</p>
</div>
</div>
</div>
<div class="th-display-grid">
<article
v-for="item in displayItems"
:key="item.key"
class="th-display-item"
>
<div class="th-display-item__head">
<AdminLabelWithHelp :label="item.title" :help="item.help" />
<div v-if="isSaving(item.key)" class="th-display-item__status">…</div>
<div
v-else-if="isSavedFlash(item.key)"
class="th-display-item__status th-display-item__status--ok"
>
✓
</div>
</div>
<a-switch
:checked="isSwitchOn(draftValue(item.key))"
checked-children="روشن"
un-checked-children="خاموش"
@change="(v) => setSwitch(item.key, !!v)"
/>
</article>
</div>
</section>
</div>
</a-spin>
</BasicShowContent>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import BasicShowContent from '@/components/BasicShowContext.vue'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
import { tradingHoursPageIntro } from '@/utils/adminHelpCopy'
import { isSwitchOn } from '@/pages/menuPages/configuration/settingTypes'
import { WEEKDAY_DEFS } from '@/pages/menuPages/configuration/tradingHoursKeys'
import TradingHoursDayRow from './components/TradingHoursDayRow.vue'
import TradingHoursTimeField from './components/TradingHoursTimeField.vue'
import { useTradingHoursPage } from './useTradingHoursPage'
const {
loading,
closePriceEnabled,
weekly,
uniformStart,
uniformEnd,
draftValue,
rowMeta,
load,
setSwitch,
setUniformTimes,
setScheduleMode,
setDaySchedule,
isSaving,
isSavedFlash,
} = useTradingHoursPage()
const displayItems = computed(() =>
(['show_last_price', 'show_home_price_when_closed', 'show_gold_ounce_price'] as const).map(
(key) => ({
key,
title: rowMeta(key).title,
help: rowMeta(key).help,
})
)
)
onMounted(() => {
void load()
})
</script>
<style scoped>
.th-page__header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
margin-bottom: 1.25rem;
}
.th-page__intro {
margin: 0;
font-size: 0.875rem;
color: var(--admin-text-secondary);
}
.th-page__refresh {
margin-inline-start: auto;
}
.th-page__sections {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding-bottom: 2rem;
}
.th-card {
border: 1px solid var(--admin-border);
border-radius: 14px;
background: var(--admin-bg-muted);
padding: 1rem 1rem 1.25rem;
}
@media (min-width: 768px) {
.th-card {
padding: 1.25rem 1.5rem 1.5rem;
}
}
.th-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.th-card__title-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.th-card__icon {
color: var(--admin-accent, #d4af37);
margin-top: 0.15rem;
}
.th-card__title {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: var(--admin-text);
}
.th-card__desc {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: var(--admin-text-secondary);
}
.th-card__status {
font-size: 0.75rem;
color: var(--admin-text-secondary);
white-space: nowrap;
}
.th-card__status--ok {
color: #52c41a;
}
.th-card__switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-bg-elevated);
margin-bottom: 1rem;
}
.th-card__switch-copy {
min-width: 0;
}
.th-card__mode--disabled {
opacity: 0.55;
pointer-events: none;
}
.th-card__mode-head {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (min-width: 768px) {
.th-card__mode-head {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.th-card__mode-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--admin-text);
}
.th-uniform__pickers {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (min-width: 768px) {
.th-uniform__pickers {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.th-field {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.th-field__label {
font-size: 0.8125rem;
}
.th-uniform__preview-title,
.th-weekly__hint {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
color: var(--admin-text-secondary);
}
.th-uniform__days,
.th-weekly__list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.th-display-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
@media (min-width: 768px) {
.th-display-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.th-display-item {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 0.9rem 1rem;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-bg-elevated);
min-height: 7.5rem;
}
.th-display-item__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
min-height: 2.5rem;
}
.th-display-item__status {
font-size: 0.75rem;
color: var(--admin-text-secondary);
}
.th-display-item__status--ok {
color: #52c41a;
}
</style>
@@ -0,0 +1,277 @@
import { debounce } from 'lodash'
import { computed, ref } from 'vue'
import type { Const } from '@/model/configuration'
import { getEditableConfiguration, setConfiguration } from '@/api/configuration'
import {
formatValueForSave,
isSwitchOn,
normalizeTimeValue,
resolveSettingFieldType,
resolveSettingTitle,
helpText,
} from '@/pages/menuPages/configuration/settingTypes'
import {
DEFAULT_DAY_END,
DEFAULT_DAY_START,
TRADING_HOURS_SETTING_KEY_SET,
WEEKDAY_DEFS,
defaultWeeklySchedule,
type DaySchedule,
type WeekdayKey,
type WeeklyScheduleDraft,
} from '@/pages/menuPages/configuration/tradingHoursKeys'
function parseWeeklyJson(raw?: string | null): WeeklyScheduleDraft | null {
if (!raw?.trim()) return null
try {
const parsed = JSON.parse(raw) as Partial<WeeklyScheduleDraft>
if (parsed.mode !== 'uniform' && parsed.mode !== 'per_day') return null
if (!parsed.days || typeof parsed.days !== 'object') return null
const base = defaultWeeklySchedule()
for (const def of WEEKDAY_DEFS) {
const row = (parsed.days as Record<string, Partial<DaySchedule>>)[def.key]
if (!row) continue
base.days[def.key] = {
enabled: row.enabled !== false,
start: normalizeTimeValue(row.start || base.days[def.key].start) || DEFAULT_DAY_START,
end: normalizeTimeValue(row.end || base.days[def.key].end) || DEFAULT_DAY_END,
}
}
base.mode = parsed.mode
return base
} catch {
return null
}
}
function serializeWeekly(draft: WeeklyScheduleDraft): string {
return JSON.stringify(draft)
}
function applyUniformTimes(draft: WeeklyScheduleDraft, start: string, end: string) {
for (const def of WEEKDAY_DEFS) {
draft.days[def.key] = {
...draft.days[def.key],
start,
end,
}
}
}
export function useTradingHoursPage() {
const loading = ref(true)
const rowsByKey = ref<Record<string, Const>>({})
const drafts = ref<Record<string, string>>({})
const savingKeys = ref<Set<string>>(new Set())
const savedFlashKeys = ref<Set<string>>(new Set())
const weekly = ref<WeeklyScheduleDraft>(defaultWeeklySchedule())
const uniformStart = ref(DEFAULT_DAY_START)
const uniformEnd = ref(DEFAULT_DAY_END)
const debouncedSave = new Map<string, ReturnType<typeof debounce>>()
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>()
const closePriceEnabled = computed(() => isSwitchOn(draftValue('close_price')))
function draftValue(key: string): string {
const row = rowsByKey.value[key]
if (!row?.id) return ''
return drafts.value[String(row.id)] ?? row.value ?? ''
}
function rowMeta(key: string) {
const row = rowsByKey.value[key]
return {
title: row ? resolveSettingTitle(row) : key,
help: row ? helpText(row) : '',
exists: Boolean(row?.id),
}
}
function hydrateWeeklyFromRows() {
const start = normalizeTimeValue(draftValue('startDay') || DEFAULT_DAY_START) || DEFAULT_DAY_START
const end = normalizeTimeValue(draftValue('endDay') || DEFAULT_DAY_END) || DEFAULT_DAY_END
uniformStart.value = start
uniformEnd.value = end
const fromJson = parseWeeklyJson(draftValue('trading_hours_by_day'))
if (fromJson) {
weekly.value = fromJson
return
}
weekly.value = defaultWeeklySchedule(start, end)
}
async function load() {
loading.value = true
try {
const rows = await getEditableConfiguration()
const map: Record<string, Const> = {}
const draftMap: Record<string, string> = {}
for (const row of rows || []) {
if (!row.key || !TRADING_HOURS_SETTING_KEY_SET.has(row.key)) continue
map[row.key] = row
if (row.id != null) draftMap[String(row.id)] = row.value ?? ''
}
rowsByKey.value = map
drafts.value = draftMap
hydrateWeeklyFromRows()
} finally {
loading.value = false
}
}
function markSavedFlash(key: string) {
savedFlashKeys.value.add(key)
if (saveTimers.has(key)) clearTimeout(saveTimers.get(key))
saveTimers.set(
key,
setTimeout(() => {
savedFlashKeys.value.delete(key)
}, 1800)
)
}
async function persistKey(key: string, rawValue: string) {
const row = rowsByKey.value[key]
const type = resolveSettingFieldType(row ?? { key, type: 'text' })
const value = formatValueForSave(rawValue, type, key)
if (row?.id) {
const current = row.value ?? ''
if (value === current && drafts.value[String(row.id)] === value) return
}
savingKeys.value.add(key)
savedFlashKeys.value.delete(key)
try {
const payload: Const = row?.id
? {
id: row.id,
key,
value,
description: row.description ?? '',
is_active: true,
title_fa: row.title_fa ?? '',
type,
}
: {
key,
value,
description: '',
is_active: true,
title_fa: key === 'trading_hours_by_day' ? 'برنامه هفتگی ساعت کاری' : '',
type: key === 'trading_hours_by_day' ? 'longtext' : 'text',
}
const saved = await setConfiguration(payload)
rowsByKey.value[key] = { ...rowsByKey.value[key], ...saved, key }
if (saved.id != null) {
drafts.value[String(saved.id)] = value
}
markSavedFlash(key)
} finally {
savingKeys.value.delete(key)
}
}
function schedulePersist(key: string, value: string, options?: { flush?: boolean }) {
if (!debouncedSave.has(key)) {
debouncedSave.set(
key,
debounce((nextValue: string) => {
void persistKey(key, nextValue)
}, key === 'trading_hours_by_day' ? 500 : 350)
)
}
const fn = debouncedSave.get(key)!
if (options?.flush) {
fn.cancel()
void persistKey(key, value)
return
}
fn(value)
}
function setDraftByKey(key: string, value: string, options?: { flush?: boolean }) {
const row = rowsByKey.value[key]
if (row?.id != null) {
drafts.value[String(row.id)] = value
}
schedulePersist(key, value, options)
}
function setSwitch(key: string, checked: boolean) {
setDraftByKey(key, checked ? 'true' : 'false', { flush: true })
}
function setUniformTimes(start: string, end: string) {
const normalizedStart = normalizeTimeValue(start) || DEFAULT_DAY_START
const normalizedEnd = normalizeTimeValue(end) || DEFAULT_DAY_END
uniformStart.value = normalizedStart
uniformEnd.value = normalizedEnd
applyUniformTimes(weekly.value, normalizedStart, normalizedEnd)
weekly.value.mode = 'uniform'
setDraftByKey('startDay', normalizedStart, { flush: true })
setDraftByKey('endDay', normalizedEnd, { flush: true })
setDraftByKey('trading_hours_by_day', serializeWeekly(weekly.value), { flush: true })
}
function setScheduleMode(mode: 'uniform' | 'per_day') {
weekly.value.mode = mode
if (mode === 'uniform') {
applyUniformTimes(weekly.value, uniformStart.value, uniformEnd.value)
}
setDraftByKey('trading_hours_by_day', serializeWeekly(weekly.value), { flush: true })
}
function syncLegacyBoundsFromWeekly() {
const enabled = WEEKDAY_DEFS.map((def) => weekly.value.days[def.key]).filter((day) => day.enabled)
if (!enabled.length) return
const starts = [...enabled].sort((a, b) => a.start.localeCompare(b.start))
const ends = [...enabled].sort((a, b) => a.end.localeCompare(b.end))
uniformStart.value = starts[0].start
uniformEnd.value = ends[ends.length - 1].end
setDraftByKey('startDay', uniformStart.value, { flush: true })
setDraftByKey('endDay', uniformEnd.value, { flush: true })
}
function setDaySchedule(day: WeekdayKey, patch: Partial<DaySchedule>) {
weekly.value.days[day] = {
...weekly.value.days[day],
...patch,
start: normalizeTimeValue(patch.start ?? weekly.value.days[day].start) || DEFAULT_DAY_START,
end: normalizeTimeValue(patch.end ?? weekly.value.days[day].end) || DEFAULT_DAY_END,
}
weekly.value.mode = 'per_day'
setDraftByKey('trading_hours_by_day', serializeWeekly(weekly.value))
syncLegacyBoundsFromWeekly()
}
function isSaving(key: string) {
return savingKeys.value.has(key)
}
function isSavedFlash(key: string) {
return savedFlashKeys.value.has(key)
}
return {
loading,
closePriceEnabled,
weekly,
uniformStart,
uniformEnd,
draftValue,
rowMeta,
load,
setSwitch,
setUniformTimes,
setScheduleMode,
setDaySchedule,
isSaving,
isSavedFlash,
}
}
+36 -3
View File
@@ -1,5 +1,6 @@
import type { RouteRecordRaw } from 'vue-router'
import { hasSmsPanelAccess } from '@/utils/smsPanelAccess'
import { isDeployAdmin } from '@/utils/deployAdmin'
import { useUserStore } from '@/store/userStore'
import { bannerEnabledSetting, trackingSystemsEnabledSetting, appNotificationEnabledSetting } from '@/utils/config'
@@ -59,10 +60,22 @@ const Setting: RouteRecordRaw = {
}
},
{
name: 'advancedConfigurationRedirect',
name: 'advancedConfiguration',
path: 'advanced-configuration',
redirect: { name: 'configuration' },
meta: { hideMenu: true }
component: () => import('@/pages/menuPages/configuration/index.vue'),
props: { mode: 'system' },
meta: {
deployAdminOnly: true,
icon: 'vuesax-linear:code-circle',
title: 'تنظیمات پیشرفته',
order: 2.5
},
beforeEnter() {
const userStore = useUserStore()
if (!isDeployAdmin(userStore.userInfo?.mobile)) {
return { name: 'configuration' }
}
}
},
{
name: 'smsPanelMain',
@@ -81,6 +94,16 @@ const Setting: RouteRecordRaw = {
}
}
},
{
name: 'tradingHours',
path: 'trading-hours',
component: () => import('@/pages/menuPages/tradingHours/index.vue'),
meta: {
icon: 'vuesax-linear:clock',
title: 'ساعت کاری',
order: 3.6
}
},
{
name: 'banners',
path: 'banners',
@@ -111,6 +134,16 @@ const Setting: RouteRecordRaw = {
}
}
},
{
name: 'automations',
path: 'automations',
component: () => import('@/pages/menuPages/automations/index.vue'),
meta: {
icon: 'vuesax-linear:flash',
title: 'اتوماسیون',
order: 4.7
}
},
{
name: 'smsMain',
path: 'sms',
+63
View File
@@ -107,3 +107,66 @@ export const physicalWithdrawApproveHelp =
export const physicalWithdrawStatusHelp =
'پیگیری مراحل ارسال: در انتظار ارسال، ارسال شده، لغو شده، یا در انتظار تعیین مجدد قیمت (برای محصولاتی که وزن نهایی مشخص نیست).'
export const automationAudienceHelp =
'چه کسانی این پیام را بگیرند. «همه کاربران» یعنی همه مشتریان خانگی فعال. «با شرط» فقط کسانی که شرط‌های زیر را دارند.'
export const automationNameHelp =
'نام داخلی برای پیدا کردن این اتوماسیون در لیست پنل؛ به مشتری نشان داده نمی‌شود.'
export const automationTitleHelp =
'عنوانی که همراه پیامک یا اعلان داخل اپ برای مشتری می‌آید.'
export const automationBodyHelp =
'متن اصلی پیام. با دکمه‌های زیر فیلد می‌توانید %نام، %نام_کوچک و %موبایل را در محل کرسر بگذارید تا موقع ارسال با اطلاعات هر مشتری جایگزین شود.'
export const automationTriggerTypeHelp =
'زمان‌دار: طبق فاصلهٔ روزها. رویداد: وقتی اتفاقی مثل ثبت‌نام بیفتد. قیمت: وقتی قیمت به اندازهٔ مشخصی حرکت کند.'
export const automationEveryDaysHelp =
'هر چند روز یک‌بار این پیام برای مخاطبان واجد شرایط فرستاده می‌شود. مثلاً ۲۰ یعنی حدوداً هر ۲۰ روز یک‌بار برای هر نفر.'
export const automationEventHelp =
'کدام رویداد سیستم باعث شروع این اتوماسیون شود (مثلاً ثبت‌نام یا خرید).'
export const automationEventTimingHelp =
'سر ضرب: بلافاصله بعد از رویداد. با تأخیر: چند ساعت بعد از رویداد ارسال می‌شود.'
export const automationDelayHoursHelp =
'چند ساعت بعد از رویداد پیام برود. مثلاً ۲۴ یعنی یک روز بعد.'
export const automationPriceWindowHelp =
'در یک روز: تغییر قیمت از ابتدای همان روز تقویمی تا الان را می‌سنجد و در همان روز حداکثر یک‌بار می‌فرستد. از آخرین ارسال: پله‌پله از آخرین باری که برای همان نفر فرستاده شده حساب می‌کند.'
export const automationPriceDirectionHelp =
'بالا = فقط وقتی قیمت بالا رفته. پایین = فقط وقتی پایین آمده. هر دو = هر دو جهت.'
export const automationPriceStepHelp =
'حداقل رقم تغییر قیمت به تومان که باید رخ دهد تا پیام برود. مثلاً ۱۰۰۰۰۰ یعنی صد هزار تومان.'
export const automationPriceProductHelp =
'اگر خالی بگذارید قیمت تابلو/مرجع کلی استفاده می‌شود. اگر محصول انتخاب کنید، فقط حرکت قیمت همان محصول ملاک است.'
export const automationCooldownHelp =
'حداقل فاصلهٔ ساعت بین دو ارسال موفق به یک نفر برای این اتوماسیون. جلوی اسپم شدن همان مشتری را می‌گیرد.'
export const automationMaxSendsHelp =
'سقف تعداد دفعاتی که این اتوماسیون می‌تواند به یک نفر پیام بدهد. خالی = بدون سقف.'
export const automationSmsHelp =
'روشن = ارسال پیامک. اگر ساعت انتخاب کنید، ارسال تا آن ساعت نگه داشته می‌شود؛ اگر خالی باشد طبق زمان تریگر می‌رود.'
export const automationInAppHelp =
'روشن = اعلان داخل اپ مشتری. ساعت اختیاری مثل پیامک عمل می‌کند.'
export const automationStartsAtHelp =
'از این تاریخ و ساعت به بعد اتوماسیون می‌تواند اجرا شود. خالی = از همین الان.'
export const automationEndsAtHelp =
'بعد از این تاریخ و ساعت دیگر اجرا نمی‌شود. خالی = بدون پایان.'
export const automationStatusHelp =
'فعال = موتور اتوماسیون این قانون را اجرا می‌کند. غیرفعال = ذخیره می‌ماند ولی چیزی نمی‌فرستد.'
export const tradingHoursPageIntro =
'ساعت باز و بسته بودن فروشگاه و رفتار نمایش قیمت در اپ مشتری. می‌توانید برای همه روزها یک ساعت بگذارید یا برای هر روز هفته جداگانه تنظیم کنید.'