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
@@ -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,
}
}