This commit is contained in:
2026-07-15 12:51:05 +03:30
parent e83daaf28b
commit 87b60a17d0
12 changed files with 623 additions and 258 deletions
+5 -4
View File
@@ -11,10 +11,11 @@ import {
export default defineConfig({
shortcuts: [
['surface', 'bg-white dark:bg-[#1f1f1f]'],
['surface-muted', 'bg-gray-50 dark:bg-[#262626]'],
['border-base', 'border-gray-200 dark:border-[color:var(--admin-border)]'],
['text-muted', 'text-gray-600 dark:text-gray-400'],
['surface', 'bg-[color:var(--admin-bg-elevated)]'],
['surface-muted', 'bg-[color:var(--admin-bg-muted)]'],
// رنگ + ضخامت — فقط color کافی نیست و در دارک/لایت کادر دیده نمی‌شود
['border-base', 'border border-solid border-[color:var(--admin-border-strong)]'],
['text-muted', 'text-[color:var(--admin-text-secondary)]'],
],
theme: {
colors: {
+22 -3
View File
@@ -9,6 +9,10 @@
.v-leave-to {
opacity: 0;
}
.ant-tabs-rtl .ant-tabs-nav .ant-tabs-tab{
margin: 3px !important;
}
/*::-webkit-scrollbar-track {
background-color: #f2f6ff !important;
}
@@ -29,9 +33,24 @@
}
}
.ant-form-item {
display: block;
margin-bottom: 0px;
/* قبل‌تر display:block انعطاف ant-row را می‌شکست و لیبل/اینپوت افقی می‌شدند */
.ant-form:not(.ant-form-inline) .ant-form-item.ant-row {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
margin-bottom: 0;
}
.ant-form:not(.ant-form-inline) .ant-form-item > .ant-form-item-label,
.ant-form:not(.ant-form-inline) .ant-form-item > .ant-form-item-control {
flex: 0 0 auto !important;
max-width: 100% !important;
width: 100% !important;
padding-inline: 0 !important;
}
.ant-form:not(.ant-form-inline) .ant-form-item > .ant-form-item-label {
padding-bottom: 8px !important;
}
.ant-select-selector{
@@ -1,34 +1,85 @@
<template>
<div :class="[prefixCls]">
<div
class="w-[96px] h-[96px] overflow-hidden border border-solid border-blue-600 bg-gray-50 rounded-md p-2"
>
<div :class="[prefixCls]" class="index-image-uploader">
<div class="index-image-uploader__card">
<div class="index-image-uploader__header">
<div class="index-image-uploader__title">تصویر شاخص محصول</div>
<p class="index-image-uploader__desc">
این تصویر در لیست محصولات و صفحه جزئیات نمایش داده می‌شود.
</p>
</div>
<AUpload
class="fix-capture"
v-if="!currentImageUrl"
class="fix-capture index-image-uploader__zone"
accept="image/*"
v-model:file-list="fileList"
name="file"
list-type="picture-card"
:showUploadList="true"
:show-upload-list="false"
:multiple="false"
:max-count="1"
:action="uploadURL"
:headers="fileHeader"
:before-upload="beforeUpload"
:file-list="fileList"
@change="handleChange"
@preview="handlePreview"
@update:file-list="onFileListUpdate"
>
<div v-if="fileList.length === 0">
<loading-outlined v-if="loading"></loading-outlined>
<BaseIcon v-else icon="vuesax-linear:add" color="#6B7280" />
<div class="text-gray-500 text-center text-xs font-normal">تصویر شاخص</div>
<div class="index-image-uploader__empty">
<div v-if="loading" class="index-image-uploader__loading">
<LoadingOutlined />
<span>در حال بارگذاری...</span>
</div>
<template v-else>
<BaseIcon icon="vuesax-linear:gallery-add" size="40" class="index-image-uploader__icon" />
<span class="index-image-uploader__cta">افزودن تصویر شاخص</span>
<span class="index-image-uploader__hint">JPG، PNG یا WEBP — حداکثر ۲۰۰ کیلوبایت</span>
<span class="index-image-uploader__hint">نسبت مربعی برای نمایش بهتر توصیه می‌شود</span>
</template>
</div>
</AUpload>
<AModal :visible="previewVisible" :title="previewTitle" :footer="null" @cancel="handleCancel">
<img alt="example" style="width: 100%" :src="previewImage" />
</AModal>
<div v-else class="index-image-uploader__filled">
<img :src="currentImageUrl" alt="تصویر شاخص" class="index-image-uploader__img" />
<div class="index-image-uploader__bar">
<button
type="button"
class="index-image-uploader__btn"
@click="openPreview"
>
پیش‌نمایش
</button>
<AUpload
class="fix-capture"
accept="image/*"
name="file"
:show-upload-list="false"
:multiple="false"
:max-count="1"
:action="uploadURL"
:headers="fileHeader"
:before-upload="beforeUpload"
:file-list="fileList"
@change="handleChange"
@update:file-list="onFileListUpdate"
>
<button type="button" class="index-image-uploader__btn index-image-uploader__btn--primary">
تغییر تصویر
</button>
</AUpload>
<button
type="button"
class="index-image-uploader__btn index-image-uploader__btn--danger"
@click="removeImage"
>
حذف تصویر
</button>
</div>
</div>
</div>
<AModal :visible="previewVisible" :title="previewTitle" :footer="null" @cancel="handleCancel">
<img alt="preview" class="w-full" :src="previewImage" />
</AModal>
</div>
</template>
<script setup lang="ts">
@@ -45,9 +96,9 @@ import { computed, onMounted, ref } from 'vue'
const { prefixCls } = usePrefix('index-image-uploader')
const previewVisible = ref(false)
const previewImage = ref('')
const previewTitle = ref('')
const previewTitle = ref('تصویر شاخص')
const loading = ref<boolean>(false)
const loading = ref(false)
const fileHeader = {
Authorization: `Bearer ${getToken()}`
}
@@ -73,16 +124,30 @@ const fileList = computed({
}
})
const handlePreview = async (file: UploadProps['fileList'][number]) => {
previewImage.value = file.url || file.thumbUrl || file.src
previewVisible.value = true
previewTitle.value =
file.name || (file.url ? file.url.substring(file.url.lastIndexOf('/') + 1) : 'image')
const currentImageUrl = computed(() => {
const file = fileList.value?.[0] as { url?: string; thumbUrl?: string; src?: string } | undefined
return file?.url || file?.thumbUrl || file?.src || ''
})
function onFileListUpdate(value: UploadProps['fileList']) {
fileList.value = value
}
const handleCancel = () => {
function openPreview() {
previewImage.value = currentImageUrl.value
previewVisible.value = true
}
function handleCancel() {
previewVisible.value = false
previewTitle.value = ''
}
function removeImage() {
const uid = (fileList.value?.[0] as { uid?: string } | undefined)?.uid
fileList.value = []
if (uid) {
emits('delete', uid)
}
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
@@ -150,28 +215,160 @@ onMounted(() => {
@prefix: ~'@{prefixCls}-index-image-uploader';
.@{prefix} {
.ant-upload-rtl.ant-upload.ant-upload-select-picture-card {
margin: 0 !important;
border: none;
width: 100%;
}
.index-image-uploader__card {
border: 1px solid var(--admin-border, var(--ant-color-border-secondary, rgba(0, 0, 0, 0.06)));
border-radius: 12px;
background: var(--admin-bg-muted, var(--ant-color-fill-alter, rgba(0, 0, 0, 0.02)));
padding: 1rem 1.25rem;
}
.index-image-uploader__header {
margin-bottom: 1rem;
}
.index-image-uploader__title {
font-size: 0.875rem;
font-weight: 700;
color: var(--admin-text, inherit);
}
.index-image-uploader__desc {
margin: 0.35rem 0 0;
font-size: 0.75rem;
line-height: 1.6;
color: var(--admin-text-secondary, #6b7280);
}
.index-image-uploader__zone {
display: block;
width: 100%;
.ant-upload {
width: 78px !important;
height: 78px !important;
background-color: #eff6ff;
border: dashed 1px #93c5fd;
border-radius: 4px;
display: block;
width: 100%;
}
.ant-upload-list-picture-card-container {
margin: 0;
width: 78px !important;
height: 78px !important;
.ant-upload-select {
display: block !important;
width: 100% !important;
margin: 0 !important;
border: none !important;
background: transparent !important;
}
.ant-upload-list-picture-card .ant-upload-list-item {
padding: 0;
border: none;
}
.ant-upload-list-picture-card .ant-upload-list-item-info {
border-radius: 4px;
.index-image-uploader__empty {
display: flex;
width: 100%;
min-height: 12rem;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 1.5rem 1.25rem;
border: 2px dashed color-mix(in srgb, var(--admin-primary, #f5b800) 45%, var(--admin-border, #d9d9d9));
border-radius: 12px;
background: color-mix(in srgb, var(--admin-bg-muted, #f5f5f5) 85%, transparent);
color: var(--admin-text-secondary, #6b7280);
text-align: center;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
&:hover {
border-color: var(--admin-primary, #f5b800);
background: color-mix(in srgb, var(--admin-primary, #f5b800) 8%, var(--admin-bg-muted, #f5f5f5));
}
}
.index-image-uploader__icon {
color: var(--admin-primary, #f5b800);
opacity: 0.9;
margin-bottom: 0.25rem;
}
.index-image-uploader__cta {
font-size: 0.9375rem;
font-weight: 600;
color: var(--admin-text, inherit);
}
.index-image-uploader__hint {
font-size: 0.75rem;
line-height: 1.5;
}
.index-image-uploader__loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.index-image-uploader__filled {
position: relative;
width: 100%;
max-width: 22rem;
aspect-ratio: 1;
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--admin-border, #d9d9d9);
background: #000;
}
.index-image-uploader__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.index-image-uploader__bar {
position: absolute;
inset: auto 0 0 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0.78), transparent);
.ant-upload {
display: inline-block;
}
.ant-upload-select {
display: inline-block !important;
margin: 0 !important;
border: none !important;
background: transparent !important;
}
}
.index-image-uploader__btn {
padding: 0.4rem 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s ease;
&--primary:hover {
background: rgba(0, 0, 0, 0.65);
}
&--danger {
border-color: rgba(255, 77, 79, 0.55);
color: #ffb3b3;
}
&--danger:hover {
background: rgba(255, 77, 79, 0.35);
}
}
</style>
+7 -1
View File
@@ -1,6 +1,6 @@
<template>
<article
class="rounded-2xl border-base surface shadow-sm"
class="mobile-card rounded-2xl border-base surface shadow-sm"
:class="[paddingClass, clickable ? 'cursor-pointer' : '']"
@click="clickable ? $emit('click') : undefined"
>
@@ -26,3 +26,9 @@ defineEmits<{
const paddingClass = computed(() => (props.compact ? 'px-3 py-3' : 'p-4'))
</script>
<style scoped>
.mobile-card {
box-shadow: 0 1px 2px var(--admin-shadow);
}
</style>
+1 -1
View File
@@ -25,7 +25,7 @@
<div
v-if="showEmpty"
class="rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-3 py-6 text-center text-sm text-gray-500"
class="rounded-2xl border border-dashed border-[color:var(--admin-border-strong)] bg-[color:var(--admin-bg-muted)] px-3 py-6 text-center text-sm text-[color:var(--admin-text-secondary)]"
>
{{ emptyText }}
</div>
+24
View File
@@ -94,5 +94,29 @@
z-index: 1300 !important;
}
// Ant Design در RTL برای لیبل افقی text-align:left می‌گذارد؛ روی موبایل/عمودی
// عنوان فیلدها سمت چپ می‌افتد. در پنل فارسی همیشه راست‌چین باشن.
.ant-form-item-label,
.ant-form-rtl .ant-form-item-label,
.ant-form-vertical .ant-form-item-label,
.ant-col-24.ant-form-item-label {
text-align: right !important;
}
.ant-form-item-label > label {
justify-content: flex-start;
}
// فرم‌ها ستونی: عنوان بالا، کنترل پایین (inline مستثنی)
.ant-form:not(.ant-form-inline) .ant-form-item.ant-row {
display: flex !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
}
.ant-form:not(.ant-form-inline) .ant-form-item > .ant-form-item-label,
.ant-form:not(.ant-form-inline) .ant-form-item > .ant-form-item-control {
flex: 0 0 auto !important;
max-width: 100% !important;
width: 100% !important;
}
@@ -1,6 +1,6 @@
<template>
<span
class="dashboard-label-with-help inline-flex max-w-full items-baseline gap-1.5"
class="dashboard-label-with-help inline-flex max-w-full items-baseline justify-start gap-1.5 text-right"
:class="[textClass || sizeTextClass, rootClass]"
>
<span class="leading-normal" :class="labelClass">{{ label }}</span>
@@ -115,7 +115,7 @@ export const SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [
title: 'عمومی و برند',
icon: 'vuesax-linear:home-2',
match: (k) =>
/^(name_fa|name_en|app_url|admin_url|apk_download_link|banner_enabled|bace_url|server_url|logo_|title_pwa|title_fav|metal_type|main_metal|unit$|open_register|auto_active_customer|store_status|key$|is_store|group_sms|has_page_earn_money|sms_panel)/.test(
/^(name_fa|name_en|app_url|admin_url|apk_download_link|banner_enabled|permision__enabled|bace_url|server_url|logo_|title_pwa|title_fav|metal_type|main_metal|unit$|open_register|auto_active_customer|store_status|key$|is_store|group_sms|has_page_earn_money|sms_panel)/.test(
k
)
},
@@ -53,7 +53,16 @@ export type SupportMessengerEntryDraft = {
id: string
}
const SUPPORT_MESSENGER_TYPES = ['bale', 'telegram', 'whatsapp', 'rubika', 'eitaa', 'soroush'] as const
const SUPPORT_MESSENGER_TYPES = [
'bale',
'telegram',
'whatsapp',
'rubika',
'eitaa',
'soroush',
'call',
'sms',
] as const
export function isSupportMessengerType(value: string | undefined | null): boolean {
const type = (value ?? '').trim().toLowerCase()
@@ -158,6 +167,9 @@ export function supportMessengerIdPlaceholder(messenger?: string): string {
case 'eitaa':
case 'soroush':
return 'یوزرنیم یا آیدی'
case 'call':
case 'sms':
return 'شماره موبایل، مثلاً 09123456789'
default:
return 'شناسه / یوزرنیم / شماره'
}
@@ -256,6 +268,7 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
external_order_api_enabled: 'switch',
is_store: 'switch',
banner_enabled: 'switch',
permision__enabled: 'switch',
apk_download_link: 'text',
node_client_id: 'text',
node_shared_secret: 'text',
@@ -307,7 +320,9 @@ const SELECT_LABELS_BY_KEY: Record<string, Record<string, string>> = {
whatsapp: 'واتساپ',
rubika: 'روبیکا',
eitaa: 'ایتا',
soroush: 'سروش'
soroush: 'سروش',
call: 'تماس تلفنی',
sms: 'پیامک',
},
price_channel_messengers: {
bale: 'بله',
@@ -321,7 +336,7 @@ const SELECT_OPTIONS_BY_KEY: Record<string, string> = {
price_provider: 'montzar,zaarbox,main_product',
payping_gateway_name: 'sepal,',
unit: 'گرم,کیلوگرم',
support_messenger: 'bale,telegram,whatsapp,rubika,eitaa,soroush',
support_messenger: 'bale,telegram,whatsapp,rubika,eitaa,soroush,call,sms',
price_channel_messengers: 'bale,telegram,rubika'
}
@@ -484,6 +499,8 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
'نام فارسی برند که در پیامک‌ها، عنوان صفحات و بخش‌های عمومی اپ به مشتری نشان داده می‌شود.',
banner_enabled:
'روشن: منوی «بنرها» در تنظیمات فعال می‌شود و می‌توانید اسلایدر بالای صفحهٔ اصلی اپ را مدیریت کنید. خاموش: بنرها در اپ نشان داده نمی‌شوند.',
permision__enabled:
'روشن: آیتم «مجوز ها» در منوی تنظیمات اپ خانگی (تم جدید) نمایش داده می‌شود. خاموش: مخفی است.',
open_register:
'روشن: کاربران جدید می‌توانند ثبت‌نام کنند. خاموش: فقط ورود کاربران قبلی مجاز است.',
sms_panel:
@@ -571,7 +588,7 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
node_shared_secret:
'کلید مشترک امضای درخواست‌ها به سرویس سفارش خارجی. محرمانه نگه دارید.',
support_messengers:
'لیست پیام‌رسان‌های پشتیبانی. اگر چند مورد باشد، با کلیک دکمه شناور منوی انتخاب باز می‌شود؛ اگر یک مورد باشد مستقیم باز می‌شود. تماس تلفنی جداگانه از «شماره تماس پشتیبانی» است.',
'فقط مواردی که در این لیست ثبت شوند در منوی شناور پشتیبانی اپ مشتری نمایش داده می‌شوند. برای تماس تلفنی یا پیامک، نوع «تماس تلفنی» / «پیامک» را با شماره اضافه کنید.',
tracking_systems:
'لیست JSON سامانه‌های ردیابی/آنالیتیکس در اپ مشتری. هر مورد: type (gtag، gtm، clarity، custom)، id (کد اندازه‌گیری یا شناسه پروژه)، enabled (روشن/خاموش). فقط از صفحه دیپلوی قابل ویرایش است.',
tracking_systems_enabled:
@@ -583,7 +600,7 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
support_messenger_text:
'متن راهنمای واریز در مودال شارژ حساب. متن را بنویسید و متغیرها (نام صاحب حساب، شماره کارت، شبا) را با کلیک اضافه کنید؛ پیش‌نمایش نتیجه را با مقادیر نمونه نشان می‌دهد. خالی = متن پیش‌فرض اپ.',
deposit_support_message_text:
'متن پیام هنگام کلیک «پیام به پشتیبانی» در مودال واریز (SMS و پیام‌رسان). متن را بنویسید و متغیر «نام مشتری» را با کلیک اضافه کنید؛ پیش‌نمایش نتیجه را نشان می‌دهد. خالی = متن پیش‌فرض اپ.',
'متن پیش‌نویس فقط وقتی کاربر از مودال راهنمای واریز روی «پیام به پشتیبانی» یا SMS می‌زند. منوی شناور پشتیبانی این متن را نمی‌گذارد. خالی = متن پیش‌فرض اپ.',
deposit_account_holder:
'نام صاحب حساب واریز — در متن راهنمای واریز با ٪نام یا %نام جایگزین می‌شود. در پیام پشتیبانی فقط وقتی نام مشتری موجود نباشد استفاده می‌شود.',
deposit_card_number:
@@ -591,7 +608,7 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
deposit_sheba:
'شماره شبا واریز — در متن‌ها با ٪شبا یا %شبا جایگزین می‌شود.',
deposit_sms_support_number:
'شماره تماس پشتیبانی؛ با کلیک روی «تماس با پشتیبانی» در اپ، تماس تلفنی برقرار می‌شود. در صفحه واریز، درباره ما و قوانین هم نمایش داده می‌شود.',
'شماره تماس پشتیبانی برای راهنمای واریز و دکمه پیامک داخل مودال شارژ. برای نمایش در منوی شناور، باید «تماس تلفنی» یا «پیامک» را جداگانه در لیست پیام‌رسان‌های پشتیبانی اضافه کنید.',
charge_notif:
'روشن: پس از شارژ کیف توسط ادمین، پیامک به مشتری ارسال می‌شود. خاموش: پیامکی پس از شارژ ارسال نمی‌شود.',
group_sms:
@@ -728,6 +745,7 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
support_messengers: 'پیام‌رسان‌های پشتیبانی',
tracking_systems: 'سامانه‌های ردیابی',
tracking_systems_enabled: 'نمایش سامانه‌های ردیابی',
permision__enabled: 'نمایش منوی مجوزها',
support_messenger: 'پیام‌رسان پشتیبانی',
support_messenger_id: 'شناسه پشتیبانی',
support_messenger_text: 'متن راهنمای واریز',
@@ -773,6 +791,7 @@ export const SETTING_LOCKED_KEYS = new Set([
'payping_fin_customer_id', 'payping_fin_sync_enabled', 'payping_fin_token', 'payping_fin_url',
'payping_gateway_items', 'payping_gateway_name',
'payping_round_toman', 'payping_start_url', 'payping_strict_failed_withdraw', 'payping_verify_url',
'permision__enabled',
'price_extreme_date', 'price_provider', 'profit_gold_api_enabled',
'send_to_tahesab', 'send_to_pargar', 'accounting_provider', 'service_charge_percentage',
'sse_path',
@@ -4,17 +4,24 @@
ref="innerFormRef"
autocomplete="off"
scroll-to-first-error
layout="vertical"
label-align="right"
class="product-editor-form"
>
<Collapse class="grid grid-cols-1 gap-2" :activeKey="activeKey" @update:activeKey="$emit('update:activeKey', $event)">
<BaseCollapse title="قیمت‌گذاری" active-key="0" :actives="activeKey">
<template #content>
<a-tabs
class="product-editor-tabs"
:activeKey="activeKey"
@update:activeKey="$emit('update:activeKey', $event)"
>
<a-tab-pane key="0" tab="قیمت‌گذاری" force-render>
<div
v-if="isMeltedCategory"
v-if="usesMeltedPricing"
class="product-editor-panel flex flex-col gap-3"
>
<div class="flex flex-wrap items-center gap-3">
<div class="product-spec-switch-row">
<div class="product-spec-label">
<DashboardLabelWithHelp label="مرجع قیمت" :help="meltedPriceSourceHelp" size="sm" />
</div>
<a-radio-group :value="meltedPriceOption" name="meltedPriceGroup" @update:value="$emit('update:meltedPriceOption', $event)">
<a-radio value="app">قیمت اپ</a-radio>
<a-radio value="fixed">ثابت</a-radio>
@@ -76,9 +83,11 @@
</div>
</div>
<div v-if="!isMeltedCategory" class="product-editor-panel flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<div v-if="!usesMeltedPricing" class="product-editor-panel flex flex-col gap-4">
<div class="product-spec-switch-row">
<div class="product-spec-label">
<DashboardLabelWithHelp label="دریافت قیمت از" :help="priceSourceHelp" size="sm" />
</div>
<a-radio-group :value="methodOption" name="radioGroup" @update:value="$emit('update:methodOption', $event)">
<a-radio value="1">دستی</a-radio>
<a-radio value="2">اتوماتیک</a-radio>
@@ -178,41 +187,70 @@
</AButton>
</div>
</div>
</template>
</BaseCollapse>
</a-tab-pane>
<BaseCollapse title="مشخصات محصول" active-key="1" :actives="activeKey">
<template #content>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<a-form-item name="title" :rules="inputRules()">
<a-tab-pane key="1" tab="مشخصات محصول" force-render>
<div class="flex flex-col gap-4">
<div class="grid gap-4 md:grid-cols-3">
<a-form-item name="title" :rules="inputRules()" class="!mb-0">
<template #label>
<DashboardLabelWithHelp label="عنوان" :help="titleHelp" size="sm" />
</template>
<BasicInput v-model:value="form.title" />
</a-form-item>
<a-form-item name="group_type" :rules="inputRules()">
<a-form-item name="group_type" :rules="inputRules()" class="!mb-0">
<template #label>
<DashboardLabelWithHelp label="دسته‌بندی" :help="categoryHelp" size="sm" />
</template>
<BasicSelect v-model:value="form.group_type" :items="categoryItems" valueName="value" keyName="key" />
</a-form-item>
<a-form-item name="metal_id" :rules="inputRules()">
<a-form-item name="metal_id" :rules="inputRules()" class="!mb-0">
<template #label>
<DashboardLabelWithHelp label="جنس فلز" :help="metalHelp" size="sm" />
</template>
<BasicSelect v-model:value="form.metal_id" :items="metalItems" valueName="value" keyName="key" />
</a-form-item>
</div>
<template v-if="!isMeltedCategory">
<div class="md:col-span-3 product-editor-panel">
<div class="flex flex-wrap items-center gap-3 mb-3">
<div v-if="!isMeltedGroup" class="product-editor-panel flex flex-col gap-4">
<div class="product-spec-switch-row">
<div class="product-spec-label">
<DashboardLabelWithHelp label="نوع معامله" :help="tradeByQuantityHelp" size="sm" />
</div>
<a-radio-group
v-if="!editId"
v-model:value="tradeTypeOption"
name="tradeTypeGroup"
>
<a-radio value="count">معامله تعدادی</a-radio>
<a-radio value="quantity">معامله مقداری</a-radio>
</a-radio-group>
<span
v-else
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-600"
:class="form.trade_by_quantity ? 'bg-emerald-100 text-emerald-800' : 'bg-sky-100 text-sky-800'"
>
{{ resolveProductTradeTypeLabel(form.trade_by_quantity) }}
</span>
</div>
<div
v-if="isQuantityTrade"
class="rounded-lg border border-solid border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-6 text-amber-800"
>
معامله مقداری: مشتری بر اساس مقدار (گرم/کیلوگرم) خرید می‌کند. قیمت‌گذاری این محصول در تب «قیمت‌گذاری» به‌صورت مقداری تنظیم می‌شود.
</div>
<div class="product-spec-switch-row">
<div class="product-spec-label">
<DashboardLabelWithHelp label="وزن محصول" :help="weightHelp" size="sm" />
</div>
<a-radio-group :value="weightOption" name="weightGroup" @update:value="$emit('update:weightOption', $event)">
<a-radio value="1">دارد</a-radio>
<a-radio value="2">ندارد</a-radio>
</a-radio-group>
</div>
<div v-if="weightOption === '1'" class="grid gap-4 md:grid-cols-3">
<div v-if="weightOption === '1'" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<a-form-item name="amount" :rules="inputRulesNumber('وزن')" class="!mb-0">
<template #label>
<DashboardLabelWithHelp :label="weightFieldLabel" :help="weightAmountHelp" size="sm" />
@@ -227,12 +265,16 @@
</div>
</div>
</div>
</template>
<div
v-if="showUnitField || showDirectPriceField || (isStore && !isMeltedGroup)"
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<a-form-item
v-if="isMeltedCategory || weightOption === '1'"
v-if="showUnitField"
name="unit"
:rules="showUnitField ? inputRules() : []"
:rules="inputRules()"
class="!mb-0"
>
<template #label>
<DashboardLabelWithHelp label="واحد" :help="unitHelp" size="sm" />
@@ -243,6 +285,7 @@
v-if="showDirectPriceField"
name="direct_price"
:rules="inputRules()"
class="!mb-0"
>
<template #label>
<DashboardLabelWithHelp label="نوع قیمت" :help="directPriceHelp" size="sm" />
@@ -250,47 +293,30 @@
<BasicSelect v-model:value="form.direct_price" :items="priceItems" valueName="value" keyName="key" />
</a-form-item>
<a-form-item
v-if="isStore && !isMeltedCategory"
v-if="isStore && !isMeltedGroup"
name="inventory"
:rules="inputRules()"
class="!mb-0"
>
<template #label>
<DashboardLabelWithHelp label="تعداد موجودی" :help="inventoryHelp" size="sm" />
</template>
<BasicInput v-model:value="form.inventory" type="number" />
</a-form-item>
<a-form-item
v-if="!isMeltedCategory && !editId"
name="trade_by_quantity"
>
<template #label>
<DashboardLabelWithHelp label="نوع معامله" :help="tradeByQuantityHelp" size="sm" />
</template>
<a-radio-group v-model:value="tradeTypeOption" name="tradeTypeGroup">
<a-radio value="count">معامله تعدادی</a-radio>
<a-radio value="quantity">معامله مقداری</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-else-if="!isMeltedCategory && editId">
<template #label>
<DashboardLabelWithHelp label="نوع معامله" :help="tradeByQuantityHelp" size="sm" />
</template>
<div class="pt-1">
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-600"
:class="form.trade_by_quantity ? 'bg-emerald-100 text-emerald-800' : 'bg-sky-100 text-sky-800'"
>
{{ resolveProductTradeTypeLabel(form.trade_by_quantity) }}
</span>
</div>
<div class="product-editor-panel product-spec-switch-row">
<div class="product-spec-label">
<DashboardLabelWithHelp label="نمایش در دارایی‌ها" :help="showInAssetsHelp" size="sm" />
</div>
<a-form-item name="show_in_assets" class="!mb-0">
<a-switch v-model:checked="form.show_in_assets" />
</a-form-item>
</div>
</template>
</BaseCollapse>
</div>
</a-tab-pane>
<BaseCollapse title="کارمزد و محدودیت‌ها" active-key="2" :actives="activeKey">
<template #content>
<a-tab-pane key="2" tab="اختلاف خرید و فروش" force-render>
<div class="grid gap-4 md:grid-cols-2">
<a-form-item name="cash_fee_buy" :rules="requiredPriceRules('هزینه نقدی موقع خرید')">
<template #label>
@@ -340,19 +366,20 @@
/>
</div>
</a-form-item>
<a-form-item name="show_in_assets">
<template #label>
<DashboardLabelWithHelp label="نمایش در دارایی‌ها" :help="showInAssetsHelp" size="sm" />
</template>
<a-switch v-model:checked="form.show_in_assets" />
</a-form-item>
</div>
</a-tab-pane>
<a-form-item name="override_trading_hours">
<template #label>
<a-tab-pane key="3" tab="ساعت کاری" force-render>
<div class="flex flex-col gap-4">
<div class="product-editor-panel product-spec-switch-row">
<div class="product-spec-label">
<DashboardLabelWithHelp label="ساعت کاری اختصاصی" :help="overrideTradingHoursHelp" size="sm" />
</template>
</div>
<a-form-item name="override_trading_hours" class="!mb-0">
<a-switch v-model:checked="form.override_trading_hours" />
</a-form-item>
</div>
<div class="grid gap-4 md:grid-cols-2">
<a-form-item name="auto_open_at">
<template #label>
<DashboardLabelWithHelp label="زمان شروع" :help="autoOpenHelp" size="sm" />
@@ -379,15 +406,21 @@
class="w-full"
/>
</a-form-item>
</div>
</div>
</a-tab-pane>
<a-form-item name="order_limit_enabled">
<template #label>
<a-tab-pane key="4" tab="محدودیت سفارش" force-render>
<div class="flex flex-col gap-4">
<div class="product-editor-panel product-spec-switch-row">
<div class="product-spec-label">
<DashboardLabelWithHelp label="محدودیت مبلغ سفارش" :help="orderLimitHelp" size="sm" />
</template>
</div>
<a-form-item name="order_limit_enabled" class="!mb-0">
<a-switch v-model:checked="form.order_limit_enabled" @change="onOrderLimitChange" />
</a-form-item>
<template v-if="form.order_limit_enabled">
</div>
<div v-if="form.order_limit_enabled" class="grid gap-4 md:grid-cols-2">
<a-form-item name="min_order_cash">
<template #label>
<DashboardLabelWithHelp label="حداقل مبلغ سفارش" :help="minOrderHelp" size="sm" />
@@ -400,15 +433,22 @@
</template>
<BasicInput v-model:value="form.max_order_cash" addon-before="ریال" type="price" />
</a-form-item>
</template>
</div>
</div>
</a-tab-pane>
<a-form-item name="has_physical_delivery">
<template #label>
<a-tab-pane key="5" tab="تحویل فیزیکی" force-render>
<div class="flex flex-col gap-4">
<div class="product-editor-panel product-spec-switch-row">
<div class="product-spec-label">
<DashboardLabelWithHelp label="تحویل فیزیکی" :help="physicalDeliveryHelp" size="sm" />
</template>
</div>
<a-form-item name="has_physical_delivery" class="!mb-0">
<a-switch v-model:checked="form.has_physical_delivery" @change="onPhysicalDeliveryChange" />
</a-form-item>
<a-form-item v-if="form.has_physical_delivery" name="min_physical_delivery_quantity">
</div>
<div v-if="form.has_physical_delivery" class="grid gap-4 md:grid-cols-2">
<a-form-item name="min_physical_delivery_quantity">
<template #label>
<DashboardLabelWithHelp :label="minPhysicalDeliveryLabel" :help="minPhysicalDeliveryHelp" size="sm" />
</template>
@@ -419,11 +459,10 @@
/>
</a-form-item>
</div>
</template>
</BaseCollapse>
</div>
</a-tab-pane>
<BaseCollapse title="تصاویر محصول" active-key="3" :actives="activeKey">
<template #content>
<a-tab-pane key="6" tab="تصاویر محصول" force-render>
<IndexImageUploader
:key="editId ?? 'new-product'"
:file-list="productIndexImage"
@@ -431,25 +470,23 @@
@ok="(id, res) => $emit('uploadedIndexFile', id, res)"
@delete="$emit('deleteIndexFile')"
/>
</template>
</BaseCollapse>
</Collapse>
</a-tab-pane>
</a-tabs>
</a-form>
</template>
<script setup lang="ts">
import type { FormInstance } from 'ant-design-vue'
import { Collapse, TimePicker } from 'ant-design-vue'
import { TimePicker } from 'ant-design-vue'
import { inputRules, inputRulesNumber, requiredPriceRules } from '@/utils/inputRules'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import BaseCollapse from '@/components/BaseCollapse.vue'
import IndexImageUploader from '@/components/ProductUploader/IndexImageUploader.vue'
import { getConfiguration } from '@/api/configuration'
import { isSwitchOn } from '@/pages/menuPages/configuration/settingTypes'
import type { last } from '@/model/price'
import type { MethodePrices } from '@/model/product'
import { usePrice } from '@core/utils/usePrice'
import { resolveProductTradeTypeLabel, usesMeltedPriceEditor } from '@/utils/productCategories'
import { isMeltedTradeGroup, resolveProductTradeTypeLabel, usesMeltedPriceEditor } from '@/utils/productCategories'
import { computeProductEditorPricePreview } from '@/utils/productEditorPrice'
import { resolveTradeWeightUnit, weightFieldLabelForUnit } from '@/utils/weightUnit'
import type { DashboardHelp } from '@/pages/dashboard/dashboardSectionCopy'
@@ -590,7 +627,7 @@ const props = defineProps<{
methodOption: string
weightOption: string
meltedPriceOption: string
activeKey: string[]
activeKey: string
pricesList: Array<{ id: string; name: string }>
pricesData: MethodePrices[]
appLastPrice: (last & { price?: number }) | null
@@ -607,18 +644,24 @@ const props = defineProps<{
const isStore = ref(Boolean(props.isStore))
const tradeWeightUnit = ref('گرم')
const isMeltedCategory = computed(() => usesMeltedPriceEditor(props.form))
/** دسته آبشده/کاتد — ساختار فرم ثابت است */
const isMeltedGroup = computed(() => isMeltedTradeGroup(props.form.group_type))
/** قیمت‌گذاری مقداری: آبشده/کاتد یا معامله مقداری */
const usesMeltedPricing = computed(() => usesMeltedPriceEditor(props.form))
const isQuantityTrade = computed(
() => props.form.trade_by_quantity === true || Number(props.form.trade_by_quantity) === 1
)
const tradeTypeOption = computed({
get: () => (props.form.trade_by_quantity ? 'quantity' : 'count'),
get: () => (isQuantityTrade.value ? 'quantity' : 'count'),
set: (value: 'quantity' | 'count') => {
props.form.trade_by_quantity = value === 'quantity'
},
})
const showDirectPriceField = computed(() => !isMeltedCategory.value && props.methodOption === '1')
const showDirectPriceField = computed(() => !usesMeltedPricing.value && props.methodOption === '1')
const showUnitField = computed(() => isMeltedCategory.value || props.weightOption === '1')
const showUnitField = computed(() => usesMeltedPricing.value || props.weightOption === '1')
const livePricePreview = computed(() =>
computeProductEditorPricePreview({
@@ -633,7 +676,7 @@ const livePricePreview = computed(() =>
watch(
() => props.methodOption,
(option) => {
if (isMeltedCategory.value) {
if (usesMeltedPricing.value) {
return
}
if (option === '3') {
@@ -697,7 +740,7 @@ const physicalDeliveryUnit = computed(() => {
return productUnit
}
if (isMeltedCategory.value) {
if (usesMeltedPricing.value) {
return tradeWeightUnit.value
}
@@ -727,7 +770,7 @@ defineEmits<{
(e: 'update:methodOption', value: string): void
(e: 'update:weightOption', value: string): void
(e: 'update:meltedPriceOption', value: string): void
(e: 'update:activeKey', value: string[]): void
(e: 'update:activeKey', value: string): void
(e: 'update:productIndexImage', value: any[]): void
(e: 'uploadedIndexFile', id: number, response?: Record<string, any>): void
(e: 'deleteIndexFile'): void
@@ -749,11 +792,25 @@ defineExpose({
</script>
<style scoped>
.product-editor-tabs :deep(.ant-tabs-nav) {
margin-bottom: 1rem;
}
.product-editor-tabs :deep(.ant-tabs-tab) {
padding: 0.5rem 0.75rem;
}
.product-editor-panel {
border: 1px solid var(--ant-color-border-secondary, rgba(0, 0, 0, 0.06));
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.product-spec-switch-row {
@apply flex flex-col items-stretch gap-2;
}
.product-spec-label {
@apply w-full shrink-0 text-right;
}
.product-editor-form :deep(.ant-form-item-label > label) {
@@ -5,7 +5,7 @@
<span class="inline-block h-2.5 w-2.5 shrink-0 rounded-full" :class="statusDotClass" />
<button
type="button"
class="truncate border-none bg-transparent p-0 text-right text-sm font-700 text-gray-900 cursor-pointer"
class="product-mobile-item__title truncate border-none bg-transparent p-0 text-right text-sm font-700 cursor-pointer"
@click="$emit('open', item)"
>
{{ item.title || '-' }}
@@ -15,8 +15,8 @@
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<span class="text-xs text-gray-500">کد محصول: {{ item.id ?? '-' }}</span>
<span class="text-sm font-600 text-gray-700">{{ priceLabel }}</span>
<span class="product-mobile-item__meta text-xs">کد محصول: {{ item.id ?? '-' }}</span>
<span class="product-mobile-item__price text-sm font-600">{{ priceLabel }}</span>
</div>
</MobileCard>
</template>
@@ -39,3 +39,17 @@ defineEmits<{
const statusDotClass = computed(() => (props.item.active ? 'bg-green-500' : 'bg-red-400'))
const priceLabel = computed(() => (props.item.price ? usePrice(props.item.price) : '-'))
</script>
<style scoped>
.product-mobile-item__title {
color: var(--admin-text);
}
.product-mobile-item__meta {
color: var(--admin-text-secondary);
}
.product-mobile-item__price {
color: var(--admin-text-muted);
}
</style>
+40 -12
View File
@@ -15,7 +15,6 @@
@cancel="closeProductModal"
@close="closeProductModal"
>
<div v-if="isMobile" class="mb-3 text-base font-700">{{ `${editId ? 'ویرایش' : 'افزودن'} محصول` }}</div>
<a-alert
v-if="submitError"
type="error"
@@ -76,7 +75,6 @@ import type { MethodePrices, Product } from '@/model/product'
import ProductEditorFormBody from '@/pages/menuPages/products/base/components/ProductEditorFormBody.vue'
import { validateProductSubmitInput } from '@/pages/menuPages/products/base/productFormValidation'
import tbl from './components/table.vue'
import { isMobile } from '@core/utils/is'
import { usePrice } from '@core/utils/usePrice'
import { referenceProviderTotal } from '@/utils/productEditorPrice'
import {
@@ -110,7 +108,7 @@ const methodOption = ref('1')
const weightOption = ref('1')
const meltedPriceOption = ref('app')
const tradeWeightUnit = ref('گرم')
const activeKey_ = ref(['0'])
const activeKey_ = ref('0')
const ProductIndexImage = ref<Array<FileList>>([])
const addUserVisible = ref(false)
let editId: number | undefined
@@ -414,6 +412,38 @@ function showSubmitError(text: string) {
message.error(text, 4)
}
const PRICING_FIELDS = new Set([
'price',
'reading_method',
'price_offset_percent',
'use_last_price_if_zero',
])
const DETAILS_FIELDS = new Set([
'title',
'group_type',
'metal_id',
'amount',
'unit',
'direct_price',
'inventory',
'trade_by_quantity',
'show_in_assets',
])
const FEES_FIELDS = new Set(['cash_fee_buy', 'cash_fee_sell'])
const HOURS_FIELDS = new Set(['override_trading_hours', 'auto_open_at', 'auto_close_at'])
const ORDER_FIELDS = new Set(['order_limit_enabled', 'min_order_cash', 'max_order_cash'])
const DELIVERY_FIELDS = new Set(['has_physical_delivery', 'min_physical_delivery_quantity'])
function tabKeyForField(fieldName: string): string {
if (PRICING_FIELDS.has(fieldName)) return '0'
if (DETAILS_FIELDS.has(fieldName)) return '1'
if (FEES_FIELDS.has(fieldName)) return '2'
if (HOURS_FIELDS.has(fieldName)) return '3'
if (ORDER_FIELDS.has(fieldName)) return '4'
if (DELIVERY_FIELDS.has(fieldName)) return '5'
return '6'
}
async function submitProduct() {
try {
await saveProduct()
@@ -425,13 +455,6 @@ async function submitProduct() {
}
async function saveProduct() {
const formSectionKeys = ['0', '1', '2']
const nextKeys = [...new Set([...formSectionKeys, ...activeKey_.value])]
if (nextKeys.length !== activeKey_.value.length) {
activeKey_.value = nextKeys
await nextTick()
}
const businessError = validateProductSubmitInput({
form: form.value as Record<string, unknown>,
methodOption: methodOption.value,
@@ -452,8 +475,12 @@ async function saveProduct() {
try {
await editorFormRef.value.validateFields()
} catch (err: unknown) {
const first = (err as { errorFields?: Array<{ errors?: string[] }> })?.errorFields?.[0]?.errors?.[0]
showSubmitError(first || 'لطفاً فیلدهای الزامی را تکمیل کنید')
const errorFields = (err as { errorFields?: Array<{ name?: Array<string | number>; errors?: string[] }> })?.errorFields
const firstField = errorFields?.[0]
const fieldName = String(firstField?.name?.[0] ?? '')
activeKey_.value = tabKeyForField(fieldName)
await nextTick()
showSubmitError(firstField?.errors?.[0] || 'لطفاً فیلدهای الزامی را تکمیل کنید')
return
}
@@ -463,6 +490,7 @@ async function saveProduct() {
const payload = cloneDeep(form.value)
const limitError = validateProductOrderLimitForm(payload)
if (limitError) {
activeKey_.value = '4'
showSubmitError(limitError)
loading.value = false
return