Files
banu-front/src/features/global-components/pages/GlobalComponents.vue
T
sajjadtalkhabi 36b747d267 fix: bugs
2026-07-30 17:38:27 +03:30

576 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<main class="global-components dir-rtl">
<header class="global-components__header">
<h1>کامپوننتهای گلوبال</h1>
<p>پیشنمایش کامپوننتهای مشترک برای تأیید بصری.</p>
</header>
<section class="global-components__section">
<h2>دکمهها</h2>
<div class="global-components__row">
<BaseButton text="تایید" customClass="global-components__btn" />
<BaseButton variant="secondary" text="لغو" customClass="global-components__btn" />
<BaseButton variant="transparent" text="بازگشت" customClass="global-components__btn" />
<BaseButton :loading="true" text="در حال ارسال" customClass="global-components__btn" />
<BaseButton :disabled="true" text="غیرفعال" customClass="global-components__btn" />
</div>
</section>
<section class="global-components__section">
<h2>فرم</h2>
<form class="global-components__form" @submit.prevent="onSubmit">
<TextField
v-model="form.fullName"
name="fullName"
label="نام و نام خانوادگی"
placeholder="نام خود را وارد کنید"
:error="errors.fullName"
@blur="validateAt('fullName', form.fullName)"
/>
<TextField
v-model="form.phone"
name="phone"
label="شماره موبایل"
placeholder="09xxxxxxxxx"
inputmode="numeric"
:convert-digits="true"
:error="errors.phone"
:vibration="true"
@blur="validateAt('phone', form.phone)"
/>
<TextField
v-model="form.nationalCode"
name="nationalCode"
label="کد ملی"
placeholder="0000000000"
inputmode="numeric"
:convert-digits="true"
:error="errors.nationalCode"
:vibration="true"
@blur="validateAt('nationalCode', form.nationalCode)"
/>
<TextField
v-model="form.disabled"
name="disabled"
label="فیلد غیرفعال"
placeholder="-"
:disabled="true"
/>
<TextareaField
v-model="form.bio"
name="bio"
label="درباره من"
placeholder="چند کلمه درباره خودتان..."
:error="errors.bio"
@blur="validateAt('bio', form.bio)"
/>
<SelectField
v-model="form.province"
name="province"
label="استان"
:options="provinceOptions"
option-label="name"
option-value="id"
:error="errors.province"
@change="validateAt('province', form.province)"
/>
<SelectField
v-model="form.skills"
name="skills"
label="مهارت‌ها (چند انتخابی)"
:options="skillOptions"
:multiple="true"
option-label="name"
option-value="id"
/>
<AutocompleteField
v-model="form.city"
name="city"
label="شهر (جستجو)"
:options="cityOptions"
option-label="name"
option-value="id"
:on-search="onCitySearch"
/>
<DatePickerField
v-model="form.birthDate"
name="birthDate"
label="تاریخ تولد"
:error="errors.birthDate"
/>
<ToggleSwitch v-model="form.notifications" label="دریافت اعلان‌ها" />
<ToggleSwitch :model-value="true" label="فعال (نمایش)" />
<ToggleSwitch :model-value="false" label="غیرفعال (نمایش)" :disabled="true" />
<div class="global-components__actions">
<BaseButton type="submit" text="ارسال فرم" customClass="global-components__btn" />
<BaseButton
variant="secondary"
text="پاکسازی"
customClass="global-components__btn"
@click="reset"
/>
</div>
<pre v-if="lastSubmitted" class="global-components__output"
>{{ lastSubmitted }}
</pre>
</form>
</section>
<section class="global-components__section">
<h2>مودال</h2>
<div class="global-components__row">
<BaseButton
text="نمایش مودال تأیید"
customClass="global-components__btn-wide"
@click="askConfirm"
/>
<BaseButton
variant="secondary"
text="نمایش مودال ساده"
customClass="global-components__btn-wide"
@click="
openModal('PreviewModal', { title: 'مودال نمونه', body: 'این یک مودال نمونه است.' })
"
/>
<BaseButton
variant="secondary"
text="نمایش مودال نمونه (الگوی مدرسه بانو)"
customClass="global-components__btn-wide"
@click="openModal('SampleModal', { context: 'پیش‌نمایش' })"
/>
</div>
</section>
<section class="global-components__section">
<h2>آپلود فایل</h2>
<FileUploader
v-model="form.files"
accept="*"
:max-files="5"
:max-size="5 * 1024 * 1024"
@select="onFilesSelected"
@error="onUploaderError"
/>
</section>
<section class="global-components__section">
<h2>تصویر پروفایل</h2>
<ImageCropper v-model="form.avatar" @crop="onAvatarCropped" @error="onUploaderError" />
<p v-if="lastCroppedName" class="global-components__hint">
فایل کراپ‌شده آماده آپلود: {{ lastCroppedName }}
</p>
</section>
<section class="global-components__section">
<h2>دکمهٔ گرد</h2>
<div class="global-components__row">
<CircleButton tooltip="تأیید" bg-color="var(--color-primary)">
<template #icon>
<SvgIcon name="check" :size="20" color="#fff" />
</template>
</CircleButton>
<CircleButton tooltip="حذف" tooltip-position="bottom">
<template #icon>
<SvgIcon name="close" :size="20" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</section>
<section class="global-components__section">
<h2>صفحهبندی</h2>
<p class="global-components__caption">
الگوی استاندارد لیستها:
<code>usePagination</code>
+
<code>useQuery(['items', pagination])</code>
</p>
<ul class="paginated-list">
<li v-for="item in pagedItems" :key="item.id">
<span>{{ item.name }}</span>
<button type="button" class="paginated-list__open" @click="openItemDetails(item)">
مشاهده
</button>
</li>
</ul>
<div class="paginated-list__nav">
<BaseButton
variant="secondary"
text="قبلی"
customClass="paginated-list__btn"
:disabled="pagination.page === 1"
@click="setPage(pagination.page - 1)"
/>
<span class="paginated-list__status">صفحهٔ {{ pagination.page }} از {{ totalPages }}</span>
<BaseButton
variant="secondary"
text="بعدی"
customClass="paginated-list__btn"
:disabled="pagination.page === totalPages"
@click="setPage(pagination.page + 1)"
/>
</div>
</section>
<BasicModal name="PreviewModal" min-width="0" max-width="480px" width="90%">
<template #default="{ data, close }">
<div class="preview-modal">
<h3 class="preview-modal__title">{{ data?.title }}</h3>
<p class="preview-modal__body">{{ data?.body }}</p>
<BaseButton text="بستن" customClass="preview-modal__btn" @click="close" />
</div>
</template>
</BasicModal>
<ItemDetailsModal v-if="isModal('ItemDetailsModal')" />
<SampleModal v-if="isModal('SampleModal')" />
</main>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import useModal from '@/composables/useModal'
import BaseButton from '@/components/BaseButton.vue'
import BasicModal from '@/components/BasicModal.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue'
import { usePagination } from '@/composables/usePagination'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { demoFormSchema } from '@/features/global-components/schema'
import AutocompleteField from '@/components/form/AutocompleteField.vue'
import SampleModal from '@/features/global-components/components/SampleModal.vue'
import ItemDetailsModal from '@/features/global-components/components/ItemDetailsModal.vue'
const schema = demoFormSchema
const provinceOptions = [
{ id: 'tehran', name: 'تهران' },
{ id: 'isfahan', name: 'اصفهان' },
{ id: 'shiraz', name: 'فارس' },
{ id: 'tabriz', name: 'آذربایجان شرقی' },
]
const skillOptions = [
{ id: 'vue', name: 'Vue' },
{ id: 'react', name: 'React' },
{ id: 'svelte', name: 'Svelte' },
{ id: 'angular', name: 'Angular' },
]
const allCities = [
{ id: '1', name: 'تهران' },
{ id: '2', name: 'مشهد' },
{ id: '3', name: 'اصفهان' },
{ id: '4', name: 'شیراز' },
{ id: '5', name: 'تبریز' },
{ id: '6', name: 'کرج' },
{ id: '7', name: 'قم' },
{ id: '8', name: 'اهواز' },
]
const cityOptions = ref(allCities)
const onCitySearch = (q) => {
if (!q) {
cityOptions.value = allCities
return
}
cityOptions.value = allCities.filter((c) => c.name.includes(q))
}
const form = ref({
fullName: '',
phone: '',
nationalCode: '',
disabled: 'فقط برای نمایش',
bio: '',
province: '',
city: '',
skills: [],
birthDate: '',
notifications: false,
files: [],
avatar: null,
})
const lastCroppedName = ref('')
const lastSubmitted = ref(null)
const { validate, validateAt, errors, resetErrors } = useYup(schema)
const { openModal, isModal } = useModal()
const openItemDetails = (item) =>
openModal('ItemDetailsModal', {
...item,
description: `این آیتم نمونه با شناسهٔ ${item.id} برای نمایش الگوی مودال نام‌دار است.`,
})
const { pagination, setPage } = usePagination({ page: 1, perPage: 5 })
const allItems = Array.from({ length: 17 }, (_, i) => ({
id: i + 1,
name: `آیتم شمارهٔ ${i + 1}`,
}))
const totalPages = computed(() =>
Math.max(1, Math.ceil(allItems.length / pagination.value.perPage))
)
const pagedItems = computed(() => {
const start = (pagination.value.page - 1) * pagination.value.perPage
return allItems.slice(start, start + pagination.value.perPage)
})
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (isValid) lastSubmitted.value = payload
}
const reset = () => {
form.value = {
fullName: '',
phone: '',
disabled: 'فقط برای نمایش',
bio: '',
province: '',
city: '',
skills: [],
birthDate: '',
notifications: false,
files: [],
avatar: null,
}
resetErrors()
lastSubmitted.value = null
lastCroppedName.value = ''
}
const onFilesSelected = (files) => {
const next = [...form.value.files]
files.forEach((file, i) => {
next.push({
id: `local-${Date.now()}-${i}`,
name: file.name,
size: file.size,
type: file.type.startsWith('image/') ? 'image' : 'other',
url: URL.createObjectURL(file),
file,
})
})
form.value.files = next
toast.info(`${files.length} فایل انتخاب شد (در پروژه واقعی، آپلود از طریق mutation)`)
}
const onAvatarCropped = (file) => {
lastCroppedName.value = file.name
form.value.avatar = { url: URL.createObjectURL(file), file }
toast.info('تصویر کراپ شد (در پروژه واقعی، آپلود از طریق mutation)')
}
const onUploaderError = (msg) => toast.error(msg)
const askConfirm = () => {
openModal('ConfirmModal', {
title: 'تأیید عملیات',
message: '<p>آیا از انجام این عملیات اطمینان دارید؟</p>',
onConfirm: () => {
lastSubmitted.value = { confirmed: true, at: new Date().toISOString() }
},
onCancel: () => {
lastSubmitted.value = { cancelled: true }
},
})
}
</script>
<style lang="scss" scoped>
.global-components {
max-width: 36rem;
margin: 0 auto;
padding: 2rem 1rem;
display: flex;
flex-direction: column;
gap: 2rem;
&__header {
h1 {
font-family: var(--font-family-fa);
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
p {
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: var(--color-prim-gray);
}
}
&__section {
h2 {
font-family: var(--font-family-fa);
font-size: 1.125rem;
font-weight: 500;
margin-bottom: 1rem;
}
}
&__row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
&__btn {
width: 9rem;
}
&__btn-wide {
width: 14rem;
}
&__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__actions {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
}
&__output {
margin-top: 1rem;
padding: 0.75rem;
background: #f3f4f6;
border-radius: 0.75rem;
font-family: var(--font-family-en);
font-size: 0.75rem;
direction: ltr;
text-align: left;
white-space: pre-wrap;
}
&__hint {
margin-top: 0.5rem;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-prim-gray);
text-align: right;
}
&__caption {
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-prim-gray);
margin-bottom: 0.75rem;
code {
font-family: var(--font-family-en);
background: #f3f4f6;
padding: 0.05rem 0.35rem;
border-radius: 0.25rem;
direction: ltr;
display: inline-block;
}
}
}
.paginated-list {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
border: 1px solid var(--color-thd-gray);
border-radius: 1rem;
overflow: hidden;
li {
padding: 0.5rem 1rem;
font-family: var(--font-family-fa);
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
& + li {
border-top: 1px solid var(--color-thd-gray);
}
}
&__open {
border: none;
background: transparent;
color: var(--color-primary);
font-family: var(--font-family-fa);
font-size: 0.8rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
&:hover {
background: rgba(243, 102, 117, 8%);
}
}
&__nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
&__btn {
width: 6rem;
}
&__status {
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: var(--color-sec-gray);
}
}
.preview-modal {
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
&__title {
font-family: var(--font-family-fa);
font-weight: 600;
font-size: 1.125rem;
}
&__body {
font-family: var(--font-family-fa);
color: var(--color-sec-gray);
}
&__btn {
width: 8rem;
}
}
</style>