fix(user): keep the chosen alert product and split convert weight across both wires

Alerts store the selected product instead of falling back to gold, and the convert sliders turn the chosen asset amount into complementary 1.5 and 2.5 rings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 18:20:13 +03:30
co-authored by Cursor
parent a19f3072f0
commit 47d4fb44df
8 changed files with 171 additions and 85 deletions
@@ -57,4 +57,8 @@
- 2026-11-01 | جای واحد و عدد در تبدیل | `CopperConvertCalculator.vue` + `useCopperConvertPage.ts` | ریال/کیلو/عدد با `bdi` داخل `measure` قبل از عدد؛ باکس مقدار `dir=ltr` - 2026-11-01 | جای واحد و عدد در تبدیل | `CopperConvertCalculator.vue` + `useCopperConvertPage.ts` | ریال/کیلو/عدد با `bdi` داخل `measure` قبل از عدد؛ باکس مقدار `dir=ltr`
- 2026-11-01 | ماشین‌حساب اجرت/سود/مالیات | `components/GoldDayCalculatorModal.vue` | اجرت ساخت مفتول؛ سود مورد انتظار جدا؛ مالیات ارزش افزوده روی (هزینه+اجرت+سود) - 2026-11-01 | ماشین‌حساب اجرت/سود/مالیات | `components/GoldDayCalculatorModal.vue` | اجرت ساخت مفتول؛ سود مورد انتظار جدا؛ مالیات ارزش افزوده روی (هزینه+اجرت+سود)
- 2026-11-01 | پلیس‌هولدر هشدار قیمت | user `priceTrigger/components/PriceTriggerForm.vue` + admin `priceTriggers/components/PriceTriggerEditorFormBody.vue` | «آبشده، سکه، نقره و ...» → «کاتد مس، مفتول مس و...» - 2026-11-01 | پلیس‌هولدر هشدار قیمت | user `priceTrigger/components/PriceTriggerForm.vue` + admin `priceTriggers/components/PriceTriggerEditorFormBody.vue` | «آبشده، سکه، نقره و ...» → «کاتد مس، مفتول مس و...»
- 2026-11-02 | هشدار کاتد→طلا | user `priceTrigger/` + `BasicSelect.vue` + server `Customer/PriceTriggerController.php` | شناسه عددی محصول ارسال شود؛ fallback عنوان «طلا» نباشد. محصول پیش‌فرض const برابر id=1 است
- 2026-11-02 | زبانه تبدیل کشیدنی | `convert/components/CopperConvertCalculator.vue` | `dragHandleOnly` در capture؛ لمس ریل مقدار را جابه‌جا نمی‌کند، فقط کشیدن دستگیره
- 2026-11-02 | سه زبانه تبدیل از اول | همان `CopperConvertCalculator.vue` | زبانه ۱.۵ و ۲.۵ دیگر پشت `remainderAmount > 0` نیستند؛ زیر زبانه مقدار، قبل از نتیجه
- 2026-11-02 | زبانه سیم مکمل | `useCopperConvertPage.ts` | بودجه = ارزش مقدار انتخاب‌شده؛ نصف به ۱.۵ و بقیه به ۲.۵؛ جابه‌جایی یکی دیگری را پر می‌کند
- 2026-11-01 | تاریخچه برداشت کیف پول | `wallet/mobile/MobileWalletView.vue` + `desktop/DesktopWalletView.vue` + `useWalletPageData.ts` | `walletWithdrawItems` از قبل لود می‌شد؛ زیر راهنمای برداشت و زیر حساب‌های دسکتاپ نشان داده می‌شود - 2026-11-01 | تاریخچه برداشت کیف پول | `wallet/mobile/MobileWalletView.vue` + `desktop/DesktopWalletView.vue` + `useWalletPageData.ts` | `walletWithdrawItems` از قبل لود می‌شد؛ زیر راهنمای برداشت و زیر حساب‌های دسکتاپ نشان داده می‌شود
+2 -1
View File
@@ -23,8 +23,9 @@
<div class="w-full text-center font-bold">{{ noData }}</div> <div class="w-full text-center font-bold">{{ noData }}</div>
</template> </template>
<a-select-option <a-select-option
:label="val[props.valueName]"
v-for="val in props.items" v-for="val in props.items"
:key="val[props.keyName]"
:label="val[props.valueName]"
:value="val[props.keyName]" :value="val[props.keyName]"
> >
<a-tooltip v-if="props.hasTooltip" placement="left" class="cursor-pointer"> <a-tooltip v-if="props.hasTooltip" placement="left" class="cursor-pointer">
@@ -76,9 +76,8 @@
</p> </p>
<div <div
class="app-touch-slider copper-convert__slider" class="app-touch-slider copper-convert__slider"
@touchstart.stop @mousedown.capture="dragHandleOnly"
@touchmove.stop @touchstart.capture="dragHandleOnly"
@pointerdown.stop
> >
<a-slider <a-slider
v-model:value="sliderAmount" v-model:value="sliderAmount"
@@ -94,46 +93,9 @@
<span class="copper-convert__measure"><bdi>{{ assetUnit }}</bdi><bdi>{{ formatAssetAmount(assetAmount) }}</bdi></span> <span class="copper-convert__measure"><bdi>{{ assetUnit }}</bdi><bdi>{{ formatAssetAmount(assetAmount) }}</bdi></span>
</div> </div>
</div> </div>
</section>
<section v-if="result" class="copper-convert__result">
<div class="copper-convert__result-head">
<span class="copper-convert__result-kicker">خروجی</span>
<span class="copper-convert__result-title">نتیجه تبدیل</span>
</div>
<div class="copper-convert__rings">
<article class="copper-convert__ring-card">
<span class="copper-convert__ring-badge" dir="ltr">۱.۵ mm²</span>
<p class="copper-convert__ring-count" dir="ltr">{{ formatRings(result.rings15ByValue) }}</p>
<p class="copper-convert__ring-caption">حلقه سیم</p>
</article>
<article class="copper-convert__ring-card">
<span class="copper-convert__ring-badge" dir="ltr">۲.۵ mm²</span>
<p class="copper-convert__ring-count" dir="ltr">{{ formatRings(result.rings25ByValue) }}</p>
<p class="copper-convert__ring-caption">حلقه سیم</p>
</article>
</div>
<div class="copper-convert__summary">
<div class="copper-convert__summary-row">
<span>ارزش معادل</span>
<strong class="copper-convert__measure"><small>ریال</small><bdi>{{ formatRial(result.totalValueRial) }}</bdi></strong>
</div>
<div class="copper-convert__summary-row">
<span>معادل مفتول</span>
<strong class="copper-convert__measure"><small>کیلو</small><bdi>{{ formatKg(result.equivalentMaftoulKg) }}</bdi></strong>
</div>
</div>
</section>
<section v-if="result && remainderAmount > 0" class="copper-convert__split">
<div class="copper-convert__result-head">
<span class="copper-convert__result-kicker">مانده دارایی</span>
<span class="copper-convert__result-title">تقسیم بین دو سیم</span>
</div>
<p class="copper-convert__asset-hint"> <p class="copper-convert__asset-hint">
مانده <span class="copper-convert__measure"><bdi>{{ assetUnit }}</bdi><bdi>{{ formatAssetAmount(remainderAmount) }}</bdi></span> است. هر زبانه فقط عدد صحیح حلقه است؛ اعشاری که به یک حلقه نرسد در دارایی می‌ماند. با این مقدار می‌توانید <bdi>{{ wire15Rings }}</bdi> حلقه سیم ۱.۵ و <bdi>{{ wire25Rings }}</bdi> حلقه سیم ۲.۵ ببرید. این دو زبانه مکمل‌اند و با هم روی همین مقدار تنظیم می‌شوند.
</p> </p>
<div class="copper-convert__weight"> <div class="copper-convert__weight">
@@ -144,9 +106,8 @@
</div> </div>
<div <div
class="app-touch-slider copper-convert__slider" class="app-touch-slider copper-convert__slider"
@touchstart.stop @mousedown.capture="dragHandleOnly"
@touchmove.stop @touchstart.capture="dragHandleOnly"
@pointerdown.stop
> >
<a-slider <a-slider
v-model:value="wire15Rings" v-model:value="wire15Rings"
@@ -171,9 +132,8 @@
</div> </div>
<div <div
class="app-touch-slider copper-convert__slider" class="app-touch-slider copper-convert__slider"
@touchstart.stop @mousedown.capture="dragHandleOnly"
@touchmove.stop @touchstart.capture="dragHandleOnly"
@pointerdown.stop
> >
<a-slider <a-slider
v-model:value="wire25Rings" v-model:value="wire25Rings"
@@ -189,9 +149,42 @@
<span class="copper-convert__measure"><bdi>عدد حلقه</bdi><bdi>{{ maxRings25 }}</bdi></span> <span class="copper-convert__measure"><bdi>عدد حلقه</bdi><bdi>{{ maxRings25 }}</bdi></span>
</div> </div>
</div> </div>
</section>
<section v-if="result" class="copper-convert__result">
<div class="copper-convert__result-head">
<span class="copper-convert__result-kicker">خروجی</span>
<span class="copper-convert__result-title">نتیجه تبدیل</span>
</div>
<div class="copper-convert__rings">
<article class="copper-convert__ring-card">
<span class="copper-convert__ring-badge" dir="ltr">۱.۵ mm²</span>
<p class="copper-convert__ring-count" dir="ltr">{{ wire15Rings }}</p>
<p class="copper-convert__ring-caption">حلقه می‌توانید ببرید</p>
</article>
<article class="copper-convert__ring-card">
<span class="copper-convert__ring-badge" dir="ltr">۲.۵ mm²</span>
<p class="copper-convert__ring-count" dir="ltr">{{ wire25Rings }}</p>
<p class="copper-convert__ring-caption">حلقه می‌توانید ببرید</p>
</article>
</div>
<div class="copper-convert__summary">
<div class="copper-convert__summary-row">
<span>ارزش معادل</span>
<strong class="copper-convert__measure"><small>ریال</small><bdi>{{ formatRial(result.totalValueRial) }}</bdi></strong>
</div>
<div class="copper-convert__summary-row">
<span>معادل مفتول</span>
<strong class="copper-convert__measure"><small>کیلو</small><bdi>{{ formatKg(result.equivalentMaftoulKg) }}</bdi></strong>
</div>
</div>
</section>
<section class="copper-convert__split">
<p class="copper-convert__asset-hint"> <p class="copper-convert__asset-hint">
اعشار مانده: <span class="copper-convert__measure"><bdi>{{ assetUnit }}</bdi><bdi>{{ formatAssetAmount(leftoverAmount) }}</bdi></span> نگه داشته می‌شود. از مقدار انتخاب‌شده، <span class="copper-convert__measure"><bdi>{{ assetUnit }}</bdi><bdi>{{ formatAssetAmount(leftoverAmount) }}</bdi></span> به حلقهٔ کامل نمی‌رسد و در دارایی می‌ماند. باقی دارایی که روی زبانه انتخاب نشده هم دست‌نخورده می‌ماند.
</p> </p>
<BasicButton <BasicButton
type="primary" type="primary"
@@ -227,7 +220,6 @@ const {
assetUnit, assetUnit,
sliderAmount, sliderAmount,
sliderStep, sliderStep,
remainderAmount,
maxRings15, maxRings15,
maxRings25, maxRings25,
wire15Rings, wire15Rings,
@@ -241,6 +233,14 @@ const {
const amountAttempted = computed(() => sliderAmount.value > 0) const amountAttempted = computed(() => sliderAmount.value > 0)
function dragHandleOnly(event: Event) {
const target = event.target
if (target instanceof Element && target.closest('.ant-slider-handle')) {
return
}
event.stopPropagation()
}
const sourceOptions = computed(() => [ const sourceOptions = computed(() => [
{ label: 'کاتد', value: 'katod' as CopperConvertSource }, { label: 'کاتد', value: 'katod' as CopperConvertSource },
{ label: 'مفتول', value: 'maftoul' as CopperConvertSource }, { label: 'مفتول', value: 'maftoul' as CopperConvertSource },
@@ -274,12 +274,6 @@ function formatKg(value: number) {
if (value >= 10) return value.toFixed(2) if (value >= 10) return value.toFixed(2)
return value.toFixed(3) return value.toFixed(3)
} }
function formatRings(value: number) {
if (value >= 100) return value.toFixed(1)
if (value >= 10) return value.toFixed(2)
return value.toFixed(3)
}
</script> </script>
<style scoped> <style scoped>
@@ -182,8 +182,7 @@ export function useCopperConvertPage() {
watch(source, () => { watch(source, () => {
sliderAmount.value = assetAmount.value sliderAmount.value = assetAmount.value
wire15Rings.value = 0 applyDefaultSplit()
wire25Rings.value = 0
}) })
watch( watch(
@@ -235,37 +234,81 @@ export function useCopperConvertPage() {
return prices.value.shamshPerKg return prices.value.shamshPerKg
}) })
const remainderValue = computed(() => const selectedValue = computed(() => result.value?.totalValueRial ?? 0)
quantityToKg(remainderAmount.value, assetUnit.value, sourceProduct.value) * sourceUnitPrice.value,
)
const maxRings15 = computed(() => { const maxRings15 = computed(() => {
const price = prices.value.wireRing15 const price = prices.value.wireRing15
if (price <= 0 || remainderValue.value <= 0) return 0 if (price <= 0 || selectedValue.value <= 0) return 0
return Math.floor(remainderValue.value / price) return Math.floor(selectedValue.value / price)
}) })
const maxRings25 = computed(() => { const maxRings25 = computed(() => {
const price = prices.value.wireRing25 const price = prices.value.wireRing25
const left = remainderValue.value - wire15Rings.value * prices.value.wireRing15 if (price <= 0 || selectedValue.value <= 0) return 0
if (price <= 0 || left <= 0) return 0 return Math.floor(selectedValue.value / price)
return Math.floor(left / price)
}) })
function rings25For(rings15: number) {
const price = prices.value.wireRing25
const left = selectedValue.value - rings15 * prices.value.wireRing15
if (price <= 0 || left <= 0) return 0
return Math.min(maxRings25.value, Math.floor(left / price))
}
function rings15For(rings25: number) {
const price = prices.value.wireRing15
const left = selectedValue.value - rings25 * prices.value.wireRing25
if (price <= 0 || left <= 0) return 0
return Math.min(maxRings15.value, Math.floor(left / price))
}
function defaultSplit() {
const value = selectedValue.value
const price15 = prices.value.wireRing15
if (value <= 0 || price15 <= 0) return { rings15: 0, rings25: 0 }
const rings15 = Math.min(maxRings15.value, Math.floor(value / 2 / price15))
return { rings15, rings25: rings25For(rings15) }
}
let splitLock = false
function writeRings(rings15: number, rings25: number) {
splitLock = true
wire15Rings.value = rings15
wire25Rings.value = rings25
splitLock = false
}
function applyDefaultSplit() {
const split = defaultSplit()
writeRings(split.rings15, split.rings25)
}
const leftoverAmount = computed(() => { const leftoverAmount = computed(() => {
const spent = wire15Rings.value * prices.value.wireRing15 + wire25Rings.value * prices.value.wireRing25 const spent = wire15Rings.value * prices.value.wireRing15 + wire25Rings.value * prices.value.wireRing25
const leftValue = Math.max(0, remainderValue.value - spent) const leftValue = Math.max(0, selectedValue.value - spent)
const perUnit = sourceUnitPrice.value const perUnit = sourceUnitPrice.value
if (perUnit <= 0) return remainderAmount.value if (perUnit <= 0) return 0
return roundAmount(kgToNative(leftValue / perUnit, assetUnit.value, sourceProduct.value)) return roundAmount(kgToNative(leftValue / perUnit, assetUnit.value, sourceProduct.value))
}) })
watch([wire15Rings, maxRings15, maxRings25], () => { watch(sliderAmount, () => applyDefaultSplit(), { flush: 'sync' })
const next15 = Math.max(0, Math.min(maxRings15.value, Math.floor(wire15Rings.value)))
if (next15 !== wire15Rings.value) wire15Rings.value = next15 watch(selectedValue, (value, prev) => {
const next25 = Math.max(0, Math.min(maxRings25.value, Math.floor(wire25Rings.value))) if ((prev ?? 0) <= 0 && value > 0) applyDefaultSplit()
if (next25 !== wire25Rings.value) wire25Rings.value = next25 }, { flush: 'sync' })
})
watch(wire15Rings, (value) => {
if (splitLock) return
const next15 = Math.max(0, Math.min(maxRings15.value, Math.floor(value)))
writeRings(next15, rings25For(next15))
}, { flush: 'sync' })
watch(wire25Rings, (value) => {
if (splitLock) return
const next25 = Math.max(0, Math.min(maxRings25.value, Math.floor(value)))
writeRings(rings15For(next25), next25)
}, { flush: 'sync' })
async function submitRequest() { async function submitRequest() {
if (submitting.value) return if (submitting.value) return
@@ -116,6 +116,7 @@ import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import DialogVant from '@/components/DialogVant.vue' import DialogVant from '@/components/DialogVant.vue'
import { deletePriceTrigger, updatePriceTrigger } from '@/api/triggerPrice' import { deletePriceTrigger, updatePriceTrigger } from '@/api/triggerPrice'
import { loading } from '@/utils' import { loading } from '@/utils'
import { usePriceTriggerPageContext } from '../composables/usePriceTriggerPage'
const infoVisible = ref(false) const infoVisible = ref(false)
const isActive = ref(false) const isActive = ref(false)
@@ -132,7 +133,17 @@ const props = withDefaults(
const emits = defineEmits(['edit', 'refresh']) const emits = defineEmits(['edit', 'refresh'])
const productTitle = computed(() => props.item.product?.title || 'طلا') const { products } = usePriceTriggerPageContext()
const productTitle = computed(() => {
const nested = props.item.product?.title?.trim()
if (nested) {
return nested
}
const id = Number(props.item.product_id ?? props.item.product?.id)
const match = products.value.find((product) => Number(product.id) === id)
return match?.title?.trim() || '—'
})
onMounted(() => { onMounted(() => {
isActive.value = props.item.is_active isActive.value = props.item.is_active
@@ -10,7 +10,7 @@
<a-form-item :rules="[required()]" label="محصول" name="product_id"> <a-form-item :rules="[required()]" label="محصول" name="product_id">
<BasicSelect <BasicSelect
v-model:value="form.product_id" v-model:value="form.product_id"
:items="products" :items="productOptions"
key-name="id" key-name="id"
placeholder="کاتد مس، مفتول مس و..." placeholder="کاتد مس، مفتول مس و..."
size="large" size="large"
@@ -62,7 +62,7 @@ const {
editId, editId,
loading, loading,
typeItems, typeItems,
products, productOptions,
ok, ok,
} = usePriceTriggerPageContext() } = usePriceTriggerPageContext()
</script> </script>
@@ -1,4 +1,4 @@
import { inject, onMounted, provide, reactive, ref, type InjectionKey, type Ref } from 'vue' import { computed, inject, onMounted, provide, reactive, ref, type ComputedRef, type InjectionKey, type Ref } from 'vue'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { type FormInstance, message } from 'ant-design-vue' import { type FormInstance, message } from 'ant-design-vue'
import { createPriceTrigger, getPriceTriggers, updatePriceTrigger } from '@/api/triggerPrice' import { createPriceTrigger, getPriceTriggers, updatePriceTrigger } from '@/api/triggerPrice'
@@ -18,9 +18,24 @@ function emptyForm(): PriceTrigger {
} }
} }
export function resolveProductId(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value
}
if (typeof value === 'string' && value.trim()) {
const id = Number(value)
return Number.isFinite(id) && id > 0 ? id : undefined
}
if (value && typeof value === 'object' && 'id' in value) {
return resolveProductId((value as { id?: unknown }).id)
}
return undefined
}
export interface PriceTriggerPageContext { export interface PriceTriggerPageContext {
items: Ref<PriceTrigger[]> items: Ref<PriceTrigger[]>
products: Ref<Product[]> products: Ref<Product[]>
productOptions: ComputedRef<Array<{ id: number; title: string }>>
addTriggerVisible: Ref<boolean> addTriggerVisible: Ref<boolean>
editId: Ref<number | undefined> editId: Ref<number | undefined>
form: Ref<PriceTrigger> form: Ref<PriceTrigger>
@@ -41,6 +56,14 @@ export const PRICE_TRIGGER_PAGE_CONTEXT_KEY: InjectionKey<PriceTriggerPageContex
export function usePriceTriggerPage() { export function usePriceTriggerPage() {
const items = ref<PriceTrigger[]>([]) const items = ref<PriceTrigger[]>([])
const products = ref<Product[]>([]) const products = ref<Product[]>([])
const productOptions = computed(() =>
products.value
.map((product) => ({
id: Number(product.id),
title: String(product.title ?? '').trim(),
}))
.filter((product) => Number.isFinite(product.id) && product.id > 0 && product.title),
)
const addTriggerVisible = ref(false) const addTriggerVisible = ref(false)
const editId = ref<number | undefined>(undefined) const editId = ref<number | undefined>(undefined)
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
@@ -72,7 +95,7 @@ export function usePriceTriggerPage() {
function openEdit(val: PriceTrigger) { function openEdit(val: PriceTrigger) {
form.value = cloneDeep({ form.value = cloneDeep({
...val, ...val,
product_id: val.product_id ?? val.product?.id, product_id: resolveProductId(val.product_id ?? val.product?.id),
}) })
editId.value = form.value.id editId.value = form.value.id
addTriggerVisible.value = true addTriggerVisible.value = true
@@ -91,21 +114,30 @@ export function usePriceTriggerPage() {
async function loadProducts() { async function loadProducts() {
try { try {
products.value = ((await getProducts()) as Product[]) ?? [] const list = await getProducts()
products.value = Array.isArray(list) ? list : []
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} }
} }
async function ok() { async function ok() {
const productId = resolveProductId(form.value.product_id)
form.value.product_id = productId
await formRef.value?.validateFields().then(async () => { await formRef.value?.validateFields().then(async () => {
try { try {
loading.value = true loading.value = true
form.value.action = 'sms' const payload: PriceTrigger = {
product_id: productId,
price_condition: form.value.price_condition,
is_active: form.value.is_active,
action: 'sms',
target_price: form.value.target_price,
}
if (editId.value) { if (editId.value) {
await updatePriceTrigger(editId.value, form.value) await updatePriceTrigger(editId.value, payload)
} else { } else {
await createPriceTrigger(form.value) await createPriceTrigger(payload)
} }
message.success('هشدار با موفقیت ایجاد شد') message.success('هشدار با موفقیت ایجاد شد')
await initItems() await initItems()
@@ -126,6 +158,7 @@ export function usePriceTriggerPage() {
const context: PriceTriggerPageContext = { const context: PriceTriggerPageContext = {
items, items,
products, products,
productOptions,
addTriggerVisible, addTriggerVisible,
editId, editId,
form, form,
+2 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="price-trigger-page-root"> <div class="price-trigger-page-root">
<DesktopPriceTriggerView v-show="isDesktop" /> <DesktopPriceTriggerView v-if="isDesktop" />
<MobilePriceTriggerView v-show="!isDesktop" /> <MobilePriceTriggerView v-else />
<BasicActionSheet v-if="!isDesktop" v-model:show="addTriggerVisible" teleport="body"> <BasicActionSheet v-if="!isDesktop" v-model:show="addTriggerVisible" teleport="body">
<div class="font-bold text-lg mb-2">هشدار قیمت</div> <div class="font-bold text-lg mb-2">هشدار قیمت</div>