admin big changes

This commit is contained in:
2026-06-13 17:38:35 +03:30
parent 213ef1a1b7
commit 0c404ee3e1
14 changed files with 188 additions and 276 deletions
@@ -3,24 +3,13 @@
class="setting-card" class="setting-card"
:class="{ :class="{
'setting-card--readonly': readonly, 'setting-card--readonly': readonly,
'setting-card--saving': saving, 'setting-card--saving': saving
'setting-card--inactive': !isActive && fieldType !== 'switch'
}" }"
> >
<div class="setting-card__head"> <div class="setting-card__head">
<div class="setting-card__title-row"> <div class="setting-card__title-row">
<span v-if="readonly" class="setting-card__lock" title="فقط‌خواندنی">🔒</span> <span v-if="readonly" class="setting-card__lock" title="فقط‌خواندنی">🔒</span>
<span class="setting-card__title">{{ title }}</span> <span class="setting-card__title">{{ title }}</span>
<a-switch
v-if="fieldType !== 'switch'"
:checked="isActive"
size="small"
class="setting-card__active-switch"
@change="onActiveChange"
>
<template #checkedChildren>فعال</template>
<template #unCheckedChildren>غیرفعال</template>
</a-switch>
</div> </div>
<div class="setting-card__key">کلید: {{ settingKey }}</div> <div class="setting-card__key">کلید: {{ settingKey }}</div>
<div v-if="help" class="setting-card__help">{{ help }}</div> <div v-if="help" class="setting-card__help">{{ help }}</div>
@@ -62,7 +51,6 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:value': [value: string] 'update:value': [value: string]
'update:isActive': [value: boolean]
}>() }>()
const settingKey = computed(() => props.row.key ?? '') const settingKey = computed(() => props.row.key ?? '')
@@ -74,12 +62,6 @@ const fieldType = computed<SettingFieldType>(() =>
resolveSettingFieldType({ key: props.row.key, type: props.row.type }) resolveSettingFieldType({ key: props.row.key, type: props.row.type })
) )
const isActive = computed(() => props.row.is_active !== false && props.row.is_active !== 0)
function onActiveChange(checked: boolean) {
emit('update:isActive', checked)
}
const help = computed(() => helpText(props.row)) const help = computed(() => helpText(props.row))
</script> </script>
@@ -108,11 +90,6 @@ const help = computed(() => helpText(props.row))
border-color: #91caff; border-color: #91caff;
} }
&--inactive {
opacity: 0.72;
background: #fafafa;
}
&__head { &__head {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -123,11 +100,6 @@ const help = computed(() => helpText(props.row))
gap: 6px; gap: 6px;
} }
&__active-switch {
margin-right: auto;
flex-shrink: 0;
}
&__lock { &__lock {
flex-shrink: 0; flex-shrink: 0;
font-size: 13px; font-size: 13px;
@@ -6,8 +6,8 @@
:disabled="disabled" :disabled="disabled"
@change="onSwitchChange" @change="onSwitchChange"
> >
<template #checkedChildren>فعال</template> <template #checkedChildren>{{ labels.on }}</template>
<template #unCheckedChildren>غیرفعال</template> <template #unCheckedChildren>{{ labels.off }}</template>
</a-switch> </a-switch>
<ZaarboxPriceKeyField <ZaarboxPriceKeyField
@@ -63,10 +63,11 @@
<script setup lang="ts"> <script setup lang="ts">
import ZaarboxPriceKeyField from './ZaarboxPriceKeyField.vue' import ZaarboxPriceKeyField from './ZaarboxPriceKeyField.vue'
import { import {
isSwitchOn,
parseSelectOptions, parseSelectOptions,
resolveSettingFieldType, resolveSettingFieldType,
switchToString, resolveSwitchChecked,
switchLabels,
switchValueFromChecked,
type SettingFieldType type SettingFieldType
} from './settingTypes' } from './settingTypes'
@@ -101,10 +102,12 @@ const selectItems = computed(() =>
}) })
) )
const switchChecked = computed(() => isSwitchOn(props.modelValue)) const labels = computed(() => switchLabels(props.settingKey))
const switchChecked = computed(() => resolveSwitchChecked(props.settingKey, props.modelValue))
function onSwitchChange(checked: boolean) { function onSwitchChange(checked: boolean) {
emit('update:value', switchToString(checked)) emit('update:value', switchValueFromChecked(props.settingKey, checked))
} }
function onSelectChange(val: string | undefined) { function onSelectChange(val: string | undefined) {
+30 -49
View File
@@ -14,16 +14,6 @@
</a-input> </a-input>
<div class="settings-toolbar__filters"> <div class="settings-toolbar__filters">
<label class="settings-filter-field">
<span class="settings-filter-field__label">وضعیت</span>
<a-select
v-model:value="filterActive"
allow-clear
placeholder="همه"
class="settings-filter-field__control"
:options="statusFilterOptions"
/>
</label>
<label class="settings-filter-field"> <label class="settings-filter-field">
<span class="settings-filter-field__label">نوع فیلد</span> <span class="settings-filter-field__label">نوع فیلد</span>
<a-select <a-select
@@ -80,7 +70,6 @@
:saving="isSaving(row.id)" :saving="isSaving(row.id)"
:saved-flash="isSavedFlash(row.id)" :saved-flash="isSavedFlash(row.id)"
@update:value="onValueChange(row, $event)" @update:value="onValueChange(row, $event)"
@update:is-active="onActiveChange(row, $event)"
/> />
</a-col> </a-col>
</a-row> </a-row>
@@ -187,11 +176,6 @@ import {
const settingTypeOptions = SETTING_TYPE_OPTIONS const settingTypeOptions = SETTING_TYPE_OPTIONS
const statusFilterOptions = [
{ value: 'active', label: 'فعال' },
{ value: 'inactive', label: 'غیرفعال' }
]
const typeSelectOptions = SETTING_TYPE_OPTIONS.map((t) => ({ const typeSelectOptions = SETTING_TYPE_OPTIONS.map((t) => ({
value: t.key, value: t.key,
label: t.value label: t.value
@@ -202,11 +186,6 @@ const drafts = ref<Record<number, string>>({})
const addConfigVisible = ref(false) const addConfigVisible = ref(false)
const search = ref('') const search = ref('')
const filterType = ref<string | undefined>() const filterType = ref<string | undefined>()
const filterActive = ref<string | undefined>()
function isRowActive(row: Const): boolean {
return row.is_active !== false && row.is_active !== 0
}
const savingIds = ref<Set<number>>(new Set()) const savingIds = ref<Set<number>>(new Set())
const savedFlashIds = ref<Set<number>>(new Set()) const savedFlashIds = ref<Set<number>>(new Set())
@@ -249,8 +228,6 @@ function isEditable(row: Const): boolean {
function matchesFilter(row: Const): boolean { function matchesFilter(row: Const): boolean {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
if (filterActive.value === 'active' && !isRowActive(row)) return false
if (filterActive.value === 'inactive' && isRowActive(row)) return false
if (filterType.value) { if (filterType.value) {
const t = resolveSettingFieldType(row) const t = resolveSettingFieldType(row)
if (t !== filterType.value) return false if (t !== filterType.value) return false
@@ -297,16 +274,29 @@ const SETTING_GROUPS: Array<{
id: 'general', id: 'general',
title: 'عمومی و اطلاعات اپ', title: 'عمومی و اطلاعات اپ',
icon: 'vuesax-linear:home-2', icon: 'vuesax-linear:home-2',
match: (k) => /^(name_|app_|admin_|server_|bace_|key$|color_|logo_|title_pwa|title_fav|metal_type|unit$)/.test(k) match: (k) => /^(name_fa|app_url|admin_url|metal_type|unit$|open_register|auto_active_customer|is_store)/.test(k)
},
{
id: 'chart',
title: 'نمودار قیمت',
icon: 'vuesax-linear:chart-2',
match: (k) => /^chart_/.test(k)
}, },
{ {
id: 'pricing', id: 'pricing',
title: 'قیمت‌گذاری و کارمزدها', title: 'قیمت‌گذاری و تابلو',
icon: 'vuesax-linear:dollar-circle', icon: 'vuesax-linear:dollar-circle',
match: (k) => match: (k) =>
/^(fee_|buy_fee|sell_fee|min_|referral_|gift_for|price_provider|zaarbox_api_url|chart_id)/.test(k) || /^(fee_|buy_fee|sell_fee|min_|price_provider|zaarbox_|main_product$|show_last_price|close_price|startDay|endDay)/.test(k) ||
k.includes('charge_percentage') k.includes('charge_percentage')
}, },
{
id: 'referral',
title: 'معرف، پاداش و ثبت‌نام',
icon: 'vuesax-linear:people',
match: (k) =>
/^(referral_|gift_for|register_referral|register_jibit|has_page_earn_money|jibit_verification)/.test(k)
},
{ {
id: 'montzar', id: 'montzar',
title: 'سرویس همکار (montzar)', title: 'سرویس همکار (montzar)',
@@ -315,22 +305,22 @@ const SETTING_GROUPS: Array<{
}, },
{ {
id: 'features', id: 'features',
title: 'ویژگی‌ها و صفحات', title: 'ویژگی‌ها و اعلان‌ها',
icon: 'vuesax-linear:menu', icon: 'vuesax-linear:menu',
match: (k) => match: (k) => /^(charge_notif|group_sms|main_wallet_products|price_from_nasiryan)/.test(k)
/^(has_page_|has_chat|display_montzar|close_price|startDay|endDay|auto_active|open_register|charge_notif|group_sms|show_last_price|product_update|product_auto_close|is_store|store_status)/.test(k)
}, },
{ {
id: 'integrations', id: 'integrations',
title: 'اتصالات خارجی و درگاه‌ها', title: 'اتصالات خارجی و درگاه‌ها',
icon: 'vuesax-linear:card', icon: 'vuesax-linear:card',
match: (k) => /^(tahesab_|payping_|jibit_|telegram_|bale_|liman_|support_messenger|external_order|profit_gold)/.test(k) match: (k) =>
/^(tahesab_|payping_|telegram_|bale_|support_messenger|external_order|profit_gold|node_)/.test(k)
}, },
{ {
id: 'misc', id: 'misc',
title: 'سایر تنظیمات (متفرقه)', title: 'سایر تنظیمات',
icon: 'vuesax-linear:more-circle', icon: 'vuesax-linear:more-circle',
match: () => true // catch-all match: () => true
} }
] ]
@@ -434,12 +424,6 @@ function onValueChange(row: Const, value: string) {
scheduleSave(row) scheduleSave(row)
} }
async function onActiveChange(row: Const, isActive: boolean) {
if (row.id == null) return
if (isRowActive(row) === isActive) return
await persistRow(row, { isActive })
}
function scheduleSave(row: Const) { function scheduleSave(row: Const) {
const id = row.id! const id = row.id!
if (!debouncedSave.has(id)) { if (!debouncedSave.has(id)) {
@@ -457,21 +441,18 @@ async function persistRowValue(id: number) {
await saveSettingRow(row) await saveSettingRow(row)
} }
async function persistRow(row: Const, overrides?: { isActive?: boolean }) { async function persistRow(row: Const) {
await saveSettingRow(row, overrides) await saveSettingRow(row)
} }
async function saveSettingRow(row: Const, overrides?: { isActive?: boolean }) { async function saveSettingRow(row: Const) {
if (row.id == null) return if (row.id == null) return
const id = row.id const id = row.id
const type = resolveSettingFieldType(row) const type = resolveSettingFieldType(row)
const raw = drafts.value[id] ?? row.value ?? '' const raw = drafts.value[id] ?? row.value ?? ''
const value = formatValueForSave(raw, type) const value = formatValueForSave(raw, type, row.key)
const isActive = if (value === (row.value ?? '')) return
overrides?.isActive !== undefined ? overrides.isActive : isRowActive(row)
if (overrides?.isActive === undefined && value === (row.value ?? '')) return
savingIds.value.add(id) savingIds.value.add(id)
savedFlashIds.value.delete(id) savedFlashIds.value.delete(id)
@@ -482,12 +463,12 @@ async function saveSettingRow(row: Const, overrides?: { isActive?: boolean }) {
key: row.key, key: row.key,
value, value,
description: row.description ?? '', description: row.description ?? '',
is_active: isActive, is_active: true,
title_fa: row.title_fa ?? '', title_fa: row.title_fa ?? '',
type type
}) })
row.value = value row.value = value
row.is_active = isActive row.is_active = true
drafts.value[id] = value drafts.value[id] = value
if (row.key === 'is_store') { if (row.key === 'is_store') {
setStoreModeFromSettings(isSwitchOn(value)) setStoreModeFromSettings(isSwitchOn(value))
@@ -536,7 +517,7 @@ async function ok() {
const payload = { const payload = {
key: form.value.key, key: form.value.key,
value: formatValueForSave(form.value.value ?? '', type), value: formatValueForSave(form.value.value ?? '', type, form.value.key),
description, description,
is_active: form.value.is_active, is_active: form.value.is_active,
title_fa: form.value.title_fa ?? '', title_fa: form.value.title_fa ?? '',
@@ -3,18 +3,21 @@ import { isStoreApp } from '@/utils/config'
export type SettingFieldType = 'text' | 'number' | 'switch' | 'select' | 'longtext' export type SettingFieldType = 'text' | 'number' | 'switch' | 'select' | 'longtext'
/** کلیدهای legacy — در پنل نمایش داده نمی‌شوند (جایگزین: price_provider) */ /** کلیدهای legacy — در پنل نمایش داده نمی‌شوند */
export const HIDDEN_LEGACY_SETTING_KEYS = new Set([ export const HIDDEN_LEGACY_SETTING_KEYS = new Set([
'main_product_price', 'main_product_price',
'montzar_price', 'montzar_price',
'etehadie_api_url', 'etehadie_api_url',
'goldir_price', 'goldir_price',
'price_primary_source', 'price_primary_source',
'price_method' 'price_method',
'montzar_last_product_log',
'montzar_order_response',
'service_charge_percentage'
]) ])
/** فقط وقتی is_store فعال است در پنل دیده می‌شوند */ /** فقط وقتی is_store فعال است در پنل دیده می‌شوند */
export const STORE_ONLY_SETTING_KEYS = new Set(['store_status']) export const STORE_ONLY_SETTING_KEYS = new Set(['store_status', 'price_from_nasiryan'])
export function isSettingVisible(key: string | undefined | null): boolean { export function isSettingVisible(key: string | undefined | null): boolean {
const k = (key || '').trim() const k = (key || '').trim()
@@ -33,8 +36,9 @@ export const SETTING_TYPE_OPTIONS = [
] ]
const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = { const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
chart_use_local_data: 'switch',
price_provider: 'select', price_provider: 'select',
referral_id: 'select', referral_id: 'switch',
payping_gateway_name: 'select', payping_gateway_name: 'select',
text_rule: 'longtext', text_rule: 'longtext',
text_about: 'longtext', text_about: 'longtext',
@@ -43,66 +47,43 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
buy_fee: 'number', buy_fee: 'number',
sell_fee: 'number', sell_fee: 'number',
fee_cash: 'number', fee_cash: 'number',
fee_free: 'number',
min_withdraw: 'number', min_withdraw: 'number',
min_first_buy: 'number', min_first_buy: 'number',
minimum_sell: 'number', minimum_sell: 'number',
gift_for_referrer: 'number', gift_for_referrer: 'number',
gift_for_referral: 'number', gift_for_referral: 'number',
referral_bonus_amount: 'text', referral_bonus_amount: 'text',
referral_count_for_gift: 'number',
montzar_product_id: 'number', montzar_product_id: 'number',
balance_report_exclude_customer_id: 'number', balance_report_exclude_customer_id: 'number',
jibit_verification: 'switch', jibit_verification: 'switch',
register_jibit_verify: 'switch', register_jibit_verify: 'switch',
register_referral_sms_to_referrer: 'switch',
register_referral_bonus_message: 'switch',
open_register: 'switch', open_register: 'switch',
auto_active_customer: 'switch', auto_active_customer: 'switch',
close_price: 'switch', close_price: 'switch',
montzar_price: 'switch',
main_product_price: 'switch',
show_last_price: 'switch', show_last_price: 'switch',
send_to_tahesab: 'switch', send_to_tahesab: 'switch',
profit_gold_api_enabled: 'switch', profit_gold_api_enabled: 'switch',
payping_round_toman: 'switch', payping_round_toman: 'switch',
payping_strict_failed_withdraw: 'switch', payping_strict_failed_withdraw: 'switch',
has_page_earn_money: 'switch', has_page_earn_money: 'switch',
has_page_fund: 'switch',
has_page_permission: 'switch',
has_page_price_trigger: 'switch',
has_page_physical_gold: 'switch',
has_chat: 'switch',
charge_notif: 'switch', charge_notif: 'switch',
group_sms: 'switch', group_sms: 'switch',
display_montzar: 'switch',
credit: 'switch',
credit_daily: 'switch',
credit_error_in_orders: 'switch',
credit_rial_dynamic: 'switch',
open_closed_messages: 'switch',
new_style_create_order: 'switch',
new_style_admin_layout: 'switch',
register: 'switch',
sharh: 'switch',
multi_source: 'switch',
get_orders_pdf: 'switch',
get_orders_excel: 'switch',
auto_order_buy_sell: 'switch',
invite_link: 'switch',
montzar_closed: 'switch', montzar_closed: 'switch',
montzar_open_buy: 'switch', montzar_open_buy: 'switch',
montzar_open_sell: 'switch', montzar_open_sell: 'switch',
product_update_price_on_list: 'switch',
product_auto_close_open_on_list: 'switch',
payping_fin_sync_enabled: 'switch', payping_fin_sync_enabled: 'switch',
external_order_api_enabled: 'switch', external_order_api_enabled: 'switch',
is_store: 'switch', is_store: 'switch',
store_status: 'select', price_from_nasiryan: 'switch',
credit_rial_price_unit: 'select',
zaarbox_price_key: 'select',
node_client_id: 'text', node_client_id: 'text',
node_shared_secret: 'text', node_shared_secret: 'text',
support_messenger_id: 'text', support_messenger_id: 'text',
support_messenger_text: 'text', support_messenger_text: 'text',
support_messenger: 'select' support_messenger: 'select',
zaarbox_price_key: 'select'
} }
const SELECT_LABELS_BY_KEY: Record<string, Record<string, string>> = { const SELECT_LABELS_BY_KEY: Record<string, Record<string, string>> = {
@@ -118,14 +99,22 @@ const SELECT_LABELS_BY_KEY: Record<string, Record<string, string>> = {
const SELECT_OPTIONS_BY_KEY: Record<string, string> = { const SELECT_OPTIONS_BY_KEY: Record<string, string> = {
price_provider: 'montzar,zaarbox,main_product', price_provider: 'montzar,zaarbox,main_product',
referral_id: 'required,',
payping_gateway_name: 'sepal,', payping_gateway_name: 'sepal,',
credit_rial_price_unit: 'gram,mesghal,unit',
store_status: 'باز,بسته',
unit: 'گرم,کیلوگرم', unit: 'گرم,کیلوگرم',
support_messenger: 'bale,telegram,whatsapp,rubika,eitaa,soroush' support_messenger: 'bale,telegram,whatsapp,rubika,eitaa,soroush'
} }
/** سوییچ‌هایی که مقدار required/خالی دارند (نه true/false) */
export const REFERRAL_REQUIRED_KEY = 'referral_id'
export function isReferralRequired(value: string | undefined): boolean {
return (value ?? '').trim().toLowerCase() === 'required'
}
export function referralRequiredToString(checked: boolean): string {
return checked ? 'required' : ''
}
export function resolveSettingFieldType(row: Pick<Const, 'key' | 'type'>): SettingFieldType { export function resolveSettingFieldType(row: Pick<Const, 'key' | 'type'>): SettingFieldType {
const raw = (row.type || '').trim().toLowerCase() const raw = (row.type || '').trim().toLowerCase()
if (['text', 'number', 'switch', 'select', 'longtext'].includes(raw)) { if (['text', 'number', 'switch', 'select', 'longtext'].includes(raw)) {
@@ -169,9 +158,27 @@ export function switchToString(checked: boolean): string {
return checked ? 'true' : 'false' return checked ? 'true' : 'false'
} }
export function formatValueForSave(value: string, type: SettingFieldType): string { export function resolveSwitchChecked(key: string | undefined, value: string | undefined): boolean {
if (key === REFERRAL_REQUIRED_KEY) {
return isReferralRequired(value)
}
return isSwitchOn(value)
}
export function switchValueFromChecked(key: string | undefined, checked: boolean): string {
if (key === REFERRAL_REQUIRED_KEY) {
return referralRequiredToString(checked)
}
return switchToString(checked)
}
export function formatValueForSave(
value: string,
type: SettingFieldType,
key?: string
): string {
if (type === 'switch') { if (type === 'switch') {
return switchToString(isSwitchOn(value)) return switchValueFromChecked(key, isSwitchOn(value) || (key === REFERRAL_REQUIRED_KEY && isReferralRequired(value)))
} }
if (type === 'number') { if (type === 'number') {
return value === '' ? '0' : String(value).replace(/,/g, '') return value === '' ? '0' : String(value).replace(/,/g, '')
@@ -179,6 +186,16 @@ export function formatValueForSave(value: string, type: SettingFieldType): strin
return value ?? '' return value ?? ''
} }
export function switchLabels(key: string | undefined): { on: string; off: string } {
if (key === REFERRAL_REQUIRED_KEY) {
return { on: 'اجباری', off: 'اختیاری' }
}
if (key === 'chart_use_local_data') {
return { on: 'داده خود سایت', off: 'Zaarbox' }
}
return { on: 'فعال', off: 'غیرفعال' }
}
export function helpText(row: Const): string { export function helpText(row: Const): string {
const desc = (row.description || '').trim() const desc = (row.description || '').trim()
if (!desc) return '' if (!desc) return ''
+1 -1
View File
@@ -603,7 +603,7 @@ const resetData = () => {
form.value = { form.value = {
fname: '', fname: '',
lname: '', lname: '',
active: false, active: true,
avatar_id: undefined, avatar_id: undefined,
sts: undefined, sts: undefined,
id: undefined, id: undefined,
@@ -11,29 +11,30 @@
@refresh="reload" @refresh="reload"
@load-more="loadMore" @load-more="loadMore"
> >
<PriceMobileListItem v-for="item in items" :key="item.id" :item="item" @open="openSheet" /> <PriceMobileListItem v-for="item in items" :key="item.id" :item="item" @open="openDetail" />
</MobileCardList> </MobileCardList>
<PriceMobileDetailSheet v-model:visible="sheetVisible" :item="selectedItem" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getPriceTable } from '@/api/price' import { getPriceSyncLogTable } from '@/api/price'
import type { Price } from '@/model/price' import type { PriceSyncLog } from '@/model/priceSyncLog'
import MobileCardList from '@/components/mobile/MobileCardList.vue' import MobileCardList from '@/components/mobile/MobileCardList.vue'
import { useMobileDetailSheet } from '@/components/mobile/composables/useMobileDetailSheet'
import { useMobileTableList } from '@/components/mobile/composables/useMobileTableList' import { useMobileTableList } from '@/components/mobile/composables/useMobileTableList'
import PriceMobileDetailSheet from './PriceMobileDetailSheet.vue'
import PriceMobileListItem from './PriceMobileListItem.vue' import PriceMobileListItem from './PriceMobileListItem.vue'
const { sheetVisible, selectedItem, openSheet, syncSelectedItem } = useMobileDetailSheet<Price>() const emit = defineEmits<{
(e: 'detail', item: PriceSyncLog): void
}>()
const { loading, search, items, hasMore, reload, loadMore, fetch } = useMobileTableList<Price>({ const { loading, search, items, hasMore, reload, loadMore, fetch } = useMobileTableList<PriceSyncLog>({
fetchApi: getPriceTable, fetchApi: getPriceSyncLogTable
onFetched: (rows) => syncSelectedItem(rows)
}) })
function openDetail(item: PriceSyncLog) {
emit('detail', item)
}
onMounted(() => reload()) onMounted(() => reload())
defineExpose({ fetch, openSheet }) defineExpose({ fetch, openDetail })
</script> </script>
@@ -2,12 +2,12 @@
<MobileCard clickable @click="$emit('open', item)"> <MobileCard clickable @click="$emit('open', item)">
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="truncate text-sm font-700 text-gray-900">{{ userName }}</span> <span class="truncate text-sm font-700 text-gray-900">{{ sourceLabel }}</span>
<span class="shrink-0 text-xs text-gray-400">{{ dateLabel }}</span> <span class="shrink-0 text-xs text-gray-400">{{ dateLabel }}</span>
</div> </div>
<div class="flex items-center justify-between gap-2 text-xs"> <div class="flex items-center justify-between gap-2 text-xs">
<span class="truncate text-gray-600">#{{ item.id ?? '-' }}</span> <span class="truncate text-gray-600">{{ item.products_updated }} محصول</span>
<span class="shrink-0 font-700 text-green-700">{{ priceLabel }}</span> <span class="shrink-0 font-700 text-green-700">{{ priceLabel }}</span>
</div> </div>
</div> </div>
@@ -15,20 +15,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Price } from '@/model/price' import type { PriceSyncLog } from '@/model/priceSyncLog'
import MobileCard from '@/components/mobile/MobileCard.vue' import MobileCard from '@/components/mobile/MobileCard.vue'
import { toJalaliNoTime } from '@core/utils/useDataTime' import { toJalaliNoTime } from '@core/utils/useDataTime'
import { usePrice } from '@core/utils/usePrice' import { usePrice } from '@core/utils/usePrice'
const SOURCE_LABELS: Record<string, string> = {
zaarbox: 'تابلو Zaarbox',
legacy: 'فید قدیمی'
}
const props = defineProps<{ const props = defineProps<{
item: Price item: PriceSyncLog
}>() }>()
defineEmits<{ defineEmits<{
(e: 'open', item: Price): void (e: 'open', item: PriceSyncLog): void
}>() }>()
const userName = computed(() => props.item.user?.name || '-') const sourceLabel = computed(() => SOURCE_LABELS[props.item.source] ?? props.item.source)
const dateLabel = computed(() => (props.item.created_at ? toJalaliNoTime(props.item.created_at) : '-')) const dateLabel = computed(() => (props.item.created_at ? toJalaliNoTime(props.item.created_at) : '-'))
const priceLabel = computed(() => usePrice(props.item.price ?? 0, true, '')) const priceLabel = computed(() =>
props.item.liquid_gold_price > 0 ? usePrice(props.item.liquid_gold_price, true, '') : '—'
)
</script> </script>
@@ -1,9 +1,8 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext :title="isStore ? 'تاریخچه همگام‌سازی قیمت' : 'تاریخچه قیمت'"> <BasicShowContext title="تاریخچه قیمت">
<template #extra> <template #extra>
<BaseButton <BaseButton
v-if="isStore"
type="primary" type="primary"
size="small" size="small"
class="flex shrink-0 md:hidden" class="flex shrink-0 md:hidden"
@@ -13,23 +12,12 @@
<div>به‌روزرسانی قیمت</div> <div>به‌روزرسانی قیمت</div>
<BaseIcon icon="vuesax-linear:refresh" Size="16px" /> <BaseIcon icon="vuesax-linear:refresh" Size="16px" />
</BaseButton> </BaseButton>
<BaseButton
v-else
type="primary"
size="small"
class="flex shrink-0 md:hidden"
@click="emits('add')"
>
<div>قیمت گذاری</div>
<BaseIcon icon="vuesax-linear:dollar-circle" Size="16px" />
</BaseButton>
</template> </template>
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
<BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl"> <BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader> <template #extraHeader>
<BaseButton <BaseButton
v-if="isStore"
type="primary" type="primary"
size="small" size="small"
class="flex" class="flex"
@@ -39,10 +27,6 @@
<div>به‌روزرسانی قیمت</div> <div>به‌روزرسانی قیمت</div>
<BaseIcon icon="vuesax-linear:refresh" Size="16px" /> <BaseIcon icon="vuesax-linear:refresh" Size="16px" />
</BaseButton> </BaseButton>
<BaseButton v-else type="primary" size="small" class="flex" @click="emits('add')">
<div>قیمت گذاری</div>
<BaseIcon icon="vuesax-linear:dollar-circle" Size="16px" />
</BaseButton>
</template> </template>
<template #action="{ record }"> <template #action="{ record }">
<div class="flex justify-center gap-2"> <div class="flex justify-center gap-2">
@@ -56,8 +40,12 @@
</template> </template>
</BaseTable> </BaseTable>
<PriceMobileCardList v-else-if="!isStore" ref="mobileListRef" class="min-h-0 flex-1" /> <PriceMobileCardList
<div v-else class="p-4 text-center text-gray-500">در موبایل از نسخه دسکتاپ استفاده کنید</div> v-else
ref="mobileListRef"
class="min-h-0 flex-1"
@detail="emits('detail', $event)"
/>
</div> </div>
</BasicShowContext> </BasicShowContext>
</div> </div>
@@ -67,30 +55,26 @@
import type { TableInput } from '@core/type/baseTable' import type { TableInput } from '@core/type/baseTable'
import useBaseTable from '@core/components/BaseTableOld/hooks/useBaseTable' import useBaseTable from '@core/components/BaseTableOld/hooks/useBaseTable'
import Icon from '@core/components/BaseIcon/src/Icon.vue' import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/priceColumns'
import { storePriceColumns } from '../hooks/storePriceColumns' import { storePriceColumns } from '../hooks/storePriceColumns'
import { usePrefix } from '@core/composable/usePrefix' import { usePrefix } from '@core/composable/usePrefix'
import { getPriceSyncLogTable, getPriceTable } from '@/api/price' import { getPriceSyncLogTable } from '@/api/price'
import { isStoreApp } from '@/utils/config'
import PriceMobileCardList from './PriceMobileCardList.vue' import PriceMobileCardList from './PriceMobileCardList.vue'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import { computed } from 'vue' import type { PriceSyncLog } from '@/model/priceSyncLog'
const props = defineProps<{ syncing?: boolean }>() defineProps<{ syncing?: boolean }>()
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null) const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
const emits = defineEmits(['delete', 'edit', 'add', 'info', 'detail', 'sync']) const emits = defineEmits(['detail', 'sync'])
const isStore = computed(() => isStoreApp())
const { prefixCls } = usePrefix('pophoverstatus') const { prefixCls } = usePrefix('pophoverstatus')
const tblProps = computed(() => ({ const tblProps = {
columns: isStore.value ? storePriceColumns : cols, columns: storePriceColumns,
api: (params: TableInput) => api: (params: TableInput) => getPriceSyncLogTable(params)
isStore.value ? getPriceSyncLogTable(params) : getPriceTable(params) }
}))
async function fetch() { async function fetch() {
await Promise.allSettled([reload(), mobileListRef.value?.fetch?.() as Promise<void>]) await Promise.allSettled([reload(), mobileListRef.value?.fetch?.() as Promise<void>])
+2 -73
View File
@@ -6,74 +6,28 @@
ref="table" ref="table"
class="h-full w-full" class="h-full w-full"
:syncing="syncing" :syncing="syncing"
@add="add"
@detail="openDetail" @detail="openDetail"
@sync="handleSync" @sync="handleSync"
/> />
</div> </div>
</BasicMainCard> </BasicMainCard>
<template v-if="!isStore">
<BasicModal
v-model:visible="priceConfirmVisible"
width="25rem"
@ok="ok"
:confirm-loading="loading"
@close="priceConfirmVisible = false"
>
<div class="flex items-center gap-2 font-bold">
<BaseIcon icon="vuesax-linear:info-circle" color="orange" />
<div>آیا مطمئن به تغییر قیمت می باشید؟</div>
</div>
</BasicModal>
<BasicModal
v-model:visible="addPriceVisible"
width="25rem"
title="قیمت گذاری"
@ok="confirmPrice"
:loading="loading"
@close="addPriceVisible = false"
>
<a-form :model="form" ref="formRef" autocomplete="off">
<div class="gap-4">
<a-form-item label="قیمت جدید" name="price" :rules="inputRules()">
<BasicInput v-model:value="form.price" type="price" addon-before="ریال" />
</a-form-item>
<div v-show="form.price > 10">معادل {{ usePrice(form.price / 10, true, 'تومان') }}</div>
</div>
</a-form>
</BasicModal>
</template>
<PriceSyncDetailModal v-model:visible="detailVisible" :log-id="selectedLogId" /> <PriceSyncDetailModal v-model:visible="detailVisible" :log-id="selectedLogId" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inputRules } from '@/utils/inputRules' import { ref } from 'vue'
import { computed, ref } from 'vue'
import tbl from './components/table.vue' import tbl from './components/table.vue'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue/es' import { message } from 'ant-design-vue/es'
import type { Price } from '@/model/price' import { runPriceSync } from '@/api/price'
import { createPrice, runPriceSync } from '@/api/price'
import { usePrice } from '@core/utils/usePrice'
import { isStoreApp } from '@/utils/config'
import PriceSyncDetailModal from './components/PriceSyncDetailModal.vue' import PriceSyncDetailModal from './components/PriceSyncDetailModal.vue'
import type { PriceSyncLog } from '@/model/priceSyncLog' import type { PriceSyncLog } from '@/model/priceSyncLog'
const loading = ref(false)
const syncing = ref(false) const syncing = ref(false)
const table = ref<null | { fetch: () => void }>(null) const table = ref<null | { fetch: () => void }>(null)
const refreshTable = () => table.value?.fetch() const refreshTable = () => table.value?.fetch()
const isStore = computed(() => isStoreApp())
const form = ref<Price>({})
const formRef = ref<FormInstance>()
const addPriceVisible = ref(false)
const priceConfirmVisible = ref(false)
const detailVisible = ref(false) const detailVisible = ref(false)
const selectedLogId = ref<number | null>(null) const selectedLogId = ref<number | null>(null)
@@ -95,31 +49,6 @@ async function handleSync() {
syncing.value = false syncing.value = false
} }
} }
async function add() {
addPriceVisible.value = true
form.value = {}
}
function confirmPrice() {
formRef.value?.validateFields().then(async () => {
priceConfirmVisible.value = true
})
}
async function ok() {
loading.value = true
try {
await createPrice(form.value)
refreshTable()
priceConfirmVisible.value = false
addPriceVisible.value = false
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
</script> </script>
<style scoped></style> <style scoped></style>
+10 -23
View File
@@ -80,12 +80,10 @@ import {
} from '@/utils/file' } from '@/utils/file'
import { normalizeProductOrderLimitForm, validateProductOrderLimitForm } from '@/utils/productOrderLimit' import { normalizeProductOrderLimitForm, validateProductOrderLimitForm } from '@/utils/productOrderLimit'
import { import {
buildCategorySelectItems,
isMeltedTradeGroup, isMeltedTradeGroup,
PRODUCT_CATEGORY_ABSHODE,
resolveMeltedPriceSource resolveMeltedPriceSource
} from '@/utils/productCategories' } from '@/utils/productCategories'
import { getCategories } from '@/api/category' import { syncAndLoadCategories } from '@/utils/categorySelect'
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
@@ -128,13 +126,8 @@ const metalItems: Array<Item> = [
{ key: 2, value: 'نقره' }, { key: 2, value: 'نقره' },
{ key: 3, value: 'مس' }, { key: 3, value: 'مس' },
] ]
const storeCategorySelectItems = ref<Array<{ key: string; value: string }>>([]) const apiCategorySelectItems = ref<Array<{ key: string; value: string }>>([])
const categorySelectItems = computed(() => { const categorySelectItems = computed(() => apiCategorySelectItems.value)
if (isStoreApp() && storeCategorySelectItems.value.length) {
return storeCategorySelectItems.value
}
return buildCategorySelectItems(metalItems)
})
const priceItems: Array<Item> = [ const priceItems: Array<Item> = [
{ key: 0, value: 'متغیر' }, { key: 0, value: 'متغیر' },
{ key: 1, value: 'ثابت' } { key: 1, value: 'ثابت' }
@@ -197,25 +190,17 @@ watch(addUserVisible, (visible) => {
onMounted(() => { onMounted(() => {
void refreshLivePrices() void refreshLivePrices()
void loadStoreCategories() void loadCategorySelectItems()
}) })
async function loadStoreCategories() { async function loadCategorySelectItems() {
if (!isStoreApp()) return
try { try {
const rows = await getCategories({ select_only: true })
const metalHint = metalItems.map((item) => item.value).join('، ') const metalHint = metalItems.map((item) => item.value).join('، ')
storeCategorySelectItems.value = rows.map((category) => ({ apiCategorySelectItems.value = await syncAndLoadCategories({ metalHint })
key: category.name,
value:
category.name === PRODUCT_CATEGORY_ABSHODE
? `${category.name} (${metalHint})`
: category.name
}))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
storeCategorySelectItems.value = [] apiCategorySelectItems.value = []
message.error('خطا در دریافت دسته‌بندی‌ها')
} }
} }
@@ -285,6 +270,7 @@ async function setProductIndexImage(record: Record<string, any>, productId?: num
} }
async function edit(record: Product) { async function edit(record: Product) {
await loadCategorySelectItems()
addUserVisible.value = true addUserVisible.value = true
editId = record.id editId = record.id
let productData = cloneDeep(record) as Record<string, any> let productData = cloneDeep(record) as Record<string, any>
@@ -333,6 +319,7 @@ async function add() {
editId = undefined editId = undefined
addUserVisible.value = true addUserVisible.value = true
resetData() resetData()
await loadCategorySelectItems()
} }
function resetData() { function resetData() {
+2 -5
View File
@@ -11,7 +11,7 @@ function applyAppModeMeta(route: RouteRecordRaw, appName: string | null) {
const store = isStoreApp() const store = isStoreApp()
if (route.name === 'pricing' && route.meta) { if (route.name === 'pricing' && route.meta) {
route.meta.title = store ? 'تاریخچه قیمت' : 'قیمت گذاری' route.meta.title = 'تاریخچه قیمت'
} }
if (!route.children?.length) return if (!route.children?.length) return
@@ -27,11 +27,8 @@ function applyAppModeMeta(route: RouteRecordRaw, appName: string | null) {
if (child.name === 'banners' && child.meta) { if (child.name === 'banners' && child.meta) {
child.meta.hideMenu = appName !== 'garoosi' child.meta.hideMenu = appName !== 'garoosi'
} }
if (child.name === 'productsMain_categories' && child.meta) {
child.meta.hideMenu = !store
}
if (child.name === 'pricingMain' && child.meta) { if (child.name === 'pricingMain' && child.meta) {
child.meta.title = store ? 'تاریخچه قیمت' : 'قیمت گذاری' child.meta.title = 'تاریخچه قیمت'
} }
applyAppModeMeta(child, appName) applyAppModeMeta(child, appName)
} }
+1 -2
View File
@@ -54,8 +54,7 @@ const Products: RouteRecordRaw = {
component: () => import('@/pages/menuPages/products/categories/index.vue'), component: () => import('@/pages/menuPages/products/categories/index.vue'),
meta: { meta: {
icon: 'vuesax-linear:category', icon: 'vuesax-linear:category',
title: 'دسته‌بندی‌ها', title: 'دسته‌بندی‌ها'
hideMenu: true
} }
} }
] ]
+32
View File
@@ -0,0 +1,32 @@
import { getCategories, syncCategoriesFromProducts } from '@/api/category'
import type { Category } from '@/model/category'
import { PRODUCT_CATEGORY_ABSHODE } from '@/utils/productCategories'
export type CategorySelectItem = { key: string; value: string }
export function mapCategoriesToSelectItems(
rows: Category[],
metalHint = 'طلا، نقره، مس'
): CategorySelectItem[] {
return rows.map((category) => ({
key: category.name,
value:
category.name === PRODUCT_CATEGORY_ABSHODE
? `${category.name} (${metalHint})`
: category.name
}))
}
/** همان منبع جدول دسته‌بندی‌ها — قبل از خواندن، همگام‌سازی می‌کند */
export async function syncAndLoadCategories(options?: {
selectOnly?: boolean
metalHint?: string
}): Promise<CategorySelectItem[]> {
await syncCategoriesFromProducts()
const rows = await getCategories(
options?.selectOnly ? { select_only: true } : undefined
)
return mapCategoriesToSelectItems(rows, options?.metalHint)
}
+3
View File
@@ -7,6 +7,9 @@ export const UNIFIED_PRODUCT_CATEGORIES = [
'گوی', 'گوی',
'سکه', 'سکه',
'سکه پارسیان', 'سکه پارسیان',
'شمش',
'شمش ونوس',
'شمش برنا',
'مس', 'مس',
'نقره', 'نقره',
] as const ] as const