fix some bugs

This commit is contained in:
2026-08-03 11:52:44 +03:30
parent e41a898686
commit d6608283f6
13 changed files with 1016 additions and 53 deletions
+176 -17
View File
@@ -1,21 +1,66 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { notification } from 'ant-design-vue'
import { getAppNotificationsUnreadCount } from '@/api/appNotifications'
import { useSettingsStore } from '@/store/settingsStore'
import { isToken } from '@/utils'
import { getToken } from '@/utils'
import { showBrowserNotification } from '@/utils/browserNotification'
import { priceSocketConnected, priceWssEnabled } from '@/utils/priceSocketState'
import { APP_Z_TOAST } from '@/utils/zIndex'
const unreadCount = ref(0)
let pollTimer: ReturnType<typeof setInterval> | null = null
let started = false
let lastStreamNotifId = 0
let hydratedUnread = false
let lastShownNotifId = 0
let toastConfigured = false
let hydrateFallbackTimer: ReturnType<typeof setTimeout> | null = null
async function refreshUnreadCount() {
const settingsStore = useSettingsStore()
if (!isToken() || !settingsStore.isSettingTruthy('has_page_notification')) {
function ensureNotifToastConfig() {
if (toastConfigured) return
toastConfigured = true
notification.config({
placement: 'topRight',
top: 'calc(var(--app-home-header-offset, 4.25rem) + 0.5rem)',
duration: 5,
})
}
function notificationsEnabled(): boolean {
return Boolean(getToken()) && useSettingsStore().isSettingTruthy('has_page_notification')
}
function clearHydrateFallback() {
if (hydrateFallbackTimer) {
clearTimeout(hydrateFallbackTimer)
hydrateFallbackTimer = null
}
}
/** از SSE رویداد unread-count — بدون HTTP */
function applyUnreadCountFromStream(payload: Record<string, unknown>) {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
return
}
const next = Number(payload.unread_count ?? 0) || 0
unreadCount.value = Math.max(0, next)
hydratedUnread = true
clearHydrateFallback()
}
/** فقط وقتی SSE در دسترس نیست یا برای ریفرش دستی صفحه اعلان‌ها */
async function refreshUnreadCount() {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
return
}
try {
const data = await getAppNotificationsUnreadCount()
unreadCount.value = Number(data?.unread_count ?? 0) || 0
hydratedUnread = true
} catch {
// silent — badge optional
}
@@ -25,29 +70,138 @@ function clearUnreadLocally() {
unreadCount.value = 0
}
function showInAppToast(title: string, body: string) {
try {
ensureNotifToastConfig()
notification.open({
message: title || 'اعلان جدید',
description: body || undefined,
placement: 'topRight',
duration: 5,
class: 'app-notif-toast',
style: { zIndex: APP_Z_TOAST },
onClick: () => {
void import('@/router').then((mod) => {
mod.default.push({ name: 'appNotifications' }).catch(() => undefined)
})
},
})
} catch {
// ignore
}
}
async function presentNotification(payload: {
id?: number
title?: string
body?: string
}) {
const id = Number(payload.id ?? 0)
if (id > 0 && id === lastShownNotifId) {
return
}
if (id > 0) {
lastShownNotifId = id
}
const title = String(payload.title ?? '').trim() || 'اعلان جدید'
const body = String(payload.body ?? '').trim()
showInAppToast(title, body)
await showBrowserNotification({
title,
body,
tag: id > 0 ? `app-notification-${id}` : undefined,
url: '/appNotifications',
})
}
/** از SSE رویداد app_notification — بدون polling HTTP */
function bumpUnreadFromStream(payload: Record<string, unknown>) {
if (!notificationsEnabled()) {
return
}
const id = Number(payload.id ?? 0)
if (id > 0 && id === lastStreamNotifId) {
return
}
if (id > 0) {
lastStreamNotifId = id
}
if (payload.unread_count !== undefined && payload.unread_count !== null) {
applyUnreadCountFromStream(payload)
} else {
unreadCount.value = Math.max(0, unreadCount.value) + 1
hydratedUnread = true
clearHydrateFallback()
}
void presentNotification({
id,
title: String(payload.title ?? ''),
body: String(payload.body ?? ''),
})
}
/**
* بدون interval: اگر استریم قیمت روشن است منتظر رویداد SSE می‌مانیم؛
* فقط وقتی wss خاموش است یا SSE تا ۱۲ثانیه نیامد، یک‌بار HTTP می‌زنیم.
*/
function scheduleHydrate() {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
clearHydrateFallback()
return
}
if (!priceWssEnabled.value) {
void refreshUnreadCount()
return
}
if (priceSocketConnected.value || hydratedUnread) {
clearHydrateFallback()
return
}
clearHydrateFallback()
hydrateFallbackTimer = setTimeout(() => {
hydrateFallbackTimer = null
if (!hydratedUnread && notificationsEnabled() && !priceSocketConnected.value) {
void refreshUnreadCount()
}
}, 12000)
}
export function useAppNotificationBadge() {
const settingsStore = useSettingsStore()
const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification'))
onMounted(() => {
if (started) {
void refreshUnreadCount()
return
}
started = true
void refreshUnreadCount()
pollTimer = setInterval(() => {
void refreshUnreadCount()
}, 60000)
scheduleHydrate()
})
onUnmounted(() => {
// keep poll for other mounted headers; only clear if needed later
// module-level badge — keep state for other headers
})
watch(enabled, (on) => {
if (!on) unreadCount.value = 0
else void refreshUnreadCount()
if (!on) {
unreadCount.value = 0
hydratedUnread = false
clearHydrateFallback()
} else {
scheduleHydrate()
}
})
watch([priceSocketConnected, priceWssEnabled], () => {
if (!started) return
scheduleHydrate()
})
return {
@@ -58,4 +212,9 @@ export function useAppNotificationBadge() {
}
}
export { refreshUnreadCount, clearUnreadLocally }
export {
refreshUnreadCount,
clearUnreadLocally,
bumpUnreadFromStream,
applyUnreadCountFromStream,
}
+5 -2
View File
@@ -1,16 +1,19 @@
import type {Wallet} from '@/models/wallet';
import type {Product} from '@/models/products';
export interface Fund {
id?: number,
title?: string,
file_id?: number,
customer_id?: number,
product_id?: number,
created_at?: string,
updated_at?: string,
deleted_at?: string,
wallet?: Wallet[] | Wallet
wallet?: Wallet[] | Wallet,
product?: Product,
}
export interface FundTransaction {
fund_id?: number
value?: number
}
}
+15
View File
@@ -150,6 +150,21 @@ const router = createRouter({
name: 'fund_add',
component: () => import('@/views/base/fund/newFund/index.vue')
},
{
path: 'piggy',
name: 'piggy',
component: () => import('@/views/base/piggy/index.vue')
},
{
path: 'piggy/add',
name: 'piggy_add',
component: () => import('@/views/base/piggy/new.vue')
},
{
path: 'piggy/:id',
name: 'piggy_info',
component: () => import('@/views/base/piggy/info.vue')
},
]
},
{
+18 -25
View File
@@ -1,39 +1,32 @@
import { type ComputedRef, ref } from 'vue'
// import { get } from 'lodash'
import {defineStore} from "pinia";
import {getMe} from "@/api/me";
import {getFundTable} from "@/api/fund";
import type {Fund} from "@/models/fund";
import type {TableOutput} from "@/models/table";
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { getFundTable } from '@/api/fund'
import type { Fund } from '@/models/fund'
import type { TableOutput } from '@/models/table'
import { getProductInventory } from '@/api/products'
export const useFundUserStore = defineStore('fund', () => {
const myFunds = ref<TableOutput<Fund> | undefined>(undefined)
const myPiggyFunds = ref<TableOutput<Fund> | undefined>(undefined)
async function getMyFund() {
try {
// console.log('fff')
myFunds.value = await getFundTable()
// دارایی‌ها — inventory؛ سازگار با صفحهٔ /fund
myFunds.value = await getProductInventory()
return myFunds.value
} catch (e) {
console.log(e)
}
}
/* const permissions: ComputedRef<string[]> = computed(() => {
const permissions: string[] = get(userInfo.value, 'permission', [])
const excludes: string[] = get(userInfo.value, 'exclude', [])
return permissions.filter((item) => !excludes.includes(item))
})*/
/*
function canAccess(permissionKey: string) {
if (isAdmin.value) return true
return permissions.value.includes(permissionKey)
async function getMyPiggyFunds() {
try {
myPiggyFunds.value = await getFundTable()
return myPiggyFunds.value
} catch (e) {
console.log(e)
}
}
*/
/*
const isAdmin = computed(() => {
return userInfo.value?.is_admin
})*/
return { getMyFund, myFunds/* permissions, canAccess, isAdmin*/ }
return { getMyFund, getMyPiggyFunds, myFunds, myPiggyFunds }
})
+52
View File
@@ -344,6 +344,42 @@ function handleListPayload(payload: unknown) {
applyProductsListFromStream(payload as Product[])
}
function handleAppNotificationPayload(payload: unknown) {
if (!payload || typeof payload !== 'object') {
return
}
touchStreamActivity()
void applyAppNotificationFromStream(payload as Record<string, unknown>)
}
function handleUnreadCountPayload(payload: unknown) {
if (!payload || typeof payload !== 'object') {
return
}
touchStreamActivity()
void applyUnreadCountFromStreamPayload(payload as Record<string, unknown>)
}
async function applyAppNotificationFromStream(payload: Record<string, unknown>) {
try {
const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge')
bumpUnreadFromStream(payload)
} catch (error) {
console.warn('App notification SSE apply failed', error)
}
}
async function applyUnreadCountFromStreamPayload(payload: Record<string, unknown>) {
try {
const { applyUnreadCountFromStream } = await import('@/composables/useAppNotificationBadge')
applyUnreadCountFromStream(payload)
} catch (error) {
console.warn('Unread-count SSE apply failed', error)
}
}
function bindStreamEvents(source: EventSource) {
source.addEventListener('last', (event) => {
try {
@@ -361,6 +397,22 @@ function bindStreamEvents(source: EventSource) {
}
})
source.addEventListener('app_notification', (event) => {
try {
handleAppNotificationPayload(JSON.parse(event.data))
} catch (error) {
console.warn('App notification SSE parse failed', error)
}
})
source.addEventListener('unread-count', (event) => {
try {
handleUnreadCountPayload(JSON.parse(event.data))
} catch (error) {
console.warn('Unread-count SSE parse failed', error)
}
})
source.addEventListener('ping', () => {
touchStreamActivity()
})
+3
View File
@@ -1,2 +1,5 @@
/** toast اعلان درون‌برنامه — بالای هدر و ناوبری */
export const APP_Z_TOAST = 5050
/** Overlay dialogs (loading / blocking) */
export const APP_Z_OVERLAY = 5600
@@ -158,6 +158,7 @@ import { getAllPrices } from '@/api/home'
import { loading } from '@/utils'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useSettingsStore } from '@/store/settingsStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { resolveAssetsRialValue } from '@/utils/productAssets'
import { usePrice, usePriceWithoutType, useTowDecimal, formatGoldAmount } from '@/utils/usePrice'
@@ -178,6 +179,7 @@ import { message } from 'ant-design-vue'
const emit = defineEmits<{ logout: []; openBalance: [] }>()
const userStore = useUserStore()
const walletStore = useWalletStore()
const settingsStore = useSettingsStore()
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const goldVal = ref<number | undefined>()
@@ -220,13 +222,29 @@ const totalRialDisplay = computed(() =>
resolveAssetsRialValue(assetsSummary.value, getTradeBuyPrice(price.value) || Number(goldVal.value ?? 0))
)
const mainActions = [
{ name: 'buySell', icon: 'vuesax-linear:money-receive', label: 'خرید و فروش', handler: () => router.push({ name: 'gold' }) },
{ name: 'deposit', icon: 'vuesax-linear:card-add', label: 'واریز نقدی', handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }) },
{ name: 'balance', icon: 'vuesax-linear:receipt-item', label: 'موجودی', handler: () => emitBalance() },
{ name: 'fund', icon: 'vuesax-linear:archive-1', label: 'دارایی ها', handler: () => router.push({ name: 'fund' }) },
{ name: 'earn', icon: 'vuesax-linear:dollar-circle', label: 'کسب درآمد', handler: () => router.push({ name: 'earnMoney' }) },
]
const mainActions = computed(() => {
const actions = [
{ name: 'buySell', icon: 'vuesax-linear:money-receive', label: 'خرید و فروش', handler: () => router.push({ name: 'gold' }) },
{ name: 'deposit', icon: 'vuesax-linear:card-add', label: 'واریز نقدی', handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }) },
{ name: 'balance', icon: 'vuesax-linear:receipt-item', label: 'موجودی', handler: () => emitBalance() },
{ name: 'fund', icon: 'vuesax-linear:archive-1', label: 'دارایی ها', handler: () => router.push({ name: 'fund' }) },
]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
actions.push({
name: 'piggy',
icon: 'vuesax-linear:safe-home',
label: 'قلک',
handler: () => router.push({ name: 'piggy' }),
})
}
actions.push({
name: 'earn',
icon: 'vuesax-linear:dollar-circle',
label: 'کسب درآمد',
handler: () => router.push({ name: 'earnMoney' }),
})
return actions
})
function emitBalance() {
emit('openBalance')
+19 -2
View File
@@ -67,7 +67,7 @@
<!-- <div class="bg-gradient-to-l mt-3 p-1 shadow-lg from-primary rounded-lg p-px to-transparent ">-->
<div class="mt-3 p-1 shadow-lg from-primary rounded-lg p-px to-transparent border-solid border border-primary">
<div class=" p-1 py-6 rounded-lg bg-white">
<div class="flex justify-between ">
<div class="flex justify-between items-start gap-1">
<!-- <div
class="text-center w-full flex-flex-col items-center home_appear_fade_in_up"
style="text-align: -webkit-center"
@@ -83,7 +83,7 @@
<div class="text-primary">فروش</div>
</div>-->
<div
class="text-center w-full flex-flex-col items-center home_appear_fade_in_up"
class="text-center min-w-0 flex-1 flex-flex-col items-center home_appear_fade_in_up"
style="text-align: -webkit-center"
@click="router.push({ name: 'gold' })"
>
@@ -136,6 +136,20 @@
</div>
<div class="text-primary">دارایی ها</div>
</div>
<div
v-if="productFundsEnabled"
class="text-center w-full flex-flex-col items-center home_appear_fade_in_down"
style="text-align: -webkit-center"
@click="router.push({ name: 'piggy' })"
>
<div
class="rounded-full w-[50px] h-[50px] bg-primary flex items-center justify-center"
style=" border: solid 1px #a72b77"
>
<Icon class="text-white font-bold" icon="vuesax-linear:safe-home" />
</div>
<div class="text-primary">قلک</div>
</div>
<div
class="text-center w-full flex-flex-col items-center home_appear_fade_in_down"
style="text-align: -webkit-center"
@@ -438,6 +452,7 @@ import { useIntersectionObserver } from '@vueuse/core'
import { loading, removeToken } from '@/utils'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useSettingsStore } from '@/store/settingsStore'
import DialogVant from '@/components/DialogVant.vue'
import { getAllPrices, getChart } from '@/api/home'
import Vue3Odometer from 'vue3-odometer'
@@ -470,7 +485,9 @@ import AppHeader from '@/views/base/home/components/AppHeader.vue'
const userStore = useUserStore()
const walletStore = useWalletStore()
const settingsStore = useSettingsStore()
const isDesktop = useDesktopBreakpoint()
const productFundsEnabled = computed(() => settingsStore.isSettingTruthy('enable_product_funds'))
const chartLoading = ref<boolean>(false)
const openModal = ref<boolean>(false)
const wholeBalanceVisible = ref<boolean>(false)
+187
View File
@@ -0,0 +1,187 @@
<template>
<div class="piggy-page">
<header class="piggy-topbar">
<button type="button" class="piggy-icon-btn" aria-label="بازگشت" @click="goBack">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
</button>
<h1 class="piggy-topbar__title">قلک</h1>
<span class="w-9" />
</header>
<a-spin :spinning="busy">
<section class="piggy-hero">
<div class="piggy-hero__text">
<p class="piggy-hero__label">موجودی قلک‌ها</p>
<p class="piggy-hero__value" dir="ltr">
<span>{{ formatPiggyAmount(totalGold) }}</span>
<span class="piggy-hero__unit">گرم</span>
</p>
<p class="piggy-hero__hint">{{ rows.length }} قلک فعال</p>
</div>
<img class="piggy-hero__art" :src="heroImg" alt="" />
</section>
<section class="piggy-sheet">
<div v-if="!rows.length" class="piggy-empty">
<img :src="emptyImg" alt="" class="piggy-empty__img" />
<p class="piggy-empty__title">هنوز قلکی نساخته‌اید</p>
<p class="piggy-empty__desc">موجودی هر محصول را در قلک‌های جدا نگه دارید</p>
</div>
<div v-else class="piggy-grid">
<button
v-for="item in rows"
:key="item.id"
type="button"
class="piggy-tile"
@click="openInfo(item.id!)"
>
<div class="piggy-tile__icon-wrap">
<img :src="piggyIconForTitle(item.title)" alt="" class="piggy-tile__icon" />
</div>
<div class="piggy-tile__title">{{ item.title || 'بدون عنوان' }}</div>
<div class="piggy-tile__product">{{ fundProductLabel(item) }}</div>
<div class="piggy-tile__amount" dir="ltr">
{{ formatPiggyAmount(fundWalletGold(item)) }}
<span class="piggy-tile__unit">گرم</span>
</div>
</button>
</div>
<BasicButton class="!w-full mt-5" type="primary" size="middle" block @click="goAdd">
<div class="flex items-center justify-center gap-2">
<BasicIcon icon="vuesax-linear:add-circle" size="18" />
<span>افزودن قلک</span>
</div>
</BasicButton>
</section>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import router from '@/router'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { message } from 'ant-design-vue'
import type { Fund } from '@/models/fund'
import cashImg from '@/assets/img/cash_img.svg'
import emptyImg from '@/assets/img/cash_empty.svg'
import {
formatPiggyAmount,
fundProductLabel,
fundWalletGold,
piggyIconForTitle,
} from './piggyIcons'
const fundStore = useFundUserStore()
const settingsStore = useSettingsStore()
const busy = ref(false)
const heroImg = cashImg
const rows = computed(() => (fundStore.myPiggyFunds?.rows ?? []) as Fund[])
const totalGold = computed(() => rows.value.reduce((sum, item) => sum + fundWalletGold(item), 0))
function goBack() {
router.back()
}
function goAdd() {
router.push({ name: 'piggy_add' })
}
function openInfo(id: number) {
router.push({ name: 'piggy_info', params: { id: String(id) } })
}
onMounted(async () => {
await settingsStore.fetchSettings()
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
message.warning('قابلیت قلک غیرفعال است')
router.replace({ name: 'user' })
return
}
try {
busy.value = true
await fundStore.getMyPiggyFunds()
} finally {
busy.value = false
}
})
</script>
<style scoped>
.piggy-page {
@apply min-h-full pb-28;
background: linear-gradient(180deg, #f3efe8 0%, #f8fafc 42%, #f8fafc 100%);
}
.piggy-topbar {
@apply sticky top-0 z-20 flex items-center justify-between px-3 py-3;
background: rgba(243, 239, 232, 0.92);
backdrop-filter: blur(8px);
}
.piggy-topbar__title {
@apply m-0 text-base font-bold text-gray-900;
}
.piggy-icon-btn {
@apply flex h-9 w-9 items-center justify-center rounded-full border-none bg-white/80 text-gray-800;
}
.piggy-hero {
@apply relative flex items-end justify-between gap-3 overflow-hidden px-5 pb-8 pt-2;
}
.piggy-hero__label {
@apply m-0 text-sm text-gray-600;
}
.piggy-hero__value {
@apply m-0 mt-1 flex items-baseline gap-2 text-3xl font-bold text-gray-900;
}
.piggy-hero__unit {
@apply text-sm font-bold text-gray-500;
}
.piggy-hero__hint {
@apply m-0 mt-2 text-xs text-gray-500;
}
.piggy-hero__art {
@apply h-28 w-28 shrink-0 object-contain;
}
.piggy-sheet {
@apply relative z-10 -mt-2 rounded-t-3xl bg-white px-4 pb-6 pt-5 shadow-sm;
min-height: 55vh;
}
.piggy-grid {
@apply grid grid-cols-2 gap-3;
}
.piggy-tile {
@apply flex flex-col items-center rounded-2xl border-none bg-gray-50 p-3 text-center;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.piggy-tile__icon-wrap {
@apply mb-2 flex h-20 w-full items-center justify-center rounded-xl bg-sky-50;
}
.piggy-tile__icon {
@apply h-16 w-16 object-contain;
}
.piggy-tile__title {
@apply w-full truncate text-sm font-bold text-gray-900;
}
.piggy-tile__product {
@apply mt-0.5 w-full truncate text-[11px] text-gray-500;
}
.piggy-tile__amount {
@apply mt-2 flex items-baseline justify-center gap-1 text-sm font-bold text-primary;
}
.piggy-tile__unit {
@apply text-[11px] font-medium text-gray-500;
}
.piggy-empty {
@apply flex flex-col items-center px-4 py-10 text-center;
}
.piggy-empty__img {
@apply mb-3 h-28 w-28 object-contain opacity-90;
}
.piggy-empty__title {
@apply m-0 text-base font-bold text-gray-800;
}
.piggy-empty__desc {
@apply m-0 mt-2 text-sm text-gray-500;
}
</style>
+276
View File
@@ -0,0 +1,276 @@
<template>
<div class="piggy-info">
<header class="piggy-info__top">
<button type="button" class="piggy-icon-btn" aria-label="بازگشت" @click="goBack">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
</button>
<h1 class="piggy-info__title">{{ fund?.title || 'قلک' }}</h1>
<button type="button" class="piggy-icon-btn piggy-icon-btn--danger" aria-label="حذف" @click="onDelete">
<BasicIcon icon="vuesax-linear:trash" size="20" />
</button>
</header>
<a-spin :spinning="busy">
<section class="piggy-info__hero">
<div class="piggy-info__icon-wrap">
<img :src="iconSrc" alt="" class="piggy-info__icon" />
</div>
<p class="piggy-info__product">{{ fund?.product?.title || '—' }}</p>
<p class="piggy-info__balance-label">موجودی این قلک</p>
<p class="piggy-info__balance" dir="ltr">
<span>{{ formatPiggyAmount(potGold) }}</span>
<span class="piggy-info__unit">گرم</span>
</p>
<p class="piggy-info__free">
آزاد قابل واریز:
<span dir="ltr" class="font-bold text-gray-800">{{ formatPiggyAmount(freeGold) }}</span>
گرم
</p>
</section>
<div class="piggy-info__actions">
<BasicButton class="!flex-1" size="middle" @click="openTx(false)">
<div class="flex items-center justify-center gap-2">
<BasicIcon icon="vuesax-linear:add-circle" size="18" />
<span>برداشت</span>
</div>
</BasicButton>
<BasicButton class="!flex-1" type="primary" size="middle" @click="openTx(true)">
<div class="flex items-center justify-center gap-2">
<BasicIcon icon="vuesax-linear:arrow-up-1" size="18" />
<span>واریز</span>
</div>
</BasicButton>
</div>
<section class="piggy-info__tx">
<h2 class="piggy-info__tx-title">تراکنش‌ها</h2>
<div v-if="!transactions.length" class="piggy-info__tx-empty">
<img :src="emptyImg" alt="" class="piggy-info__tx-empty-img" />
<p class="m-0 text-sm text-gray-500">هیچ تراکنشی یافت نشد</p>
</div>
<div v-else class="piggy-info__tx-list">
<div v-for="tx in transactions" :key="tx.id" class="piggy-info__tx-row">
<div class="min-w-0 text-right">
<div class="truncate text-sm font-bold text-gray-900">
{{ tx.type_str || tx.type || 'تراکنش' }}
</div>
<div class="mt-0.5 text-[11px] text-gray-400">{{ tx.created_at || '' }}</div>
</div>
<div class="shrink-0 font-bold text-primary" dir="ltr">
{{ formatPiggyAmount(Number(tx.gold ?? 0)) }}
</div>
</div>
</div>
</section>
</a-spin>
<BasicModal v-model:visible="txVisible" :title="isDeposit ? 'واریز به قلک' : 'برداشت از قلک'">
<div class="flex flex-col gap-3 p-1">
<p class="m-0 text-xs text-gray-500">
{{ isDeposit ? `حداکثر آزاد: ${formatPiggyAmount(freeGold)} گرم` : `حداکثر قلک: ${formatPiggyAmount(potGold)} گرم` }}
</p>
<BasicInput v-model:value="txValue" type="number" placeholder="مقدار به گرم" />
<BasicButton class="!w-full" type="primary" block :disabled="busy" @click="submitTx">
تأیید
</BasicButton>
</div>
</BasicModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import router from '@/router'
import {
deleteFund,
fundDeposit,
fundWithdraw,
getFundById,
getFundTransactionTable,
} from '@/api/fund'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { message, Modal } from 'ant-design-vue'
import type { Fund } from '@/models/fund'
import type { Transaction } from '@/models/transaction'
import emptyImg from '@/assets/img/cash_empty.svg'
import { formatPiggyAmount, piggyIconForTitle } from './piggyIcons'
const fundStore = useFundUserStore()
const settingsStore = useSettingsStore()
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const busy = ref(false)
const fund = ref<Fund>()
const transactions = ref<Transaction[]>([])
const txVisible = ref(false)
const isDeposit = ref(true)
const txValue = ref<string | number | undefined>()
const fundId = computed(() => parseInt(String(router.currentRoute.value.params.id), 10))
const iconSrc = computed(() => piggyIconForTitle(fund.value?.title))
const potGold = computed(() => {
const wallets = Array.isArray(fund.value?.wallet)
? fund.value!.wallet
: fund.value?.wallet
? [fund.value.wallet]
: []
return wallets.reduce((acc, w) => acc + Number((w as { gold?: number })?.gold ?? 0), 0)
})
const freeGold = computed(() => {
const productId = Number(fund.value?.product_id ?? 0)
if (!productId) return 0
const cards = assetsSummary.value?.inventory_cards ?? []
const card = cards.find((c: any) => Number(c.product_id ?? c.id) === productId)
if (card && card.quantity != null) return Number(card.quantity)
return Number(assetsSummary.value?.metal_totals?.[0]?.weight ?? 0)
})
function goBack() {
router.back()
}
function openTx(deposit: boolean) {
isDeposit.value = deposit
txValue.value = undefined
txVisible.value = true
}
async function submitTx() {
const value = Number(txValue.value)
if (!Number.isFinite(value) || value <= 0) {
message.error('مقدار نامعتبر است')
return
}
try {
busy.value = true
const payload = { fund_id: fundId.value, value }
if (isDeposit.value) await fundDeposit(payload)
else await fundWithdraw(payload)
message.success('انجام شد')
txVisible.value = false
await reload()
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
}
function onDelete() {
if (potGold.value > 0) {
message.error('ابتدا موجودی قلک را صفر کنید')
return
}
Modal.confirm({
title: 'حذف قلک',
content: 'آیا از حذف این قلک مطمئن هستید؟',
okText: 'حذف',
cancelText: 'انصراف',
async onOk() {
await deleteFund(fundId.value)
await fundStore.getMyPiggyFunds()
message.success('حذف شد')
router.replace({ name: 'piggy' })
},
})
}
async function reload() {
fund.value = await getFundById(fundId.value)
const walletId = Array.isArray(fund.value?.wallet)
? fund.value?.wallet?.[0]?.id
: (fund.value?.wallet as { id?: number } | undefined)?.id
if (walletId) {
const table = await getFundTransactionTable({ wallet_id: walletId } as any)
transactions.value = (table?.rows ?? []) as Transaction[]
} else {
transactions.value = []
}
await loadWalletAssets()
}
onMounted(async () => {
await settingsStore.fetchSettings()
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
message.warning('قابلیت قلک غیرفعال است')
router.replace({ name: 'user' })
return
}
try {
busy.value = true
await reload()
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
})
</script>
<style scoped>
.piggy-info {
@apply min-h-full bg-gray-50 pb-28;
}
.piggy-info__top {
@apply sticky top-0 z-20 flex items-center justify-between bg-white px-3 py-3 shadow-sm;
}
.piggy-info__title {
@apply m-0 max-w-[55%] truncate text-base font-bold text-gray-900;
}
.piggy-icon-btn {
@apply flex h-9 w-9 items-center justify-center rounded-full border-none bg-gray-50 text-gray-800;
}
.piggy-icon-btn--danger {
@apply text-red-500;
}
.piggy-info__hero {
@apply flex flex-col items-center px-4 pt-6;
}
.piggy-info__icon-wrap {
@apply flex h-36 w-36 items-center justify-center rounded-3xl bg-sky-50;
}
.piggy-info__icon {
@apply h-28 w-28 object-contain;
}
.piggy-info__product {
@apply m-0 mt-3 rounded-full bg-white px-3 py-1 text-xs text-gray-600 shadow-sm;
}
.piggy-info__balance-label {
@apply m-0 mt-4 text-sm text-gray-500;
}
.piggy-info__balance {
@apply m-0 mt-1 flex items-baseline gap-2 text-3xl font-bold text-gray-900;
}
.piggy-info__unit {
@apply text-sm font-bold text-gray-500;
}
.piggy-info__free {
@apply m-0 mt-2 text-xs text-gray-500;
}
.piggy-info__actions {
@apply mt-5 flex gap-3 px-4;
}
.piggy-info__tx {
@apply mt-6 px-4;
}
.piggy-info__tx-title {
@apply m-0 mb-3 text-base font-bold text-gray-900;
}
.piggy-info__tx-empty {
@apply flex flex-col items-center rounded-2xl bg-white px-4 py-10 shadow-sm;
}
.piggy-info__tx-empty-img {
@apply mb-3 h-24 w-24 object-contain opacity-80;
}
.piggy-info__tx-list {
@apply flex flex-col gap-2;
}
.piggy-info__tx-row {
@apply flex items-center justify-between gap-3 rounded-2xl bg-white p-3 shadow-sm;
}
</style>
+182
View File
@@ -0,0 +1,182 @@
<template>
<div class="piggy-create">
<header class="piggy-create__top">
<button type="button" class="piggy-icon-btn" aria-label="بازگشت" @click="goBack">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
</button>
<h1 class="piggy-create__title">{{ editId ? 'ویرایش قلک' : 'ساخت قلک' }}</h1>
<span class="w-9" />
</header>
<a-spin :spinning="busy">
<div class="piggy-create__body">
<div class="piggy-create__preview">
<img :src="previewImg" alt="" class="piggy-create__preview-img" />
</div>
<div class="piggy-create__card">
<label class="piggy-create__label">محصول قلک</label>
<BasicSelect
v-model:value="productId"
class="!w-full"
key-name="id"
value-name="title"
:items="products"
:disabled="Boolean(editId)"
placeholder="انتخاب محصول"
/>
<p class="piggy-create__hint">هر قلک فقط یک محصول دارد — طلا و سکه مخلوط نمی‌شوند</p>
</div>
<div class="piggy-create__card">
<label class="piggy-create__label">عنوان قلک</label>
<BasicInput v-model:value="title" placeholder="مثلاً پس‌انداز" maxlength="25" />
<div class="piggy-create__presets">
<button
v-for="preset in presets"
:key="preset"
type="button"
class="piggy-create__chip"
:class="{ 'piggy-create__chip--on': title === preset }"
@click="title = preset"
>
{{ preset }}
</button>
</div>
</div>
</div>
<div class="piggy-create__footer">
<BasicButton class="!w-full" type="primary" size="middle" block :disabled="busy" @click="submit">
{{ editId ? 'ذخیره تغییرات' : 'ساخت قلک' }}
</BasicButton>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import router from '@/router'
import { createFund, editFund, getFundById } from '@/api/fund'
import { getProducts } from '@/api/products'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { message } from 'ant-design-vue'
import type { Product } from '@/models/products'
import { PIGGY_PRESETS, piggyIconForTitle } from './piggyIcons'
const fundStore = useFundUserStore()
const settingsStore = useSettingsStore()
const busy = ref(false)
const title = ref('پس انداز')
const productId = ref<number | undefined>()
const products = ref<Product[]>([])
const presets = [...PIGGY_PRESETS]
const editId = computed(() => {
const raw = router.currentRoute.value.query.id
const n = parseInt(String(raw ?? ''), 10)
return Number.isFinite(n) && n > 0 ? n : 0
})
const previewImg = computed(() => piggyIconForTitle(title.value))
function goBack() {
router.back()
}
async function submit() {
if (!title.value.trim()) {
message.error('عنوان قلک را وارد کنید')
return
}
if (!editId.value && !productId.value) {
message.error('محصول قلک را انتخاب کنید')
return
}
try {
busy.value = true
if (editId.value) {
await editFund(editId.value, { title: title.value.trim() })
} else {
await createFund({ title: title.value.trim(), product_id: productId.value })
}
await fundStore.getMyPiggyFunds()
message.success(editId.value ? 'قلک ویرایش شد' : 'قلک ساخته شد')
router.replace({ name: 'piggy' })
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
}
onMounted(async () => {
await settingsStore.fetchSettings()
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
message.warning('قابلیت قلک غیرفعال است')
router.replace({ name: 'user' })
return
}
try {
busy.value = true
const list = await getProducts()
products.value = Array.isArray(list) ? list : ((list as any)?.rows ?? (list as any)?.data ?? [])
if (editId.value) {
const fund = await getFundById(editId.value)
title.value = fund?.title ?? ''
productId.value = fund?.product_id
}
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
})
</script>
<style scoped>
.piggy-create {
@apply min-h-full bg-gray-50 pb-28;
}
.piggy-create__top {
@apply sticky top-0 z-20 flex items-center justify-between bg-white px-3 py-3 shadow-sm;
}
.piggy-create__title {
@apply m-0 text-base font-bold text-gray-900;
}
.piggy-icon-btn {
@apply flex h-9 w-9 items-center justify-center rounded-full border-none bg-gray-50 text-gray-800;
}
.piggy-create__body {
@apply flex flex-col items-center gap-4 px-4 pt-6;
}
.piggy-create__preview {
@apply flex h-32 w-32 items-center justify-center rounded-2xl bg-sky-50;
}
.piggy-create__preview-img {
@apply h-24 w-24 object-contain;
}
.piggy-create__card {
@apply w-full rounded-2xl bg-white p-4 shadow-sm;
}
.piggy-create__label {
@apply mb-2 block text-sm font-bold text-gray-800;
}
.piggy-create__hint {
@apply m-0 mt-2 text-xs leading-5 text-gray-500;
}
.piggy-create__presets {
@apply mt-3 flex gap-2 overflow-x-auto whitespace-nowrap pb-1;
}
.piggy-create__chip {
@apply shrink-0 rounded-full border-none bg-gray-100 px-3 py-2 text-xs text-gray-600;
}
.piggy-create__chip--on {
@apply bg-primary text-white;
}
.piggy-create__footer {
@apply fixed bottom-20 left-0 right-0 z-20 px-4;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
import homeImg from '@/assets/img/cash_home.svg'
import giftImg from '@/assets/img/cash_gift.svg'
import circulationImg from '@/assets/img/cash_circulation.svg'
import loveImg from '@/assets/img/cash_love.svg'
import repairsImg from '@/assets/img/cash_repairs.svg'
import savingImg from '@/assets/img/cash_saving.svg'
import type { Fund } from '@/models/fund'
export const PIGGY_PRESETS = [
'پس انداز',
'هدیه',
'ازدواج',
'تعمیرات',
'گردش',
'خرید لوازم منزل',
] as const
export function piggyIconForTitle(title?: string | null): string {
switch ((title ?? '').trim()) {
case 'خرید لوازم منزل':
return homeImg
case 'هدیه':
return giftImg
case 'گردش':
return circulationImg
case 'ازدواج':
return loveImg
case 'تعمیرات':
return repairsImg
case 'پس انداز':
default:
return savingImg
}
}
export function fundWalletGold(item?: Fund | null): number {
if (!item) return 0
const wallets = Array.isArray(item.wallet) ? item.wallet : item.wallet ? [item.wallet] : []
return wallets.reduce((acc, w) => acc + Number((w as { gold?: number })?.gold ?? 0), 0)
}
export function fundProductLabel(item?: Fund | null): string {
if (!item) return '—'
return item.product?.title || (item as { product_title?: string }).product_title || '—'
}
export function formatPiggyAmount(value: number): string {
const n = Number(value) || 0
if (Math.abs(n) < 0.0005) return '۰'
return n.toLocaleString('fa-IR', { maximumFractionDigits: 3 })
}
@@ -111,6 +111,13 @@ export function useUserPage() {
onClick: () => router.push({ name: 'notifyTrigger' }),
visible: settingsStore.isSettingTruthy('has_page_price_trigger'),
},
{
id: 'piggy',
label: 'قلک‌ها',
icon: 'vuesax-linear:safe-home',
onClick: () => router.push({ name: 'piggy' }),
visible: settingsStore.isSettingTruthy('enable_product_funds'),
},
{
id: 'about',
label: 'درباره ما',