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 | ماشین‌حساب اجرت/سود/مالیات | `components/GoldDayCalculatorModal.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` از قبل لود می‌شد؛ زیر راهنمای برداشت و زیر حساب‌های دسکتاپ نشان داده می‌شود
+2 -1
View File
@@ -23,8 +23,9 @@
<div class="w-full text-center font-bold">{{ noData }}</div>
</template>
<a-select-option
:label="val[props.valueName]"
v-for="val in props.items"
:key="val[props.keyName]"
:label="val[props.valueName]"
:value="val[props.keyName]"
>
<a-tooltip v-if="props.hasTooltip" placement="left" class="cursor-pointer">
@@ -76,9 +76,8 @@
</p>
<div
class="app-touch-slider copper-convert__slider"
@touchstart.stop
@touchmove.stop
@pointerdown.stop
@mousedown.capture="dragHandleOnly"
@touchstart.capture="dragHandleOnly"
>
<a-slider
v-model:value="sliderAmount"
@@ -94,46 +93,9 @@
<span class="copper-convert__measure"><bdi>{{ assetUnit }}</bdi><bdi>{{ formatAssetAmount(assetAmount) }}</bdi></span>
</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">
مانده <span class="copper-convert__measure"><bdi>{{ assetUnit }}</bdi><bdi>{{ formatAssetAmount(remainderAmount) }}</bdi></span> است. هر زبانه فقط عدد صحیح حلقه است؛ اعشاری که به یک حلقه نرسد در دارایی می‌ماند.
با این مقدار می‌توانید <bdi>{{ wire15Rings }}</bdi> حلقه سیم ۱.۵ و <bdi>{{ wire25Rings }}</bdi> حلقه سیم ۲.۵ ببرید. این دو زبانه مکمل‌اند و با هم روی همین مقدار تنظیم می‌شوند.
</p>
<div class="copper-convert__weight">
@@ -144,9 +106,8 @@
</div>
<div
class="app-touch-slider copper-convert__slider"
@touchstart.stop
@touchmove.stop
@pointerdown.stop
@mousedown.capture="dragHandleOnly"
@touchstart.capture="dragHandleOnly"
>
<a-slider
v-model:value="wire15Rings"
@@ -171,9 +132,8 @@
</div>
<div
class="app-touch-slider copper-convert__slider"
@touchstart.stop
@touchmove.stop
@pointerdown.stop
@mousedown.capture="dragHandleOnly"
@touchstart.capture="dragHandleOnly"
>
<a-slider
v-model:value="wire25Rings"
@@ -189,9 +149,42 @@
<span class="copper-convert__measure"><bdi>عدد حلقه</bdi><bdi>{{ maxRings25 }}</bdi></span>
</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">
اعشار مانده: <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>
<BasicButton
type="primary"
@@ -227,7 +220,6 @@ const {
assetUnit,
sliderAmount,
sliderStep,
remainderAmount,
maxRings15,
maxRings25,
wire15Rings,
@@ -241,6 +233,14 @@ const {
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(() => [
{ label: 'کاتد', value: 'katod' as CopperConvertSource },
{ label: 'مفتول', value: 'maftoul' as CopperConvertSource },
@@ -274,12 +274,6 @@ function formatKg(value: number) {
if (value >= 10) return value.toFixed(2)
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>
<style scoped>
@@ -182,8 +182,7 @@ export function useCopperConvertPage() {
watch(source, () => {
sliderAmount.value = assetAmount.value
wire15Rings.value = 0
wire25Rings.value = 0
applyDefaultSplit()
})
watch(
@@ -235,37 +234,81 @@ export function useCopperConvertPage() {
return prices.value.shamshPerKg
})
const remainderValue = computed(() =>
quantityToKg(remainderAmount.value, assetUnit.value, sourceProduct.value) * sourceUnitPrice.value,
)
const selectedValue = computed(() => result.value?.totalValueRial ?? 0)
const maxRings15 = computed(() => {
const price = prices.value.wireRing15
if (price <= 0 || remainderValue.value <= 0) return 0
return Math.floor(remainderValue.value / price)
if (price <= 0 || selectedValue.value <= 0) return 0
return Math.floor(selectedValue.value / price)
})
const maxRings25 = computed(() => {
const price = prices.value.wireRing25
const left = remainderValue.value - wire15Rings.value * prices.value.wireRing15
if (price <= 0 || left <= 0) return 0
return Math.floor(left / price)
if (price <= 0 || selectedValue.value <= 0) return 0
return Math.floor(selectedValue.value / 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 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
if (perUnit <= 0) return remainderAmount.value
if (perUnit <= 0) return 0
return roundAmount(kgToNative(leftValue / perUnit, assetUnit.value, sourceProduct.value))
})
watch([wire15Rings, maxRings15, maxRings25], () => {
const next15 = Math.max(0, Math.min(maxRings15.value, Math.floor(wire15Rings.value)))
if (next15 !== wire15Rings.value) wire15Rings.value = next15
const next25 = Math.max(0, Math.min(maxRings25.value, Math.floor(wire25Rings.value)))
if (next25 !== wire25Rings.value) wire25Rings.value = next25
})
watch(sliderAmount, () => applyDefaultSplit(), { flush: 'sync' })
watch(selectedValue, (value, prev) => {
if ((prev ?? 0) <= 0 && value > 0) applyDefaultSplit()
}, { 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() {
if (submitting.value) return
@@ -116,6 +116,7 @@ import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import DialogVant from '@/components/DialogVant.vue'
import { deletePriceTrigger, updatePriceTrigger } from '@/api/triggerPrice'
import { loading } from '@/utils'
import { usePriceTriggerPageContext } from '../composables/usePriceTriggerPage'
const infoVisible = ref(false)
const isActive = ref(false)
@@ -132,7 +133,17 @@ const props = withDefaults(
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(() => {
isActive.value = props.item.is_active
@@ -10,7 +10,7 @@
<a-form-item :rules="[required()]" label="محصول" name="product_id">
<BasicSelect
v-model:value="form.product_id"
:items="products"
:items="productOptions"
key-name="id"
placeholder="کاتد مس، مفتول مس و..."
size="large"
@@ -62,7 +62,7 @@ const {
editId,
loading,
typeItems,
products,
productOptions,
ok,
} = usePriceTriggerPageContext()
</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 { type FormInstance, message } from 'ant-design-vue'
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 {
items: Ref<PriceTrigger[]>
products: Ref<Product[]>
productOptions: ComputedRef<Array<{ id: number; title: string }>>
addTriggerVisible: Ref<boolean>
editId: Ref<number | undefined>
form: Ref<PriceTrigger>
@@ -41,6 +56,14 @@ export const PRICE_TRIGGER_PAGE_CONTEXT_KEY: InjectionKey<PriceTriggerPageContex
export function usePriceTriggerPage() {
const items = ref<PriceTrigger[]>([])
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 editId = ref<number | undefined>(undefined)
const formRef = ref<FormInstance>()
@@ -72,7 +95,7 @@ export function usePriceTriggerPage() {
function openEdit(val: PriceTrigger) {
form.value = cloneDeep({
...val,
product_id: val.product_id ?? val.product?.id,
product_id: resolveProductId(val.product_id ?? val.product?.id),
})
editId.value = form.value.id
addTriggerVisible.value = true
@@ -91,21 +114,30 @@ export function usePriceTriggerPage() {
async function loadProducts() {
try {
products.value = ((await getProducts()) as Product[]) ?? []
const list = await getProducts()
products.value = Array.isArray(list) ? list : []
} catch (e) {
console.log(e)
}
}
async function ok() {
const productId = resolveProductId(form.value.product_id)
form.value.product_id = productId
await formRef.value?.validateFields().then(async () => {
try {
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) {
await updatePriceTrigger(editId.value, form.value)
await updatePriceTrigger(editId.value, payload)
} else {
await createPriceTrigger(form.value)
await createPriceTrigger(payload)
}
message.success('هشدار با موفقیت ایجاد شد')
await initItems()
@@ -126,6 +158,7 @@ export function usePriceTriggerPage() {
const context: PriceTriggerPageContext = {
items,
products,
productOptions,
addTriggerVisible,
editId,
form,
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<div class="price-trigger-page-root">
<DesktopPriceTriggerView v-show="isDesktop" />
<MobilePriceTriggerView v-show="!isDesktop" />
<DesktopPriceTriggerView v-if="isDesktop" />
<MobilePriceTriggerView v-else />
<BasicActionSheet v-if="!isDesktop" v-model:show="addTriggerVisible" teleport="body">
<div class="font-bold text-lg mb-2">هشدار قیمت</div>