fix bug 22/4 2

This commit is contained in:
2026-07-13 19:03:03 +03:30
parent 61235b3bf8
commit 76813df062
15 changed files with 1020 additions and 70 deletions
+2 -1
View File
@@ -30,12 +30,13 @@ export interface Product{
override_trading_hours?: boolean
use_last_price_if_zero?: boolean
has_physical_delivery?: boolean
min_physical_delivery_grams?: number | null
min_physical_delivery_quantity?: number | null
title: string
updated_at: string
methodOption: number
prices: MethodePrices[]
direct_price: number
app_price?: boolean
price: number
price_profit?: number
price_offset_percent?: number
@@ -12,7 +12,18 @@
<span class="setting-card__title">{{ title }}</span>
</div>
<div class="setting-card__key">کلید: {{ settingKey }}</div>
<div v-if="help" class="setting-card__help">{{ help }}</div>
<div v-if="switchHelp" class="setting-card__help setting-card__help--switch">
<div v-if="switchHelp.note" class="setting-card__help-note">{{ switchHelp.note }}</div>
<div v-if="switchHelp.on" class="setting-card__help-state">
<span class="setting-card__help-badge setting-card__help-badge--on">روشن</span>
<span class="setting-card__help-text">{{ switchHelp.on }}</span>
</div>
<div v-if="switchHelp.off" class="setting-card__help-state">
<span class="setting-card__help-badge setting-card__help-badge--off">خاموش</span>
<span class="setting-card__help-text">{{ switchHelp.off }}</span>
</div>
</div>
<div v-else-if="help" class="setting-card__help">{{ help }}</div>
</div>
<div
class="setting-card__body"
@@ -41,6 +52,7 @@
import SettingValueField from './SettingValueField.vue'
import {
helpText,
parseSwitchHelp,
resolveSettingTitle,
resolveSettingFieldType,
type SettingFieldType
@@ -70,6 +82,10 @@ const fieldType = computed<SettingFieldType>(() =>
)
const help = computed(() => helpText(props.row))
const switchHelp = computed(() =>
fieldType.value === 'switch' ? parseSwitchHelp(help.value) : null
)
</script>
<style lang="less" scoped>
@@ -136,6 +152,54 @@ const help = computed(() => helpText(props.row))
line-height: 1.35;
}
&__help--switch {
display: flex;
flex-direction: column;
gap: 4px;
}
&__help-note {
font-size: 11px;
line-height: 1.5;
color: var(--admin-text-secondary);
}
&__help-state {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 11px;
line-height: 1.6;
}
&__help-badge {
flex-shrink: 0;
min-width: 42px;
text-align: center;
padding: 1px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
line-height: 1.6;
&--on {
color: #047857;
background: #d1fae5;
border: 1px solid #a7f3d0;
}
&--off {
color: #b91c1c;
background: #fee2e2;
border: 1px solid #fecaca;
}
}
&__help-text {
color: var(--admin-text-secondary);
padding-top: 1px;
}
&__body {
margin-top: 12px;
flex: 1;
@@ -0,0 +1,124 @@
<template>
<div
class="setting-toggle"
:class="{
'setting-toggle--on': checked,
'setting-toggle--off': !checked,
'setting-toggle--disabled': disabled,
}"
role="switch"
:aria-checked="checked"
>
<button
type="button"
class="setting-toggle__seg"
:class="{ 'setting-toggle__seg--active': !checked }"
:disabled="disabled"
@click="setChecked(false)"
>
<BaseIcon icon="vuesax-linear:close-circle" size="15" />
<span>{{ labels.off }}</span>
</button>
<button
type="button"
class="setting-toggle__seg"
:class="{ 'setting-toggle__seg--active': checked }"
:disabled="disabled"
@click="setChecked(true)"
>
<BaseIcon icon="vuesax-linear:tick-circle" size="15" />
<span>{{ labels.on }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import BaseIcon from '@core/components/BaseIcon/src/Icon.vue'
const props = withDefaults(
defineProps<{
checked: boolean
labels: { on: string; off: string }
disabled?: boolean
}>(),
{ disabled: false },
)
const emit = defineEmits<{
change: [checked: boolean]
}>()
function setChecked(next: boolean) {
if (props.disabled || next === props.checked) return
emit('change', next)
}
</script>
<style lang="less" scoped>
.setting-toggle {
display: inline-flex;
align-items: stretch;
gap: 4px;
padding: 4px;
border-radius: 12px;
background: var(--admin-bg-muted, #f1f5f9);
border: 1px solid var(--admin-border, #e2e8f0);
user-select: none;
&--disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.setting-toggle__seg {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 84px;
padding: 6px 14px;
border: none;
border-radius: 9px;
background: transparent;
color: var(--admin-text-secondary, #64748b);
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.18s ease;
:deep(svg) {
opacity: 0.75;
}
&:disabled {
cursor: not-allowed;
}
&--active {
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
}
/* حالت روشن → سگمنت فعال سبز */
.setting-toggle--on .setting-toggle__seg--active {
background: #10b981;
color: #fff;
:deep(svg) {
opacity: 1;
}
}
/* حالت خاموش → سگمنت فعال خاکستری قرمزفام */
.setting-toggle--off .setting-toggle__seg--active {
background: #fff;
color: #ef4444;
:deep(svg) {
opacity: 1;
}
}
</style>
@@ -1,14 +1,12 @@
<template>
<div class="setting-value-field">
<a-switch
<SettingToggle
v-if="fieldType === 'switch'"
:checked="switchChecked"
:labels="labels"
:disabled="disabled"
@change="onSwitchChange"
>
<template #checkedChildren>{{ labels.on }}</template>
<template #unCheckedChildren>{{ labels.off }}</template>
</a-switch>
/>
<SupportMessengersField
v-else-if="isSupportMessengers"
@@ -38,6 +36,15 @@
@update:value="emit('update:value', $event)"
/>
<TemplateTextField
v-else-if="isTemplateField"
:model-value="modelValue"
:setting-key="settingKey || ''"
:disabled="disabled"
@update:value="emit('update:value', $event)"
@blur="emit('blur')"
/>
<a-select
v-else-if="fieldType === 'select'"
:value="modelValue || undefined"
@@ -124,8 +131,11 @@
<script setup lang="ts">
import MainWalletProductsField from './MainWalletProductsField.vue'
import PartnerServerUrlsField from './PartnerServerUrlsField.vue'
import SettingToggle from './SettingToggle.vue'
import SupportMessengersField from './SupportMessengersField.vue'
import TemplateTextField from './TemplateTextField.vue'
import ZaarboxPriceKeyField from './ZaarboxPriceKeyField.vue'
import { hasTemplateField } from './templateFields'
import {
parseSelectOptions,
resolveSettingFieldType,
@@ -163,6 +173,7 @@ const isZaarboxPriceKey = computed(() => props.settingKey === 'zaarbox_price_key
const isMainWalletProducts = computed(() => props.settingKey === 'main_wallet_products')
const isSupportMessengers = computed(() => props.settingKey === 'support_messengers')
const isPartnerServerUrls = computed(() => props.settingKey === 'zaarbox_api_url')
const isTemplateField = computed(() => hasTemplateField(props.settingKey))
const selectItems = computed(() =>
parseSelectOptions({
@@ -0,0 +1,332 @@
<template>
<div class="tpl-field">
<div class="tpl-field__grid">
<!-- ویرایشگر قالب -->
<div class="tpl-field__editor">
<div class="tpl-field__section-title">
<BaseIcon icon="vuesax-linear:edit-2" size="15" />
<span>{{ config.editorTitle }}</span>
</div>
<textarea
ref="textareaRef"
class="tpl-field__textarea"
:value="modelValue"
:disabled="disabled"
:rows="config.rows"
spellcheck="false"
:placeholder="config.placeholder"
@input="onInput"
@blur="emit('blur')"
/>
<div class="tpl-field__hint">
<BaseIcon icon="vuesax-linear:info-circle" size="13" />
<span>
{{ config.hint }} متغیرها با <code>%</code> یا <code>٪</code> نوشته می‌شوند.
</span>
</div>
<!-- پالت متغیرها -->
<div class="tpl-field__vars">
<div v-for="group in config.variableGroups" :key="group.title" class="tpl-vargroup">
<div class="tpl-vargroup__head">
<span class="tpl-vargroup__dot" :style="{ background: group.color }" />
<span class="tpl-vargroup__title">{{ group.title }}</span>
</div>
<div class="tpl-vargroup__chips">
<button
v-for="v in group.items"
:key="v.token"
type="button"
class="tpl-chip"
:class="{ 'tpl-chip--disabled': disabled }"
:disabled="disabled"
:title="v.help"
@click="insertToken(v.token)"
>
<span class="tpl-chip__token">%{{ v.token }}</span>
<span class="tpl-chip__label">{{ v.label }}</span>
</button>
</div>
</div>
</div>
</div>
<!-- پیش‌نمایش -->
<div class="tpl-field__preview">
<div class="tpl-field__section-title">
<BaseIcon icon="vuesax-linear:eye" size="15" />
<span>{{ config.previewTitle }}</span>
<span class="tpl-field__preview-tag">نمونه</span>
</div>
<div class="tpl-preview">
<div class="tpl-preview__bubble" dir="auto">{{ previewText }}</div>
</div>
<div class="tpl-field__legend">
{{ config.legend }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import BaseIcon from '@core/components/BaseIcon/src/Icon.vue'
import { getTemplateFieldConfig, TEMPLATE_FIELD_CONFIGS, type TemplateFieldConfig } from './templateFields'
const props = withDefaults(
defineProps<{
modelValue: string
settingKey: string
disabled?: boolean
}>(),
{ disabled: false },
)
const emit = defineEmits<{
'update:value': [value: string]
blur: []
}>()
const textareaRef = ref<HTMLTextAreaElement>()
const config = computed<TemplateFieldConfig>(
() => getTemplateFieldConfig(props.settingKey) ?? TEMPLATE_FIELD_CONFIGS.price_channel_message_text,
)
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const previewText = computed(() => {
const cfg = config.value
let out = (props.modelValue ?? '').trim() || cfg.defaultTemplate
const keys = Object.keys(cfg.sampleValues).sort((a, b) => b.length - a.length)
for (const key of keys) {
const re = new RegExp('[%٪]\\s*' + escapeRegExp(key), 'gu')
out = out.replace(re, cfg.sampleValues[key])
}
return out.replace(/\n{3,}/g, '\n\n').trim()
})
function onInput(event: Event) {
emit('update:value', (event.target as HTMLTextAreaElement).value)
}
function insertToken(token: string) {
if (props.disabled) return
const insert = `%${token}`
const current = props.modelValue ?? ''
const el = textareaRef.value
if (!el) {
emit('update:value', current ? `${current}${insert}` : insert)
return
}
const start = el.selectionStart ?? current.length
const end = el.selectionEnd ?? current.length
const next = current.slice(0, start) + insert + current.slice(end)
emit('update:value', next)
nextTick(() => {
el.focus()
const pos = start + insert.length
el.setSelectionRange(pos, pos)
})
}
</script>
<style lang="less" scoped>
.tpl-field {
width: 100%;
}
.tpl-field__grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
@media (min-width: 992px) {
grid-template-columns: 1.15fr 0.85fr;
}
}
.tpl-field__section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--admin-text);
margin-bottom: 8px;
:deep(svg) {
color: #d4af37;
}
}
.tpl-field__preview-tag {
margin-inline-start: auto;
font-size: 10px;
font-weight: 500;
color: #b45309;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 6px;
padding: 1px 8px;
}
.tpl-field__textarea {
width: 100%;
min-height: 160px;
resize: vertical;
padding: 12px 14px;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: var(--admin-bg-elevated);
color: var(--admin-text);
font-size: 13px;
line-height: 2;
font-family: 'Vazirmatn', 'IRANSans', ui-monospace, monospace;
direction: rtl;
transition: border-color 0.2s, box-shadow 0.2s;
&:focus {
outline: none;
border-color: #d4af37;
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.15);
}
&:disabled {
background: var(--admin-bg-muted);
cursor: not-allowed;
opacity: 0.7;
}
}
.tpl-field__hint {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
font-size: 11px;
line-height: 1.7;
color: var(--admin-text-secondary);
:deep(svg) {
flex-shrink: 0;
margin-top: 2px;
color: #3b82f6;
}
code {
background: var(--admin-bg-muted);
border-radius: 4px;
padding: 0 4px;
}
}
.tpl-field__vars {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tpl-vargroup__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.tpl-vargroup__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.tpl-vargroup__title {
font-size: 12px;
font-weight: 600;
color: var(--admin-text-secondary);
}
.tpl-vargroup__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tpl-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-bg-elevated);
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
&:hover:not(.tpl-chip--disabled) {
border-color: #d4af37;
background: #fffaf0;
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(212, 175, 55, 0.15);
}
&--disabled {
cursor: not-allowed;
opacity: 0.5;
}
&__token {
font-size: 12px;
font-weight: 700;
color: #b45309;
direction: ltr;
}
&__label {
font-size: 11px;
color: var(--admin-text-secondary);
}
}
.tpl-preview {
background: linear-gradient(160deg, #e8f5e9 0%, #eef7ff 100%);
border: 1px solid var(--admin-border);
border-radius: 12px;
padding: 14px;
min-height: 160px;
}
.tpl-preview__bubble {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px 12px 12px 4px;
padding: 12px 14px;
font-size: 13px;
line-height: 2;
color: #111827;
white-space: pre-wrap;
word-break: break-word;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.tpl-field__legend {
margin-top: 8px;
font-size: 11px;
line-height: 1.7;
color: var(--admin-text-secondary);
}
</style>
@@ -290,6 +290,7 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
price_channel_bale_to: 'text',
price_channel_telegram_to: 'text',
price_channel_rubika_to: 'text',
price_channel_message_text: 'longtext',
rubika_bot_token: 'text',
change_mesghal: 'number'
}
@@ -429,6 +430,54 @@ export function switchLabels(key: string | undefined): { on: string; off: string
return { on: 'فعال', off: 'غیرفعال' }
}
export interface SwitchHelpParts {
note: string
on: string
off: string
}
/**
* تفکیک توضیح سوییچ به بخش‌های «روشن» و «خاموش» برای نمایش خط‌به‌خط.
* از نشانگرهای «روشن:»/«روشن =» و «خاموش:»/«خاموش =» استفاده می‌کند.
* اگر هیچ نشانگری نبود null برمی‌گرداند تا متن ساده نمایش داده شود.
*/
export function parseSwitchHelp(help: string | undefined | null): SwitchHelpParts | null {
const text = (help ?? '').trim()
if (!text) return null
const onMatch = /روشن\s*[:=]\s*/.exec(text)
const offMatch = /خاموش\s*[:=]\s*/.exec(text)
if (!onMatch && !offMatch) return null
const trim = (s: string) => s.replace(/^[\s،؛.:]+|[\s،؛:]+$/g, '').trim()
let note = ''
let on = ''
let off = ''
if (onMatch && offMatch) {
const onIdx = onMatch.index
const offIdx = offMatch.index
if (onIdx < offIdx) {
note = text.slice(0, onIdx)
on = text.slice(onIdx + onMatch[0].length, offIdx)
off = text.slice(offIdx + offMatch[0].length)
} else {
note = text.slice(0, offIdx)
off = text.slice(offIdx + offMatch[0].length, onIdx)
on = text.slice(onIdx + onMatch[0].length)
}
} else if (onMatch) {
note = text.slice(0, onMatch.index)
on = text.slice(onMatch.index + onMatch[0].length)
} else if (offMatch) {
note = text.slice(0, offMatch.index)
off = text.slice(offMatch.index + offMatch[0].length)
}
return { note: trim(note), on: trim(on), off: trim(off) }
}
/** توضیحات fallback — وقتی در کاتالوگ تعریف شده، بر description دیتابیس اولویت دارد */
const KEY_HELP_FALLBACK: Record<string, string> = {
name_fa:
@@ -442,13 +491,13 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
auto_active_customer:
'روشن: حساب بلافاصله پس از ثبت‌نام فعال می‌شود. خاموش: نیاز به تأیید دستی شما در پنل دارد.',
close_price:
'فیلتر اصلی فروشگاه. وقتی روشن باشد، خرید و فروش و نمایش قیمت فقط بین «ساعت شروع» و «ساعت پایان» انجام می‌شود و خارج از این بازه به مشتری «فروشگاه بسته است» نشان داده می‌شود. خاموش: محدودیت ساعتی سراسری اعمال نمی‌شود (مگر برای محصولی ساعت اختصاصی تعریف کرده باشید).',
'فیلتر اصلی ساعت کاری فروشگاه. روشن: خرید، فروش و نمایش قیمت فقط بین «ساعت شروع» و «ساعت پایان» انجام می‌شود و خارج از این بازه به مشتری «فروشگاه بسته است» نشان داده می‌شود. خاموش: محدودیت ساعتی سراسری اعمال نمی‌شود (مگر برای محصولی ساعت اختصاصی تعریف کرده باشید).',
startDay:
'ساعت شروع فعالیت روزانه فروشگاه. فقط وقتی «بستن خودکار خارج از ساعت کاری» روشن باشد اعمال می‌شود. از ساعت‌شمار انتخاب کنید (مثلاً ۰۹:۰۰).',
endDay:
'ساعت پایان فعالیت روزانه فروشگاه. فقط وقتی «بستن خودکار خارج از ساعت کاری» روشن باشد اعمال می‌شود. بعد از این ساعت فروشگاه بسته می‌شود (مثلاً ۱۷:۰۰ یا ۲۲:۰۰).',
show_last_price:
'وقتی قیمت زنده در دسترس نباشد: روشن = آخرین قیمت ذخیره‌شده نشان داده می‌شود؛ خاموش = به‌جای صفر پیام «مظنه در دسترس نیست» نمایش داده می‌شود.',
'رفتار هنگام در دسترس نبودن قیمت زنده. روشن: آخرین قیمت ذخیره‌شده نشان داده می‌شود. خاموش: به‌جای صفر پیام «مظنه در دسترس نیست» نمایش داده می‌شود.',
show_gold_ounce_price:
'روشن: اگر آدرس API تابلو (zaarbox_api_url) تنظیم شده باشد، قیمت انس طلا در اپ مشتری نمایش داده می‌شود. خاموش: نمایش داده نمی‌شود.',
fee_single:
@@ -482,9 +531,9 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
gift_for_referral:
'مقدار پاداش (معمولاً وزن فلز) که به معرف‌شونده واریز می‌شود. صفر = بدون پاداش. زمان واریز بسته به «پاداش معرف پس از اولین سفارش» است.',
register_referral_sms_to_referrer:
'روشن: پس از ثبت‌نام کاربر جدید، پیامک اطلاع‌رسانی برای معرف ارسال می‌شود.',
'روشن: پس از ثبت‌نام کاربر جدید، پیامک اطلاع‌رسانی برای معرف ارسال می‌شود. خاموش: پیامکی برای معرف ارسال نمی‌شود.',
register_referral_bonus_message:
'روشن: متن پاداش معرف در پیامک خوش‌آمد ثبت‌نام گنجانده می‌شود.',
'روشن: متن پاداش معرف در پیامک خوش‌آمد ثبت‌نام گنجانده می‌شود. خاموش: متن پاداش در پیامک خوش‌آمد نمی‌آید.',
referral_gift_on_first_order:
'روشن: پاداش معرف (gift_for_referrer) و معرف‌شونده (gift_for_referral) پس از تکمیل اولین سفارش معرف‌شونده به کیف واریز می‌شود — فقط اگر مقدار هر پاداش بیشتر از صفر باشد. خاموش: پاداش بلافاصله پس از ثبت‌نام موفق یا تأیید احراز هویت واریز می‌شود (باز هم فقط برای مقادیر بیشتر از صفر).',
referral_limit:
@@ -492,13 +541,13 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
referral_bonus_amount:
'مبلغ یا مقدار پاداش معرف برای نمایش در پیامک‌ها و متن‌های دعوت.',
jibit_verification:
'روشن: هنگام ثبت یا ویرایش کارت بانکی، تطبیق با سرویس احراز هویت جیبیت انجام می‌شود.',
'روشن: هنگام ثبت یا ویرایش کارت بانکی، تطبیق با سرویس احراز هویت جیبیت انجام می‌شود. خاموش: کارت بدون تطبیق جیبیت ثبت می‌شود.',
register_jibit_verify:
'روشن: در ثبت‌نام، تطبیق کد ملی و شماره موبایل از طریق جیبیت انجام می‌شود.',
'روشن: در ثبت‌نام، تطبیق کد ملی و شماره موبایل از طریق جیبیت انجام می‌شود. خاموش: ثبت‌نام بدون تطبیق جیبیت انجام می‌شود.',
has_page_earn_money:
'روشن: منوی «کسب درآمد / دعوت دوستان» در اپ مشتری نمایش داده می‌شود.',
'روشن: منوی «کسب درآمد / دعوت دوستان» در اپ مشتری نمایش داده می‌شود. خاموش: این منو در اپ نمایش داده نمی‌شود.',
invite_link_text:
'متن پیام اشتراک‌گذاری دعوت. می‌توانید از ٪نام برای نام کاربر و ٪لینک برای لینک ثبت‌نام با کد معرف استفاده کنید. خالی = متن پیش‌فرض اپ.',
'متن پیام اشتراک‌گذاری دعوت. متن را بنویسید و متغیرها (نام کاربر، لینک ثبت‌نام) را با کلیک اضافه کنید؛ پیش‌نمایش نتیجه را با مقادیر نمونه نشان می‌دهد. خالی = متن پیش‌فرض اپ.',
montzar_mobile:
'شماره موبایل حساب اتصال به سرویس همکار قیمت.',
montzar_password:
@@ -510,13 +559,13 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
zaarbox_api_url:
'آدرس سرور(های) فید قیمت (/api/free). تا ۵ آدرس می‌توانید اضافه کنید؛ اگر آدرس اول پاسخ ندهد به‌ترتیب سراغ بعدی می‌رود. آدرس فعلی در ردیف اول حفظ می‌شود.',
montzar_open_buy:
'روشن: ثبت سفارش خرید از طریق قیمت همکار مجاز است (وضعیت واقعی از سرویس خارجی خوانده می‌شود).',
'وضعیت واقعی از سرویس خارجی خوانده می‌شود. روشن: ثبت سفارش خرید از طریق قیمت همکار مجاز است. خاموش: ثبت سفارش خرید از طریق قیمت همکار غیرفعال است.',
montzar_open_sell:
'روشن: ثبت سفارش فروش از طریق قیمت همکار مجاز است.',
'وضعیت واقعی از سرویس خارجی خوانده می‌شود. روشن: ثبت سفارش فروش از طریق قیمت همکار مجاز است. خاموش: ثبت سفارش فروش از طریق قیمت همکار غیرفعال است.',
montzar_closed:
'وضعیت گزارش‌شده از سرویس همکار؛ فقط برای نمایش — این سوییچ را دستی تغییر ندهید.',
external_order_api_enabled:
'روشن: دریافت کیف پول و ثبت سفارش از سرویس فروشگاه خارجی فعال است.',
'روشن: دریافت کیف پول و ثبت سفارش از سرویس فروشگاه خارجی فعال است. خاموش: اتصال به سرویس فروشگاه خارجی غیرفعال است.',
node_client_id:
'شناسه کلاینت برای اتصال امن به سرویس سفارش خارجی.',
node_shared_secret:
@@ -532,15 +581,9 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
support_messenger_id:
'شناسه تماس در پیام‌رسان انتخاب‌شده: یوزرنیم، آیدی یا لینک کامل. فقط روی آیکون پیام‌رسان و دکمه شناور اعمال می‌شود؛ تماس تلفنی از فیلد «شماره پشتیبانی» خوانده می‌شود.',
support_messenger_text:
'متن راهنمای واریز در مودال شارژ حساب. متغیرها: ٪نام یا %نام = نام صاحب حساب؛ ٪کارت یا %کارت = شماره کارت؛ ٪شبا یا %شبا = شبا. فاصله بعد از ٪ اختیاری است. اگر خالی باشد از متن پیش‌فرض اپ استفاده می‌شود.',
'متن راهنمای واریز در مودال شارژ حساب. متن را بنویسید و متغیرها (نام صاحب حساب، شماره کارت، شبا) را با کلیک اضافه کنید؛ پیش‌نمایش نتیجه را با مقادیر نمونه نشان می‌دهد. خالی = متن پیش‌فرض اپ.',
deposit_support_message_text:
`متن پیام هنگام کلیک «پیام به پشتیبانی» در مودال واریز (SMS و پیام‌رسان).
متغیر:
• %نام یا ٪نام = نام مشتری واردشده (اگر خالی بود، نام صاحب حساب از تنظیمات)
پیش‌فرض اپ:
باسلام، %نام هستم. کدپیگیری و مبلغ واریزی من:`,
'متن پیام هنگام کلیک «پیام به پشتیبانی» در مودال واریز (SMS و پیام‌رسان). متن را بنویسید و متغیر «نام مشتری» را با کلیک اضافه کنید؛ پیش‌نمایش نتیجه را نشان می‌دهد. خالی = متن پیش‌فرض اپ.',
deposit_account_holder:
'نام صاحب حساب واریز — در متن راهنمای واریز با ٪نام یا %نام جایگزین می‌شود. در پیام پشتیبانی فقط وقتی نام مشتری موجود نباشد استفاده می‌شود.',
deposit_card_number:
@@ -550,11 +593,11 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
deposit_sms_support_number:
'شماره تماس پشتیبانی؛ با کلیک روی «تماس با پشتیبانی» در اپ، تماس تلفنی برقرار می‌شود. در صفحه واریز، درباره ما و قوانین هم نمایش داده می‌شود.',
charge_notif:
'روشن: پس از شارژ کیف توسط ادمین، پیامک به مشتری ارسال می‌شود.',
'روشن: پس از شارژ کیف توسط ادمین، پیامک به مشتری ارسال می‌شود. خاموش: پیامکی پس از شارژ ارسال نمی‌شود.',
group_sms:
'روشن: امکان ارسال پیامک گروهی از پنل ادمین فعال است.',
'روشن: امکان ارسال پیامک گروهی از پنل ادمین فعال است. خاموش: ارسال پیامک گروهی غیرفعال است.',
profit_gold_api_enabled:
'روشن: محاسبه سود طلا از API خارجی فعال است. در اکثر نصب‌ها خاموش می‌ماند.',
'روشن: محاسبه سود طلا از API خارجی فعال است. خاموش: محاسبه سود از API خارجی انجام نمی‌شود (حالت پیش‌فرض اکثر نصب‌ها).',
main_wallet_products:
'محصولاتی که موجودی‌شان از کیف طلای اصلی خوانده می‌شود. می‌توانید چند محصول انتخاب کنید. اگر خالی بماند، پیش‌فرض محصول ۱۳ است.',
unit:
@@ -572,7 +615,7 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
zaarbox_price_key:
'شناسه ردیف قیمت در پاسخ سرویس تابلو.',
payping_fin_sync_enabled:
'روشن: پس از واریز یا برداشت، تراکنش به سامانه حسابداری مالی همگام می‌شود.',
'روشن: پس از واریز یا برداشت، تراکنش به سامانه حسابداری مالی همگام می‌شود. خاموش: تراکنش‌ها به سامانه حسابداری مالی ارسال نمی‌شوند.',
payping_fin_url:
'آدرس ثبت تراکنش در سامانه حسابداری مالی.',
payping_fin_token:
@@ -590,7 +633,7 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
payping_round_toman:
'روشن: مبلغ پرداخت به نزدیک‌ترین تومان گرد می‌شود. خاموش: مبلغ دقیق ریالی.',
payping_strict_failed_withdraw:
'روشن: در صورت برداشت ناموفق قبلی، تسویه جدید با احتیاط بیشتر انجام می‌شود.',
'روشن: در صورت برداشت ناموفق قبلی، تسویه جدید با احتیاط بیشتر انجام می‌شود. خاموش: تسویه به‌صورت عادی و بدون احتیاط اضافه انجام می‌شود.',
send_to_tahesab:
'تنظیم قدیمی — از «سیستم حسابداری» استفاده کنید.',
accounting_provider:
@@ -641,6 +684,8 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
'آیدی چنل یا گروه تلگرام که قیمت برایش ارسال می‌شود. چند مقصد را با ویرگول جدا کنید.',
price_channel_rubika_to:
'آیدی چنل یا گروه روبیکا که قیمت برایش ارسال می‌شود. چند مقصد را با ویرگول جدا کنید.',
price_channel_message_text:
'قالب پیامی که با تغییر قیمت به کانال ارسال می‌شود. متن را بنویسید و متغیرها را با کلیک اضافه کنید؛ پیش‌نمایش، پیام نهایی را با مقادیر نمونه نشان می‌دهد. خالی = قالب پیش‌فرض.',
rubika_bot_token:
'توکن ربات روبیکا برای ارسال قیمت به کانال. محرمانه نگه دارید.',
change_mesghal:
@@ -701,6 +746,7 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
price_channel_bale_to: 'مقصد ارسال بله',
price_channel_telegram_to: 'مقصد ارسال تلگرام',
price_channel_rubika_to: 'مقصد ارسال روبیکا',
price_channel_message_text: 'قالب متن پیام کانال',
rubika_bot_token: 'توکن بات روبیکا',
change_mesghal: 'وزن مثقال (گرم)'
}
@@ -0,0 +1,234 @@
/**
* کاتالوگ فیلدهای «قالب متن با متغیر» — همهٔ تنظیماتی که placeholder با % یا ٪ دارند.
* هر کلید یک ویرایشگر متن با چیپ‌های متغیر و پیش‌نمایش زنده می‌گیرد.
*/
export interface TemplateVariableItem {
/** توکن بدون % (مثلاً «نام») */
token: string
/** برچسب کوتاه فارسی روی چیپ */
label: string
/** توضیح کامل (tooltip) */
help: string
}
export interface TemplateVariableGroup {
title: string
color: string
items: TemplateVariableItem[]
}
export interface TemplateFieldConfig {
/** گروه‌های متغیر برای پالت چیپ‌ها */
variableGroups: TemplateVariableGroup[]
/** مقدار نمونه هر توکن برای پیش‌نمایش */
sampleValues: Record<string, string>
/** قالب پیش‌فرض (وقتی فیلد خالی است در پیش‌نمایش نشان داده می‌شود) */
defaultTemplate: string
/** عنوان بالای ویرایشگر */
editorTitle: string
/** عنوان بالای پیش‌نمایش */
previewTitle: string
/** placeholder داخل textarea */
placeholder: string
/** متن راهنمای زیر textarea */
hint: string
/** توضیح زیر پیش‌نمایش */
legend: string
/** تعداد پیش‌فرض ردیف‌های textarea */
rows: number
}
function toFa(input: string): string {
const fa = '۰۱۲۳۴۵۶۷۸۹'
return input.replace(/[0-9]/g, (d) => fa[Number(d)])
}
const PRICE_CHANNEL_DEFAULT = [
'سلام 👋',
'%عنوان',
'📅 %تاریخ 🕐 %ساعت',
'',
'🔴 خرید گرم: %گرم_خرید',
'🟢 فروش گرم: %گرم_فروش',
'🔴 خرید مثقال: %مثقال_خرید',
'🟢 فروش مثقال: %مثقال_فروش',
'',
'📞 %شماره',
'%آیدی',
'%لینک',
].join('\n')
const INVITE_DEFAULT = [
'⭐️الان طلا بخر؛ آخر ماه سودشو ببر!',
'با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!',
'',
'این دعوت نامه از طرف "%نام" عزیز برای شما ارسال شده تا شما هم از بازار طلای ما جا نمونید.',
'روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.',
'',
'%لینک',
].join('\n')
const DEPOSIT_GUIDE_DEFAULT = [
'برای شارژ حساب، مبلغ دلخواه را به کارت زیر واریز کنید:',
'',
'صاحب حساب: %نام',
'شماره کارت: %کارت',
'شماره شبا: %شبا',
'',
'پس از واریز، از دکمه «پیام به پشتیبانی» فیش را ارسال کنید.',
].join('\n')
const DEPOSIT_SUPPORT_DEFAULT = 'باسلام، %نام هستم. کدپیگیری و مبلغ واریزی من:'
export const TEMPLATE_FIELD_CONFIGS: Record<string, TemplateFieldConfig> = {
price_channel_message_text: {
editorTitle: 'متن قالب پیام',
previewTitle: 'پیش‌نمایش پیام',
placeholder: 'متن پیام کانال را بنویسید و متغیرها را از پایین اضافه کنید…',
hint: 'روی هر متغیر کلیک کنید تا در محل نشانگر متن اضافه شود. اگر متن را خالی بگذارید، قالب پیش‌فرض ارسال می‌شود.',
legend:
'مقادیر بالا نمونه‌اند؛ در پیام واقعی، قیمت‌ها زنده و نام/شماره/آیدی/لینک از تنظیمات همین بخش پر می‌شوند.',
rows: 12,
defaultTemplate: PRICE_CHANNEL_DEFAULT,
variableGroups: [
{
title: 'قیمت تابلو',
color: '#d4af37',
items: [
{ token: 'گرم_خرید', label: 'خرید هر گرم', help: 'قیمت خرید هر گرم' },
{ token: 'گرم_فروش', label: 'فروش هر گرم', help: 'قیمت فروش هر گرم (عدد بزرگ صفحهٔ اصلی اپ)' },
{ token: 'مثقال_خرید', label: 'خرید هر مثقال', help: 'قیمت خرید هر مثقال' },
{ token: 'مثقال_فروش', label: 'فروش هر مثقال', help: 'قیمت فروش هر مثقال' },
],
},
{
title: 'زمان',
color: '#3b82f6',
items: [
{ token: 'تاریخ', label: 'تاریخ شمسی', help: 'تاریخ شمسی امروز' },
{ token: 'ساعت', label: 'ساعت', help: 'ساعت لحظهٔ ارسال' },
],
},
{
title: 'نام و عنوان',
color: '#8b5cf6',
items: [
{ token: 'نام', label: 'نام فروشگاه', help: 'فقط نام فروشگاه (از تنظیم «نام فارسی»)' },
{ token: 'عنوان', label: 'عنوان آماده', help: 'خط آمادهٔ «📊 قیمت لحظه‌ای + نام فروشگاه»' },
],
},
{
title: 'تماس (از فیلدهای همین بخش)',
color: '#10b981',
items: [
{ token: 'شماره', label: 'شماره تماس', help: 'از فیلد «شماره تماس در پیام»' },
{ token: 'آیدی', label: 'آیدی کانال', help: 'از فیلد «آیدی کانال در پیام»' },
{ token: 'لینک', label: 'لینک', help: 'از فیلد «لینک در پیام»' },
],
},
{
title: 'اختیاری',
color: '#6b7280',
items: [
{ token: 'محصولات', label: 'لیست محصولات', help: 'چند خط قیمت محصولات فعال' },
{ token: 'فوتر', label: 'فوتر آماده', help: 'بلوک آمادهٔ پایان پیام (آیدی/شماره/لینک)' },
],
},
],
sampleValues: {
گرم_خرید: '۷۱,۲۵۰,۰۰۰',
گرم_فروش: '۷۱,۵۰۰,۰۰۰',
مثقال_خرید: '۳۰۸,۷۰۰,۰۰۰',
مثقال_فروش: '۳۰۹,۸۰۰,۰۰۰',
تاریخ: '۱۴۰۴/۴/۲۲',
ساعت: toFa('12:30'),
نام: 'طلای نمونه',
عنوان: '📊 قیمت لحظه‌ای طلای نمونه',
شماره: '۰۹۱۲۳۴۵۶۷۸۹',
آیدی: '@gold_channel',
لینک: 'https://example.com',
محصولات: '• سکه تمام: ۹۲۱,۱۰۰,۰۰۰\n• نیم سکه: ۴۷۵,۰۰۰,۰۰۰',
فوتر: '📞 ۰۹۱۲۳۴۵۶۷۸۹\n@gold_channel',
},
},
invite_link_text: {
editorTitle: 'متن پیام دعوت',
previewTitle: 'پیش‌نمایش پیام دعوت',
placeholder: 'متن دعوت از دوستان را بنویسید و متغیرها را از پایین اضافه کنید…',
hint: 'روی هر متغیر کلیک کنید تا در محل نشانگر اضافه شود. اگر خالی بگذارید، متن پیش‌فرض اپ استفاده می‌شود.',
legend: 'مقادیر بالا نمونه‌اند؛ در پیام واقعی «نام» نام کاربر دعوت‌کننده و «لینک» آدرس ثبت‌نام با کد معرف اوست.',
rows: 10,
defaultTemplate: INVITE_DEFAULT,
variableGroups: [
{
title: 'متغیرها',
color: '#8b5cf6',
items: [
{ token: 'نام', label: 'نام کاربر', help: 'نام کامل کاربر دعوت‌کننده' },
{ token: 'لینک', label: 'لینک ثبت‌نام', help: 'آدرس کامل ثبت‌نام همراه کد معرف' },
],
},
],
sampleValues: {
نام: 'علی رضایی',
لینک: 'https://gold.example.com/?ref=AB12CD',
},
},
support_messenger_text: {
editorTitle: 'متن راهنمای واریز',
previewTitle: 'پیش‌نمایش راهنما',
placeholder: 'متن راهنمای واریز در مودال شارژ حساب را بنویسید…',
hint: 'روی هر متغیر کلیک کنید تا در محل نشانگر اضافه شود. اگر خالی بگذارید، متن پیش‌فرض اپ استفاده می‌شود.',
legend: 'مقادیر بالا نمونه‌اند؛ در اپ، نام/کارت/شبا از فیلدهای «نام صاحب حساب واریز»، «شماره کارت واریز» و «شبا واریز» پر می‌شوند.',
rows: 8,
defaultTemplate: DEPOSIT_GUIDE_DEFAULT,
variableGroups: [
{
title: 'اطلاعات حساب واریز',
color: '#10b981',
items: [
{ token: 'نام', label: 'صاحب حساب', help: 'نام صاحب حساب (از فیلد «نام صاحب حساب واریز»)' },
{ token: 'کارت', label: 'شماره کارت', help: 'شماره کارت (از فیلد «شماره کارت واریز»)' },
{ token: 'شبا', label: 'شماره شبا', help: 'شماره شبا (از فیلد «شبا واریز»)' },
],
},
],
sampleValues: {
نام: 'علی رضایی',
کارت: '۶۰۳۷-۹۹۷۹-۱۲۳۴-۵۶۷۸',
شبا: 'IR۸۲۰۵۴۰۱۰۲۶۸۰۰۲۰۸۱۷۹۰۹۰۰۲',
},
},
deposit_support_message_text: {
editorTitle: 'متن پیام به پشتیبانی',
previewTitle: 'پیش‌نمایش پیام',
placeholder: 'متنی که با کلیک «پیام به پشتیبانی» برای مشتری آماده می‌شود…',
hint: 'روی متغیر کلیک کنید تا در محل نشانگر اضافه شود. اگر خالی بگذارید، متن پیش‌فرض اپ استفاده می‌شود.',
legend: 'مقدار بالا نمونه است؛ در اپ «نام» نام مشتری واردشده است (و اگر خالی بود، نام صاحب حساب از تنظیمات).',
rows: 5,
defaultTemplate: DEPOSIT_SUPPORT_DEFAULT,
variableGroups: [
{
title: 'متغیر',
color: '#3b82f6',
items: [{ token: 'نام', label: 'نام مشتری', help: 'نام مشتری واردشده (یا نام صاحب حساب)' }],
},
],
sampleValues: {
نام: 'علی رضایی',
},
},
}
export function hasTemplateField(key: string | undefined | null): boolean {
return !!key && Object.prototype.hasOwnProperty.call(TEMPLATE_FIELD_CONFIGS, key)
}
export function getTemplateFieldConfig(key: string | undefined | null): TemplateFieldConfig | undefined {
if (!key) return undefined
return TEMPLATE_FIELD_CONFIGS[key]
}
@@ -58,12 +58,12 @@
<BasicInfoItem
is-line
:title="metalLabel.goldWalletTitle()"
:value="selectedCustomer?.wallet?.[0]?.gold ?? '_'"
:value="goldBalanceDisplay"
/>
<BasicInfoItem
is-line
title="کیف پول ریالی"
:value="selectedCustomer?.wallet?.[0]?.cash ?? '_'"
:value="cashBalanceDisplay"
/>
</div>
@@ -108,6 +108,7 @@ import type { last } from '@/model/price'
import { inputRules, inputRulesNumber } from '@/utils/inputRules'
import { usePrice } from '@core/utils/usePrice'
import { metalLabel } from '@/utils/adminMetalLabels'
import { getCashBalance, getGoldBalance } from '@/components/mobile/utils/wallet'
const props = defineProps<{
form: Order
@@ -131,6 +132,14 @@ const marketPriceLabel = computed(() => {
return usePrice(price)
})
const goldBalanceDisplay = computed(
() => getGoldBalance(props.selectedCustomer?.wallet) || '_'
)
const cashBalanceDisplay = computed(
() => getCashBalance(props.selectedCustomer?.wallet) || '_'
)
defineExpose({
validateFields: () => formRef.value?.validateFields(),
resetFields: () => formRef.value?.resetFields()
+2
View File
@@ -91,6 +91,7 @@ import { createOrder } from '@/api/order'
import { isMobile } from '@core/utils/is'
import { metalLabel } from '@/utils/adminMetalLabels'
import { formatAmountWithUnit } from '@/utils/weightUnit'
import { initWalletSettingsFromApi } from '@/utils/config'
//loading
const loading = ref<boolean>(false)
@@ -118,6 +119,7 @@ async function getCustomers() {
}
onMounted(() => {
void initWalletSettingsFromApi()
getCustomers()
})
@@ -26,7 +26,7 @@
<BasicInfoItem title="قیمت" :value="usePrice(product.price || 0)" />
<BasicInfoItem title="هزینه خرید" :value="formatCashFee(product.cash_fee_buy, product.cash_fee_buy_is_percent)" />
<BasicInfoItem title="هزینه فروش" :value="formatCashFee(product.cash_fee_sell, product.cash_fee_sell_is_percent)" />
<BasicInfoItem title="معامله مقداری" :value="product.trade_by_quantity ? 'بله' : 'خیر'" />
<BasicInfoItem title="نوع معامله" :value="resolveProductTradeTypeLabel(product.trade_by_quantity)" />
<BasicInfoItem title="تاریخ ثبت" :value="product.created_at ? toJalali(product.created_at) : '—'" />
</div>
@@ -70,6 +70,7 @@
import type { Product } from '@/model/product'
import BasicActionSheet from '@/components/BasicActionSheet.vue'
import CustomTag from '@/components/CustomTag.vue'
import { resolveProductTradeTypeLabel } from '@/utils/productCategories'
import { isMobile } from '@core/utils/is'
import { toJalali } from '@core/utils/useDataTime'
import { usePrice } from '@core/utils/usePrice'
@@ -82,6 +82,7 @@
<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>
<a-radio value="3">قیمت اپ</a-radio>
</a-radio-group>
</div>
<div v-if="methodOption === '1'" class="grid gap-4 md:grid-cols-3">
@@ -131,6 +132,25 @@
<a-switch v-model:checked="form.use_last_price_if_zero" />
</a-form-item>
</div>
<div v-if="methodOption === '3'" class="flex flex-col gap-3">
<div class="grid gap-4 md:grid-cols-3">
<a-form-item name="price_offset_percent" class="!mb-0">
<template #label>
<DashboardLabelWithHelp label="تعدیل قیمت اپ (٪)" :help="appPriceOffsetHelp" size="sm" />
</template>
<BasicInput type="number" v-model:value="form.price_offset_percent" placeholder="مثلاً 5 یا -3" />
</a-form-item>
<a-form-item name="use_last_price_if_zero" class="!mb-0">
<template #label>
<DashboardLabelWithHelp label="قیمت صفر از مرجع" :help="useLastPriceIfZeroHelp" size="sm" />
</template>
<a-switch v-model:checked="form.use_last_price_if_zero" />
</a-form-item>
</div>
<div 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>
</div>
<div class="rounded-lg border border-solid border-blue-200 bg-blue-50 px-3 py-2">
@@ -239,6 +259,32 @@
</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>
</a-form-item>
</div>
</template>
</BaseCollapse>
@@ -340,23 +386,6 @@
</template>
<a-switch v-model:checked="form.order_limit_enabled" @change="onOrderLimitChange" />
</a-form-item>
<a-form-item
v-if="!isMeltedCategory && !editId"
name="trade_by_quantity"
>
<template #label>
<DashboardLabelWithHelp label="معامله مقداری" :help="tradeByQuantityHelp" size="sm" />
</template>
<a-switch v-model:checked="form.trade_by_quantity" />
</a-form-item>
<a-form-item v-else-if="!isMeltedCategory && editId">
<template #label>
<DashboardLabelWithHelp label="نوع معامله" :help="tradeByQuantityHelp" size="sm" />
</template>
<div class="text-sm leading-6 pt-1">
{{ form.trade_by_quantity ? 'معامله مقداری' : 'معامله عادی' }}
</div>
</a-form-item>
<template v-if="form.order_limit_enabled">
<a-form-item name="min_order_cash">
@@ -379,13 +408,13 @@
</template>
<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_grams">
<a-form-item v-if="form.has_physical_delivery" name="min_physical_delivery_quantity">
<template #label>
<DashboardLabelWithHelp label="حداقل گرم برای تحویل" :help="minPhysicalDeliveryHelp" size="sm" />
<DashboardLabelWithHelp :label="minPhysicalDeliveryLabel" :help="minPhysicalDeliveryHelp" size="sm" />
</template>
<BasicInput
v-model:value="form.min_physical_delivery_grams"
addon-before="گرم"
v-model:value="form.min_physical_delivery_quantity"
:addon-before="physicalDeliveryUnit"
type="number"
/>
</a-form-item>
@@ -420,7 +449,7 @@ 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 { usesMeltedPriceEditor } from '@/utils/productCategories'
import { resolveProductTradeTypeLabel, usesMeltedPriceEditor } from '@/utils/productCategories'
import { computeProductEditorPricePreview } from '@/utils/productEditorPrice'
import { resolveTradeWeightUnit, weightFieldLabelForUnit } from '@/utils/weightUnit'
import type { DashboardHelp } from '@/pages/dashboard/dashboardSectionCopy'
@@ -429,7 +458,8 @@ import dayjs from 'dayjs'
import { computed, onMounted, ref, watch } from 'vue'
const priceSourceHelp: DashboardHelp = {
description: 'دستی: خودتان قیمت را وارد می‌کنید. اتوماتیک: قیمت از مرجع بیرونی (zaarbox) خوانده می‌شود.',
description:
'دستی: خودتان قیمت را وارد می‌کنید. اتوماتیک: قیمت از مرجع بیرونی (zaarbox) خوانده می‌شود. قیمت اپ: از قیمت زندهٔ صفحهٔ اصلی اپ بر اساس وزن محاسبه می‌شود (هر گرم × وزن محصول).',
}
const meltedPriceSourceHelp: DashboardHelp = {
@@ -540,17 +570,17 @@ const orderLimitHelp: DashboardHelp = {
const physicalDeliveryHelp: DashboardHelp = {
description:
'روشن: این محصول امکان تحویل فیزیکی دارد. خاموش: تحویل فیزیکی ندارد. برای محصولات تعدادی، مقدار به گرم = تعداد × وزن هر واحد سنجیده می‌شود.',
'روشن: این محصول امکان تحویل فیزیکی دارد. خاموش: تحویل فیزیکی ندارد. حداقل بر حسب واحد خود محصول سنجیده می‌شود: محصول تعدادی → تعداد (عدد)، محصول وزنی → گرم یا کیلوگرم.',
}
const minPhysicalDeliveryHelp: DashboardHelp = {
description:
'حداقل موجودی (به گرم) که کاربر باید داشته باشد تا تحویل فیزیکی فعال شود؛ در غیر این صورت پیام «چند گرم مانده تا تحویل» نمایش داده می‌شود.',
'حداقل موجودی که کاربر باید داشته باشد تا تحویل فیزیکی فعال شود؛ واحد آن بر اساس نوع محصول است (تعداد برای تعدادی، گرم/کیلوگرم برای وزنی). در غیر این صورت پیام «چند واحد مانده تا تحویل» نمایش داده می‌شود.',
}
const tradeByQuantityHelp: DashboardHelp = {
description:
'فرم خرید/فروش با «ارزش کل» و «مقدار». فقط هنگام ساخت محصول قابل تعیین است و بعداً تغییر نمی‌کند.',
'معامله تعدادی: مشتری تعداد قلم می‌خرد (مثل سکه). معامله مقداری: فرم با «ارزش کل» و «مقدار» (گرم/کیلوگرم). فقط هنگام ساخت محصول قابل تعیین است و بعداً تغییر نمی‌کند.',
}
const innerFormRef = ref<FormInstance>()
@@ -579,6 +609,13 @@ const isStore = ref(Boolean(props.isStore))
const tradeWeightUnit = ref('گرم')
const isMeltedCategory = computed(() => usesMeltedPriceEditor(props.form))
const tradeTypeOption = computed({
get: () => (props.form.trade_by_quantity ? 'quantity' : 'count'),
set: (value: 'quantity' | 'count') => {
props.form.trade_by_quantity = value === 'quantity'
},
})
const showDirectPriceField = computed(() => !isMeltedCategory.value && props.methodOption === '1')
const showUnitField = computed(() => isMeltedCategory.value || props.weightOption === '1')
@@ -593,6 +630,23 @@ const livePricePreview = computed(() =>
})
)
watch(
() => props.methodOption,
(option) => {
if (isMeltedCategory.value) {
return
}
if (option === '3') {
// قیمت اپ: مرجع خارجی ندارد، متغیر (هر گرم × وزن)
props.form.reading_method = null
props.form.direct_price = 0
props.form.app_price = true
} else {
props.form.app_price = false
}
}
)
watch(
() => props.meltedPriceOption,
(option) => {
@@ -636,6 +690,24 @@ const weightFieldLabel = computed(() => {
return weightFieldLabelForUnit(tradeWeightUnit.value)
})
// واحد حداقل تحویل فیزیکی: محصول وزنی → گرم/کیلوگرم، محصول تعدادی → عدد
const physicalDeliveryUnit = computed(() => {
const productUnit = props.form.unit?.trim()
if (productUnit === 'کیلوگرم' || productUnit === 'گرم') {
return productUnit
}
if (isMeltedCategory.value) {
return tradeWeightUnit.value
}
return 'عدد'
})
const minPhysicalDeliveryLabel = computed(() =>
physicalDeliveryUnit.value === 'عدد' ? 'حداقل تعداد برای تحویل' : 'حداقل مقدار برای تحویل'
)
function onOrderLimitChange(checked: boolean) {
if (!checked) {
props.form.min_order_cash = undefined
@@ -645,7 +717,7 @@ function onOrderLimitChange(checked: boolean) {
function onPhysicalDeliveryChange(checked: boolean) {
if (!checked) {
props.form.min_physical_delivery_grams = undefined
props.form.min_physical_delivery_quantity = undefined
}
}
@@ -64,7 +64,7 @@
<MobileDetailRow label="دسته بندی محصول" :value="row.group_type || '-'" />
<MobileDetailRow label="واحد" :value="row.unit || '-'" />
<MobileDetailRow label="وزن" :value="row.amount ?? '-'" />
<MobileDetailRow label="معامله مقداری" :value="row.trade_by_quantity ? 'بله' : 'خیر'" />
<MobileDetailRow label="نوع معامله" :value="resolveProductTradeTypeLabel(row.trade_by_quantity)" />
</MobileInfoGrid>
<BaseButton
@@ -88,6 +88,7 @@ import MobileIconButton from '@/components/mobile/MobileIconButton.vue'
import MobileInfoGrid from '@/components/mobile/MobileInfoGrid.vue'
import MobileSheetHeader from '@/components/mobile/MobileSheetHeader.vue'
import MobileStatCard from '@/components/mobile/MobileStatCard.vue'
import { resolveProductTradeTypeLabel } from '@/utils/productCategories'
import { isMobile } from '@core/utils/is'
import { toJalali } from '@core/utils/useDataTime'
import { usePrice } from '@core/utils/usePrice'
+27 -8
View File
@@ -307,7 +307,9 @@ async function edit(record: Product) {
/* use table row data */
}
methodOption.value = productData.reading_method ? '2' : '1'
methodOption.value = productData.app_price
? '3'
: (productData.reading_method ? '2' : '1')
meltedPriceOption.value = resolveMeltedPriceSource(productData)
if (usesMeltedPriceEditor(productData)) {
methodOption.value = '1'
@@ -336,9 +338,13 @@ async function edit(record: Product) {
...productData,
show_in_assets: productData.show_in_assets ?? false,
trade_by_quantity: productData.trade_by_quantity ?? false,
app_price: Boolean(productData.app_price),
override_trading_hours: productData.override_trading_hours ?? false,
has_physical_delivery: productData.has_physical_delivery ?? false,
min_physical_delivery_grams: productData.min_physical_delivery_grams ?? undefined,
min_physical_delivery_quantity:
productData.min_physical_delivery_quantity ??
(productData as { min_physical_delivery_grams?: number | null }).min_physical_delivery_grams ??
undefined,
cash_fee_buy: parseCashFeeValue(productData.cash_fee_buy),
cash_fee_sell: parseCashFeeValue(productData.cash_fee_sell),
cash_fee_buy_is_percent: normalizeCashFeeFlags(productData.cash_fee_buy_is_percent),
@@ -385,6 +391,7 @@ function resetData() {
price_offset_percent: 0,
metal_id: 1,
direct_price: 0,
app_price: false,
file_id: undefined,
file: undefined,
order_limit_enabled: false,
@@ -393,7 +400,7 @@ function resetData() {
override_trading_hours: false,
use_last_price_if_zero: false,
has_physical_delivery: false,
min_physical_delivery_grams: undefined,
min_physical_delivery_quantity: undefined,
inventory: 0,
min_order_cash: undefined,
max_order_cash: undefined,
@@ -467,6 +474,8 @@ async function saveProduct() {
payload.cash_fee_sell_is_percent = normalizeCashFeeFlags(payload.cash_fee_sell_is_percent)
if (usesMeltedPriceEditor(payload)) {
// ادیتور آبشده/مقداری قیمت اپ را از روی استنتاج تشخیص می‌دهد، نه ستون app_price
payload.app_price = false
if (isMeltedTradeGroup(payload.group_type)) {
payload.amount = 1
if (!editId) {
@@ -489,9 +498,18 @@ async function saveProduct() {
} else if (methodOption.value === '1') {
payload.reading_method = null
payload.price_offset_percent = 0
payload.app_price = false
} else if (methodOption.value === '3') {
// قیمت اپ: بدون مرجع خارجی، متغیر (هر گرم × وزن)، قیمت دستی نگه نمی‌دارد
payload.reading_method = null
payload.app_price = true
payload.direct_price = 0
delete payload.price
payload.price_offset_percent = Number(payload.price_offset_percent ?? 0)
} else {
delete payload.price
payload.price_offset_percent = Number(payload.price_offset_percent ?? 0)
payload.app_price = false
}
if (weightOption.value === '2') {
payload.amount = 0
@@ -529,16 +547,17 @@ async function saveProduct() {
payload.has_physical_delivery = Boolean(payload.has_physical_delivery)
if (!payload.has_physical_delivery) {
payload.min_physical_delivery_grams = null
payload.min_physical_delivery_quantity = null
} else {
const minGrams = Number(payload.min_physical_delivery_grams)
if (!Number.isFinite(minGrams) || minGrams <= 0) {
showSubmitError('برای فعال کردن تحویل فیزیکی باید حداقل گرم معتبر وارد شود')
const minQuantity = Number(payload.min_physical_delivery_quantity)
if (!Number.isFinite(minQuantity) || minQuantity <= 0) {
showSubmitError('برای فعال کردن تحویل فیزیکی باید حداقل مقدار معتبر وارد شود')
loading.value = false
return
}
payload.min_physical_delivery_grams = minGrams
payload.min_physical_delivery_quantity = minQuantity
}
delete payload.min_physical_delivery_grams
if (editId) {
delete payload.trade_by_quantity
+7
View File
@@ -45,6 +45,13 @@ export function usesMeltedPriceEditor(input?: {
export type MeltedPriceSource = 'app' | 'fixed' | 'reference'
/** برچسب نوع معامله — فقط مقداری یا تعدادی */
export function resolveProductTradeTypeLabel(tradeByQuantity?: boolean | number | null) {
return tradeByQuantity === true || Number(tradeByQuantity) === 1
? 'معامله مقداری'
: 'معامله تعدادی'
}
export function resolveMeltedPriceSource(product?: {
reading_method?: string | null
direct_price?: number | null
+27
View File
@@ -95,6 +95,7 @@ export function computeProductEditorPricePreview(input: {
form: {
group_type?: string
price?: number | null
amount?: number | null
reading_method?: string | null
price_offset_percent?: number | null
use_last_price_if_zero?: boolean | null
@@ -168,6 +169,32 @@ export function computeProductEditorPricePreview(input: {
: { status: 'empty', message: 'قیمت وسط را وارد کنید' }
}
if (input.methodOption === '3') {
const weight = Number(input.form.amount) > 0 ? Number(input.form.amount) : 1
const base = resolveAppBasePrice(input.appLastPrice)
if (base > 0) {
const perGram = adjustedProductPrice(base, 0, offsetPercent)
return {
status: 'ready',
amount: Math.round(perGram * weight),
source: 'قیمت صفحه اصلی اپ × وزن'
}
}
const fallback = resolveStoredFallbackPrice(
input.form.price,
input.form.use_last_price_if_zero ?? false,
input.appLastPrice?.last_historical
)
if (fallback > 0) {
return {
status: 'ready',
amount: Math.round(adjustedProductPrice(fallback, 0, offsetPercent) * weight),
source: 'آخرین قیمت ذخیره‌شده × وزن'
}
}
return { status: 'empty', message: 'قیمت اپ در دسترس نیست' }
}
if (!input.form.reading_method) {
return { status: 'empty', message: 'مرجع قیمت را انتخاب کنید' }
}