change responsive]

This commit is contained in:
2026-05-31 11:51:52 +03:30
parent 06568c5858
commit 023b8ee5be
124 changed files with 6296 additions and 4233 deletions
+66
View File
@@ -0,0 +1,66 @@
<template>
<CollapsePanel :key="activeKey">
<template #header>
<div class="w-full">
<div class="flex justify-between items-center mb-3">
<span>{{ props.title }}</span>
<BaseIcon :class="[{ rotate: props.actives.includes(props.activeKey) }]" icon="vuesax-linear:arrow-left-1" size="17" />
</div>
<div :class="[{ open: props.actives.includes(props.activeKey) }]"></div>
</div>
</template>
<slot name="content"></slot>
</CollapsePanel>
</template>
<script setup lang="ts">
import { CollapsePanel } from 'ant-design-vue'
interface Props {
title: string
activeKey: string | number
actives: string[] | number[]
}
const props = withDefaults(defineProps<Props>(), {})
</script>
<style lang="less">
.open {
width: 100%;
height: 1px;
background-color: #E5E7EB;
}
.rotate {
transform: rotate(-90deg);
transition: all 0.3s;
}
.ant-collapse > .ant-collapse-item > .ant-collapse-header {
direction: rtl !important;
justify-content: space-between !important;
background-color: #fff !important;
font-weight: bold;
}
.ant-collapse{
border: none !important;
}
.ant-collapse > .ant-collapse-item {
border-bottom: none !important;
}
.ant-collapse-content {
border-top: none;
}
.ant-collapse > .ant-collapse-item > .ant-collapse-header .ant-collapse-arrow {
display: none !important;
}
.ant-collapse > .ant-collapse-item > .ant-collapse-header {
padding: 12px 16px 0 !important;
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<BasicModal
v-if="!isMobileView"
:key="`action-sheet-modal-${overlayKey}`"
v-model:visible="visibleProxy"
:title="title"
:width="modalWidth"
:loading="loading"
custom-footer
@close="handleCancel"
@ok="$emit('ok')"
>
<slot />
<template #footer>
<div
v-if="showButton && (showCancel || showOk)"
class="grid gap-3"
:class="[!showOk || !showCancel ? 'grid-cols-1' : 'grid-cols-12']"
>
<AButton
v-if="showCancel"
type="primary"
:class="[{ 'col-span-4': showOk }]"
size="large"
ghost
block
@click="handleCancel"
>
{{ cancelText }}
</AButton>
<AButton
v-if="showOk"
type="primary"
:class="[{ 'col-span-8': showCancel }]"
size="large"
block
@click="$emit('ok')"
>
{{ okText }}
</AButton>
</div>
</template>
</BasicModal>
<ADrawer
v-else-if="isMobileView"
:key="`action-sheet-drawer-${overlayKey}`"
v-model:visible="visibleProxy"
placement="bottom"
:closable="false"
:destroy-on-close="true"
:height="drawerHeight"
class="basic-action-sheet"
>
<div class="basic-action-sheet__container">
<div class="flex cursor-pointer justify-center pt-2" @click="visibleProxy = false">
<span class="w-12 rounded-sm border-t-2 border-t-solid border-t-gray-500"></span>
</div>
<div class="basic-action-sheet__content">
<slot />
</div>
<div
v-if="showButton && (showCancel || showOk)"
class="basic-action-sheet__footer grid gap-3"
:class="[!showOk || !showCancel ? 'grid-cols-1' : 'grid-cols-12']"
>
<AButton
v-if="showCancel"
type="primary"
:class="[{ 'col-span-4': showOk }]"
size="large"
ghost
block
@click="handleCancel"
>
{{ cancelText }}
</AButton>
<AButton
v-if="showOk"
type="primary"
:class="[{ 'col-span-8': showCancel }]"
size="large"
block
@click="$emit('ok')"
>
{{ okText }}
</AButton>
</div>
</div>
</ADrawer>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import BasicModal from '@/components/BasicModal.vue'
import { ACTION_SHEET_HEIGHT } from '@/constants/actionSheet'
import { isMobile } from '@core/utils/is'
interface Props {
visible?: boolean
title?: string
modalWidth?: string
loading?: boolean
drawerHeight?: string
cancelText?: string
showButton?: boolean
showCancel?: boolean
showOk?: boolean
okText?: string
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
title: '',
modalWidth: '36rem',
loading: false,
drawerHeight: ACTION_SHEET_HEIGHT,
cancelText: 'بازگشت',
showButton: false,
showCancel: true,
showOk: true,
okText: 'ثبت'
})
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'update:show', value: boolean): void
(e: 'ok'): void
(e: 'cancel'): void
(e: 'close'): void
}>()
const isMobileView = computed(() => isMobile.value)
const overlayKey = computed(() => (isMobileView.value ? 'drawer' : 'modal'))
const visibleProxy = computed({
get: () => props.visible,
set: (value: boolean) => {
emit('update:visible', value)
emit('update:show', value)
}
})
function handleCancel() {
emit('cancel')
emit('close')
visibleProxy.value = false
}
</script>
<style lang="less">
.basic-action-sheet {
.ant-drawer-content {
border-radius: 16px 16px 0 0;
overflow: hidden;
}
.ant-drawer-body {
padding: 0;
height: 100%;
}
}
.basic-action-sheet__container {
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
.basic-action-sheet__content {
flex: 1;
overflow-y: auto;
padding: 0 16px 12px;
}
.basic-action-sheet__footer {
position: sticky;
bottom: 0;
z-index: 2;
background: #fff;
padding: 8px 16px calc(16px + env(safe-area-inset-bottom));
border-top: 1px solid #f0f0f0;
}
</style>
+13 -4
View File
@@ -1,11 +1,20 @@
<template>
<a-card class="m-5 rounded-xl md:h-[91vh]">
<slot></slot>
</a-card>
<div class="box-border flex h-full min-h-0 min-w-0 w-full max-w-full flex-col overflow-hidden p-3 md:p-5">
<a-card class="basic-main-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
<slot></slot>
</a-card>
</div>
</template>
<script></script>
<style scoped>
.basic-main-card :deep(.ant-card-body) {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
</style>
-3
View File
@@ -1,14 +1,11 @@
<template>
<div>
<!-- {{ props.menu }}-->
<AMenu
v-model:selectedKeys="selectedKeys"
:style="{ width: props.width }"
:mode="props.mode"
:theme="props.theme"
>
<!-- <a-menu-item key="1"> Navigation One </a-menu-item>-->
<!-- {{ props.menu }}-->
<BasicMenuItem :menu="props.menu" />
</AMenu>
</div>
+176
View File
@@ -0,0 +1,176 @@
<template>
<nav class="mobile-bottom-nav md:hidden" aria-label="منوی اصلی">
<button
v-for="item in navItems"
:key="String(item.name)"
type="button"
class="mobile-bottom-nav__item"
:class="{ 'mobile-bottom-nav__item--active': isRouteActive(item) }"
@click="navigateTo(item)"
>
<span class="mobile-bottom-nav__icon-wrap">
<BaseIcon
v-if="item.meta?.icon"
:icon="item.meta.icon as string"
:size="menuIconSize"
/>
</span>
<span class="mobile-bottom-nav__label">{{ item.meta?.title ?? item.name }}</span>
</button>
<BasicMobileOverflowMenu :items="overflowMenuItems" @select="navigateTo" />
</nav>
</template>
<script setup lang="ts">
import BaseIcon from '@core/components/BaseIcon'
import { menuIconSize } from '@root/config'
import { useRouterStore } from '@core/stores/router'
import BasicMobileOverflowMenu from '@/components/BasicMobileOverflowMenu.vue'
import { sortBy } from 'lodash'
import { computed } from 'vue'
import type { RouteRecordName, RouteRecordRaw } from 'vue-router'
const BOTTOM_NAV_PRIORITY: (string | symbol)[] = ['dashboard', 'orders', 'accounting', 'customers']
const router = useRouter()
const currentRoute = useRoute()
const routerStore = useRouterStore()
const allMenuItems = computed(() =>
sortBy(
routerStore.routes.filter((item) => !item.meta?.hideMenu),
(item) => item.meta?.order
)
)
const navItems = computed(() => {
const items: RouteRecordRaw[] = []
for (const name of BOTTOM_NAV_PRIORITY) {
const route = allMenuItems.value.find((item) => item.name === name)
if (route) items.push(route)
}
for (const route of allMenuItems.value) {
if (items.length >= 4) break
if (!items.some((item) => item.name === route.name)) {
items.push(route)
}
}
return items.slice(0, 4)
})
const navItemNames = computed(() => new Set(navItems.value.map((item) => item.name)))
const overflowMenuItems = computed(() =>
allMenuItems.value.filter((item) => !navItemNames.value.has(item.name))
)
function isRouteActive(route: RouteRecordRaw): boolean {
if (currentRoute.name === route.name) return true
function checkChildren(children?: RouteRecordRaw[]): boolean {
if (!children?.length) return false
for (const child of children) {
if (child.name === currentRoute.name) return true
if (checkChildren(child.children)) return true
}
return false
}
return checkChildren(route.children)
}
function navigateTo(item: RouteRecordRaw) {
const redirect = item.redirect
if (redirect && typeof redirect === 'object' && 'name' in redirect) {
router.push({ name: redirect.name as RouteRecordName })
return
}
router.push({ name: item.name })
}
</script>
<style scoped lang="less">
.mobile-bottom-nav {
position: fixed;
right: 0;
left: 0;
bottom: 0;
z-index: 100;
display: flex;
align-items: stretch;
justify-content: space-around;
gap: 4px;
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #f0f0f0;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
}
.mobile-bottom-nav__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 0;
padding: 6px 4px 8px;
border: none;
background: transparent;
cursor: pointer;
position: relative;
color: #595959;
&--active {
color: #1677ff;
.mobile-bottom-nav__icon-wrap {
background: #e6f4ff;
color: #1677ff;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 28px;
height: 3px;
border-radius: 3px 3px 0 0;
background: #1677ff;
}
}
&:active {
opacity: 0.85;
}
}
.mobile-bottom-nav__icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 32px;
border-radius: 10px;
color: #292d32;
:deep(svg path) {
stroke: currentColor;
}
}
.mobile-bottom-nav__label {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
</style>
+117 -146
View File
@@ -1,22 +1,18 @@
<template>
<div theme="light" :class="[prefixCls]" class="md:hidden">
<div class="flex items-center justify-center gap-2 relative">
<BaseIcon
icon="vuesax-linear:menu"
class="p-3 absolute right-2 top-1"
@click="showDrawer"
/>
<img :src="getConfigApp('avatar')" class="w-4rem mr-2 my-5" />
<div class="absolute left-2 top-3">
<div class="flex items-center w-full justify-center"></div>
<div class="mobile-header-bar">
<span class="mobile-header-bar__name">{{ displayName }}</span>
<div class="mobile-header-bar__logo">
<BasicMobileHeaderLogo />
</div>
<div class="mobile-header-bar__actions">
<button type="button" class="mobile-header-bar__icon" aria-label="رفرش صفحه" @click="handleRefresh">
<Icon icon="vuesax-linear:refresh" :size="26" />
</button>
<ADropdown placement="bottomLeft">
<a class="ant-dropdown-link" @click.prevent>
<!-- Hover me-->
<div class="flex items-center gap-1 text-gray-400">
<AAvatar :src="placeholderM" />
<Icon icon="vuesax-linear:arrow-down" size="15" />
</div>
</a>
<button type="button" class="mobile-header-bar__icon" aria-label="پروفایل">
<Icon icon="vuesax-linear:profile-circle" :size="26" />
</button>
<template #overlay>
<AMenu>
<AMenuItem>
@@ -41,98 +37,53 @@
</AMenu>
</template>
</ADropdown>
<BaseButton
iconSize="20"
icon="vuesax-linear:setting"
type="text"
v-if="layoutStore.showSettingsIcon"
@click="handleShowSettings"
/>
</div>
<BasicModal
v-model:visible="myInfoVisible"
width="40rem"
title="اطلاعات حساب کاربری"
@ok="ok"
:loading="loading"
@close="myInfoVisible = false"
>
<a-form :model="form" ref="formRef" autocomplete="off">
<div class="grid md:grid-cols-2 gap-4">
<a-form-item label="نام" name="fname" :rules="inputRules()">
<BasicInput v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" name="lname" :rules="inputRules()">
<BasicInput v-model:value="form.lname" />
</a-form-item>
<a-form-item label="شماره موبایل" name="mobile" :rules="inputLenRules(11)">
<BasicInput v-model:value="form.mobile" type="number" />
</a-form-item>
<a-form-item label="رمز کاربری" name="password">
<BasicInput type="password" v-model:value="form.password" />
</a-form-item>
</div>
</a-form>
</BasicModal>
</div>
<BasicModal
v-model:visible="myInfoVisible"
width="40rem"
title="اطلاعات حساب کاربری"
@ok="ok"
:loading="loading"
@close="myInfoVisible = false"
>
<a-form :model="form" ref="formRef" autocomplete="off">
<div class="grid md:grid-cols-2 gap-4">
<a-form-item label="نام" name="fname" :rules="inputRules()">
<BasicInput v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" name="lname" :rules="inputRules()">
<BasicInput v-model:value="form.lname" />
</a-form-item>
<a-form-item label="شماره موبایل" name="mobile" :rules="inputLenRules(11)">
<BasicInput v-model:value="form.mobile" type="number" />
</a-form-item>
<a-form-item label="رمز کاربری" name="password">
<BasicInput type="password" v-model:value="form.password" />
</a-form-item>
</div>
</a-form>
</BasicModal>
</div>
<ADrawer height="460px" :placement="placement" v-model:visible="open" :closable="false" @close="onClose">
<AMenu v-model:selectedKeys="selectedKeys" mode="vertical">
<template v-for="item in menu">
<BasicSubMenu
v-if="!item.meta?.hideChildren && item.children && item.children.length > 0"
:menu-item="item"
:key="item.name"
:level="1"
@click="onClose"
/>
<BaseMenuItem v-else :item="item" :key="item.name" @click="onClose" />
</template>
</AMenu>
</ADrawer>
</template>
<script setup lang="ts">
// import Logo from '@/assets/img/saminLogo.png'
import type { DrawerProps } from 'ant-design-vue'
import { inputLenRules, inputRules } from '@/utils/inputRules'
const placement = ref<DrawerProps['placement']>('bottom')
import { usePrefix } from '@core/composable/usePrefix'
import { useLayoutStore } from '@core/stores/layout'
import BasicMenu from '@core/layouts/composnts/BasicMenu.vue'
import placeholderM from '@core/assets/img/avatars/placeholder-m.jpg'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { useSettingsStore } from '@core/stores/settings'
import { useStaticApiListsStore } from '@/store/staticApisStore'
import BasicMobileHeaderLogo from '@/components/BasicMobileHeaderLogo.vue'
import { useUserStore } from '@/store/userStore'
import type { User } from '@/model/user'
import { createUser, updateMyInformation, updateUser } from '@/api/user'
import { updateMyInformation } from '@/api/user'
import { message } from 'ant-design-vue/es'
import { onMounted, ref } from 'vue'
import { onMounted, ref, computed } from 'vue'
import type { FormInstance } from 'ant-design-vue'
import { cloneDeep, sortBy } from 'lodash'
import { useWindowSize } from '@vueuse/core'
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { RouteRecordName, RouteRecordRaw } from 'vue-router'
import { useRouterStore } from '@core/stores/router'
import BasicSubMenu from '@core/layouts/composnts/BasicSubMenu.vue'
import BaseMenuItem from '@core/layouts/composnts/BaseMenuItem.vue'
import {getConfigApp} from "@/utils/config";
import { cloneDeep } from 'lodash'
const { prefixCls } = usePrefix('basic-header')
const layoutStore = useLayoutStore()
const store = useStaticApiListsStore()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const currentRoute = useRoute()
const selectedKeys = ref<(RouteRecordName | undefined)[]>([])
const routerStore = useRouterStore()
const { width } = useWindowSize()
//my info modal
const loading = ref<boolean>(false)
const myInfoVisible = ref<boolean>(false)
const formRef = ref<FormInstance>()
@@ -150,45 +101,17 @@ const form = ref<User>({
user_type: undefined,
permission: []
})
const displayName = computed(() => {
const info = userStore.userInfo
if (info?.name) return info.name
return [info?.fname, info?.lname].filter(Boolean).join(' ') || 'کاربر'
})
onMounted(() => {
form.value = cloneDeep(userStore?.userInfo) as User
if (width.value <= 426) {
layoutStore.layout = 'side'
layoutStore.sideMenu.mode = 'vertical'
layoutStore.sideMenu.collapsed = false
console.log(layoutStore.layout + width.value)
}
const path: (RouteRecordName | undefined)[] = []
function walkInRoutes(routes: RouteRecordRaw[]) {
for (const route of routes) {
if (route.name == currentRoute.name) {
path.push(route.name)
return true
}
if (route.children && route.children.length > 0) {
path.push(route.name)
const res = walkInRoutes(route.children)
if (!res) {
path.pop()
} else {
return true
}
}
}
return false
}
walkInRoutes(routerStore.routes)
selectedKeys.value = path
})
const menu: ComputedRef<RouteRecordRaw[]> = computed(() => {
return sortBy(routerStore.routes, (item) => {
return item.meta?.order
}).filter((item) => !item.meta?.hideMenu)
})
//submit user
async function ok() {
formRef.value?.validateFields().then(async () => {
loading.value = true
@@ -198,7 +121,6 @@ async function ok() {
message.success('اطلاعات با موفقیت ویرایش شد')
myInfoVisible.value = false
} catch (e) {
// message.error('خطا در بارگذاری اطلاعات')
console.error(e)
} finally {
loading.value = false
@@ -206,30 +128,79 @@ async function ok() {
})
}
//const temp = 12
function handleCollapseSideMenu() {
layoutStore.sideMenu.collapsed = !layoutStore.sideMenu.collapsed
}
function handleShowSettings() {
settingsStore.showSettingsDrawer = !settingsStore.showSettingsDrawer
}
function clearLocalStorage() {
localStorage.clear()
location.reload()
}
const open = ref<boolean>(false)
const showDrawer = () => {
open.value = true
}
const onClose = () => {
open.value = false
console.log(open.value)
function handleRefresh() {
window.location.reload()
}
</script>
<style scoped lang="less"></style>
<style scoped lang="less">
@prefix: ~'@{prefixCls}-basic-header';
.@{prefix} {
padding: 12px 16px;
background-color: #f5f5f5;
border-bottom: none;
}
.mobile-header-bar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 16px;
}
.mobile-header-bar__name {
max-width: 34%;
font-size: 15px;
font-weight: 600;
color: #1f1f1f;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-header-bar__logo {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.mobile-header-bar__actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.mobile-header-bar__icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: #292d32;
cursor: pointer;
line-height: 0;
&:active {
opacity: 0.7;
}
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<template>
<img :src="getConfigApp('avatar')" alt="لوگو" class="mobile-header-logo" />
</template>
<script setup lang="ts">
import { getConfigApp } from '@/utils/config'
</script>
<style scoped lang="less">
.mobile-header-logo {
height: 30px;
width: auto;
object-fit: contain;
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<template>
<div ref="menuRef" class="mobile-overflow-menu">
<button
type="button"
class="mobile-overflow-menu__trigger"
:class="{ 'mobile-overflow-menu__trigger--active': open }"
@click="toggleMenu"
>
<span class="mobile-overflow-menu__icon-wrap">
<BaseIcon icon="vuesax-linear:menu" :size="menuIconSize" />
</span>
<span class="mobile-overflow-menu__label">منو</span>
</button>
<div v-if="open" class="mobile-overflow-menu__panel">
<button
v-for="item in items"
:key="String(item.name)"
type="button"
class="mobile-overflow-menu__panel-item"
@click="handleSelect(item)"
>
<span class="mobile-overflow-menu__panel-label">{{ item.meta?.title ?? item.name }}</span>
<BaseIcon
v-if="item.meta?.icon"
:icon="item.meta.icon as string"
:size="menuIconSize"
class="mobile-overflow-menu__panel-icon"
/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import BaseIcon from '@core/components/BaseIcon'
import { menuIconSize } from '@root/config'
import { onClickOutside } from '@vueuse/core'
import { ref } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
interface Props {
items: RouteRecordRaw[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'select', item: RouteRecordRaw): void
}>()
const open = ref(false)
const menuRef = ref<HTMLElement | null>(null)
onClickOutside(menuRef, () => {
open.value = false
})
function toggleMenu() {
open.value = !open.value
}
function handleSelect(item: RouteRecordRaw) {
open.value = false
emit('select', item)
}
</script>
<style scoped lang="less">
.mobile-overflow-menu {
position: relative;
flex: 1;
}
.mobile-overflow-menu__trigger {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 0;
padding: 6px 4px 8px;
border: none;
background: transparent;
cursor: pointer;
position: relative;
color: #595959;
&:active {
opacity: 0.85;
}
}
.mobile-overflow-menu__trigger--active {
color: #1677ff;
.mobile-overflow-menu__icon-wrap {
background: #e6f4ff;
color: #1677ff;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 28px;
height: 3px;
border-radius: 3px 3px 0 0;
background: #1677ff;
}
}
.mobile-overflow-menu__icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 32px;
border-radius: 10px;
color: #292d32;
}
.mobile-overflow-menu__label {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
}
.mobile-overflow-menu__panel {
position: absolute;
bottom: calc(100% + 12px);
left: 8px;
min-width: 220px;
background: #f5f5f5;
border: 1px solid #e8e8e8;
border-radius: 12px;
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.12);
padding: 10px;
z-index: 210;
}
.mobile-overflow-menu__panel-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border: none;
background: transparent;
border-radius: 8px;
padding: 10px 8px;
color: #262626;
font-size: 18px;
cursor: pointer;
text-align: right;
&:active {
background: #e9e9e9;
}
}
.mobile-overflow-menu__panel-icon {
color: #262626;
}
</style>
+6 -5
View File
@@ -1,7 +1,10 @@
<template>
<div class="py-2 pr-3 w-full h-full relative">
<div class="text-black font-bold">{{ props.title }}</div>
<div class="mt-2 overflow-y-auto p-1 h-[96%]">
<div class="relative flex h-full min-h-0 min-w-0 w-full max-w-full flex-col overflow-hidden py-2 pr-3">
<div class="flex shrink-0 min-w-0 items-center justify-between gap-2">
<div class="truncate text-black font-bold">{{ props.title }}</div>
<slot name="extra" />
</div>
<div class="mt-2 min-h-0 flex-1 overflow-hidden p-1">
<slot />
</div>
</div>
@@ -13,5 +16,3 @@ interface Prop {
}
const props = defineProps<Prop>()
</script>
<style scoped></style>
+28
View File
@@ -0,0 +1,28 @@
<template>
<article
class="rounded-2xl border border-gray-200 bg-white shadow-sm"
:class="[paddingClass, clickable ? 'cursor-pointer' : '']"
@click="clickable ? $emit('click') : undefined"
>
<slot />
</article>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
clickable?: boolean
compact?: boolean
}>(),
{
clickable: false,
compact: true
}
)
defineEmits<{
(e: 'click'): void
}>()
const paddingClass = computed(() => (props.compact ? 'px-3 py-3' : 'p-4'))
</script>
+74
View File
@@ -0,0 +1,74 @@
<template>
<div class="min-w-0 h-full max-w-full overflow-x-hidden overflow-y-auto space-y-3 md:hidden">
<div v-if="$slots.toolbar" class="flex">
<slot name="toolbar" />
</div>
<div v-if="showSearch" class="flex items-center gap-2">
<a-input
v-model:value="searchModel"
:placeholder="searchPlaceholder"
class="w-full flex-1"
@keyup.enter="$emit('search')"
>
<template #prefix>
<BaseIcon icon="vuesax-linear:search-normal" size="18" color="#9ca3af" />
</template>
</a-input>
<BaseButton v-if="showRefresh" type="text" icon="vuesax-linear:refresh" iconSize="18" @click="$emit('refresh')" />
</div>
<a-spin :spinning="loading" tip="در حال دریافت اطلاعات...">
<div class="space-y-2">
<slot />
</div>
<div
v-if="showEmpty"
class="rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-3 py-6 text-center text-sm text-gray-500"
>
{{ emptyText }}
</div>
<div v-if="hasMore" class="mt-3 flex justify-center">
<BaseButton size="small" @click="$emit('loadMore')">بارگذاری بیشتر</BaseButton>
</div>
</a-spin>
<slot name="footer" />
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
loading: boolean
hasMore: boolean
emptyText?: string
showEmpty?: boolean
search?: string
searchPlaceholder?: string
showSearch?: boolean
showRefresh?: boolean
}>(),
{
emptyText: 'موردی یافت نشد',
showEmpty: false,
searchPlaceholder: 'جستجو...',
showSearch: true,
showRefresh: false
}
)
const emit = defineEmits<{
(e: 'search'): void
(e: 'refresh'): void
(e: 'loadMore'): void
(e: 'update:search', value: string): void
}>()
const searchModel = computed({
get: () => props.search ?? '',
set: (value: string) => emit('update:search', value)
})
</script>
+50
View File
@@ -0,0 +1,50 @@
<template>
<div class="flex justify-between rounded-lg border px-3 py-2" :class="rowClass">
<div class="text-xs" :class="labelClass">{{ label }}</div>
<div class="truncate font-700 mr-1" :class="valueClass">
<slot>{{ value }}</slot>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
label: string
value?: string | number
variant?: 'neutral' | 'success' | 'warning' | 'danger'
fullWidth?: boolean
valueClass?: string
}>(),
{
variant: 'neutral',
fullWidth: false
}
)
const rowClass = computed(() => {
const span = props.fullWidth ? 'col-span-2' : ''
const variants: Record<string, string> = {
neutral: 'border-gray-200 bg-gray-50',
success: 'border-green-200 bg-green-50',
warning: 'border-amber-200 bg-amber-50',
danger: 'border-red-200 bg-red-50'
}
return `${span} ${variants[props.variant]}`
})
const labelClass = computed(() => {
if (props.variant === 'success') return 'text-green-700'
if (props.variant === 'warning') return 'text-amber-700'
if (props.variant === 'danger') return 'text-red-700'
return 'text-gray-500'
})
const valueClass = computed(() => {
if (props.valueClass) return props.valueClass
if (props.variant === 'success') return 'text-green-700'
if (props.variant === 'warning') return 'text-amber-700'
if (props.variant === 'danger') return 'text-red-600'
return 'text-gray-900'
})
</script>
@@ -0,0 +1,37 @@
<template>
<BasicActionSheet
v-model:visible="visibleModel"
:title="title"
:showButton="false"
:drawerHeight="drawerHeight"
>
<div v-if="item" class="space-y-3 pb-2">
<slot :item="item" />
</div>
</BasicActionSheet>
</template>
<script setup lang="ts" generic="T">
import { ACTION_SHEET_HEIGHT } from '@/constants/actionSheet'
const props = withDefaults(
defineProps<{
visible: boolean
item: T | null
title?: string
drawerHeight?: string
}>(),
{
drawerHeight: ACTION_SHEET_HEIGHT
}
)
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
}>()
const visibleModel = computed({
get: () => props.visible,
set: (value: boolean) => emit('update:visible', value)
})
</script>
@@ -0,0 +1,34 @@
<template>
<button
type="button"
class="flex shrink-0 items-center justify-center transition-colors"
:class="
plain
? 'h-auto w-auto border-none bg-transparent p-0'
: 'h-9 w-9 rounded-full border-none bg-white active:bg-gray-50'
"
@click.stop="$emit('click')"
>
<BaseIcon :icon="icon" :size="size" :color="color" />
</button>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
icon: string
color?: string
size?: number
plain?: boolean
}>(),
{
color: '#374151',
size: 18,
plain: false
}
)
defineEmits<{
(e: 'click'): void
}>()
</script>
+16
View File
@@ -0,0 +1,16 @@
<template>
<div class="min-w-0">
<p class="text-xs text-gray-500">{{ label }}</p>
<p class="truncate text-sm font-medium text-gray-900" :class="valueClass">
<slot>{{ value }}</slot>
</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string
value?: string | number
valueClass?: string
}>()
</script>
+16
View File
@@ -0,0 +1,16 @@
<template>
<div class="grid gap-3" :class="gridClass">
<slot />
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
cols?: 1 | 2
}>(),
{ cols: 2 }
)
const gridClass = computed(() => (props.cols === 1 ? 'grid-cols-1' : 'grid-cols-2'))
</script>
@@ -0,0 +1,25 @@
<template>
<div class="space-y-3">
<div class="min-w-0 space-y-1">
<div v-if="showTitle" class="text-lg font-700 text-gray-900">
<slot name="title">{{ title }}</slot>
</div>
<div v-if="subtitle || $slots.subtitle" class="text-xs text-gray-500">
<slot name="subtitle">{{ subtitle }}</slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
title?: string
subtitle?: string
showTitle?: boolean
}>(),
{
showTitle: true
}
)
</script>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div class="flex items-center justify-between px-4 py-3" :class="containerClass">
<div class="flex items-center">
<slot name="prefix" />
<span class="font-700" :class="[labelClass, $slots.prefix ? 'mr-2' : '']">{{ label }}</span>
</div>
<div class="font-800" :class="valueClass">
<slot>{{ value }}</slot>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
label: string
value?: string | number
variant?: 'success' | 'danger' | 'neutral'
bordered?: boolean
}>(),
{
variant: 'neutral',
bordered: false
}
)
const containerClass = computed(() => {
const radius = props.bordered ? 'rounded-xl border' : 'rounded-xl'
if (props.variant === 'success') return `${radius} border-green-200 bg-green-50`
if (props.variant === 'danger') return `${radius} border-red-200 bg-red-50`
return `${radius} border-gray-200 bg-gray-50`
})
const labelClass = computed(() => {
if (props.variant === 'success') return 'text-green-700'
if (props.variant === 'danger') return 'text-red-600'
return 'text-gray-500'
})
const valueClass = computed(() => {
if (props.variant === 'success') return 'text-lg text-green-700'
if (props.variant === 'danger') return 'text-lg text-red-600'
return 'text-gray-900'
})
</script>
@@ -0,0 +1,22 @@
export function useMobileDetailSheet<T extends { id?: number }>() {
const sheetVisible = ref(false)
const selectedItem = ref<T | null>(null)
function openSheet(item: T) {
selectedItem.value = item
sheetVisible.value = true
}
function syncSelectedItem(items: T[]) {
if (!selectedItem.value?.id) return
const updated = items.find((item) => item.id === selectedItem.value?.id)
if (updated) selectedItem.value = updated
}
return {
sheetVisible,
selectedItem,
openSheet,
syncSelectedItem
}
}
@@ -0,0 +1,58 @@
import type { Ref } from 'vue'
import type { TableInput, TableOutput } from '@core/type/baseTable'
interface Options<T> {
fetchApi: (params: TableInput) => Promise<TableOutput<T>>
onFetched?: (items: T[]) => void
}
export function useMobileTableList<T extends { id?: number }>(options: Options<T>) {
const loading = ref(false)
const search = ref('')
const query = ref<TableInput>({ page: 1, limit: 10, select: [] })
const total = ref(0)
const items = ref<T[]>([]) as Ref<T[]>
const hasMore = computed(() => items.value.length < total.value)
async function fetchList(append = false) {
try {
loading.value = true
const params: TableInput = {
...query.value,
search: search.value || null
}
const res = await options.fetchApi(params)
query.value = res.query
total.value = res.query.total ?? 0
items.value = append ? [...items.value, ...res.rows] : res.rows
options.onFetched?.(items.value)
} catch {
total.value = 0
items.value = []
options.onFetched?.([])
} finally {
loading.value = false
}
}
async function reload() {
query.value.page = 1
await fetchList(false)
}
async function loadMore() {
query.value.page = (query.value.page ?? 1) + 1
await fetchList(true)
}
return {
loading,
search,
items,
hasMore,
reload,
loadMore,
fetch: reload
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Wallet } from '@/model/wallet'
export function getWallets(wallet?: Wallet | Wallet[]): Wallet[] {
if (!wallet) return []
return Array.isArray(wallet) ? wallet : [wallet]
}
export function getCashBalance(wallet?: Wallet | Wallet[]): number {
return getWallets(wallet)[0]?.cash ?? 0
}
export function getGoldBalance(wallet?: Wallet | Wallet[]): string {
const sum = getWallets(wallet).reduce((total, item) => total + (item.gold ?? 0), 0)
return sum.toFixed(3)
}