pre form builder handler

This commit is contained in:
2026-03-23 00:47:20 +03:30
commit 6a1ed57d23
50 changed files with 5738 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+114
View File
@@ -0,0 +1,114 @@
# توضیح مرحله‌به‌مرحلهٔ پروژه — فرم‌ساز (Form Builder)
## هدف کلی
ساخت یک **اپلیکیشن تک‌صفحه‌ای (SPA)** به نام **فرم‌ساز** که کاربر بعد از ورود (لاگین) بتواند با کشیدن المان‌ها از یک پالت، فرم دلخواه بسازد، ذخیره کند و بعداً دقیقاً مثل چیدمان خودش دوباره ببیند.
---
## مرحله ۱ — ورود کاربر (لاگین)
- یک صفحهٔ لاگین ساده (مثلاً نام کاربری / رمز یا فقط یک دکمه «ورود» برای تست).
- بعد از لاگین موفق، کاربر به **داشبورد** هدایت می‌شود.
- نیازی به بک‌اند نیست؛ می‌توان لاگین را فقط در فرانت و با state ساده یا Vuex شبیه‌سازی کرد.
---
## مرحله ۲ — داشبورد و چیدمان کلی
- بعد از لاگین، **داشبورد** نمایش داده می‌شود.
- چیدمان پیشنهادی:
- **سمت راست:** پالت المان‌ها (پالت فرم‌ساز).
- **وسط/چپ:** پنل اصلی (بوم) که کاربر المان‌ها را اینجا می‌گذارد و فرم را می‌سازد.
- بهتر است پالت در سمت راست صفحه باشد.
---
## مرحله ۳ — پالت المان‌ها (سمت راست)
پالت شامل المان‌هایی است که کاربر می‌تواند **بکشد** و روی بوم **رها** کند:
| المان | توضیح | اعتبارسنجی / تنظیمات |
|------|--------|------------------------|
| **Input box** | ورودی متن | نوع: رنگ / عدد / رشته / چک‌باکس / رادیو — اعتبارسنجی: اجباری (require)، min، max |
| **Select box** | انتخاب‌گر | نوع: تکی یا چندتایی — اعتبارسنجی: اجباری (Require) |
| **Range slider** | اسلایدر محدوده | اعتبارسنجی: اجباری، steps، min، max |
کاربر هر المان را که بخواهد از پالت برمی‌دارد و در **موقعیت دلخواه** روی پنل اصلی قرار می‌دهد.
---
## مرحله ۴ — بوم فرم‌ساز (پنل اصلی)
- کاربر المان‌ها را از پالت **کشیده** و در این پنل **رها** می‌کند (Drag & Drop).
- برای هر المان باید بتوان مشخص کرد:
- **موقعیت** (مثلاً x, y یا چیدمان grid/flex).
- **عرض و ارتفاع** (طول و عرض المان).
- چیدمان نهایی باید قابل **ذخیره** باشد تا بعداً عیناً **رندر** شود.
---
## مرحله ۵ — تنظیمات و اعتبارسنجی هر فیلد
- برای هر فیلد (Input، Select، Slider) کاربر باید بتواند:
- **ولیدیشن‌ها** را تعیین کند (مثلاً اجباری بودن).
- برای فیلدهای متنی: **حداقل و حداکثر تعداد کاراکتر** (min/max length).
- برای Slider: **steps، min، max**.
- برای Select: **Require** و در صورت نیاز گزینه‌های چندتایی.
---
## مرحله ۶ — ذخیرهٔ فرم و خروجی JSON
- هر فرمی که ساخته می‌شود باید **یک نام** داشته باشد.
- ذخیره به صورت **محلی** (مثلاً در مرورگر یا در state/Vuex):
- در تسک گفته شده اگر فریمورک Vue.js باشد، در **Vuex** ذخیره شود.
- خروجی نهایی = یک **فایل (ساختار) JSON** که شامل همهٔ اطلاعات است:
- نوع و ترتیب المان‌ها
- موقعیت و اندازهٔ هر المان
- تنظیمات و ولیدیشن‌ها
---
## مرحله ۷ — نمایش و بازسازی فرم از روی JSON
- بعد از ذخیره، باید بتوان از روی همان JSON فرم را **دوباره رندر** کرد.
- خروجی باید **کاملاً دقیق** مطابق چیدمان کاربر باشد (طول و عرض المان‌ها و مکان قرارگیری آن‌ها مهم است).
---
## مرحله ۸ — جدول فرم‌های ذخیره‌شده
- **جدولی** که تمام فرم‌های ذخیره‌شده را نشان می‌دهد.
- قابلیت‌ها:
- **صفحه‌بندی (Pagination).**
- **جستجو بر اساس نام** فرم.
- داده‌ها از همان ذخیرهٔ محلی (مثلاً Vuex) خوانده می‌شوند؛ نیازی به بک‌اند نیست.
---
## مرحله ۹ — ذخیرهٔ محلی اطلاعات پنل
- اطلاعات پنل (تعداد المان‌ها، تنظیمات گرفته‌شده از کاربر و …) هم به صورت **محلی** ذخیره شوند.
- در صورت استفاده از Vue.js، در **Vuex** ذخیره شود.
- هیچ نیازی به ارتباط با **Back-end** نیست.
---
## مرحله ۱۰ — ظاهر و واکنش‌گرایی
- المان‌ها و رابط کاربری تا حد امکان **زیبا و جذاب** باشند.
- اگر اپ **Responsive** باشد، به عنوان یک نکتهٔ مثبت در نظر گرفته می‌شود.
---
## جمع‌بندی مراحل به ترتیب پیاده‌سازی پیشنهادی
1. **لاگین** → ورود به داشبورد
2. **داشبورد** → چیدمان کلی (پالت راست + بوم وسط/چپ)
3. **پالت** → تعریف المان‌ها (Input، Select، Slider) و قابلیت کشیدن
4. **بوم** → Drag & Drop، قرار دادن المان، موقعیت و اندازه
5. **تنظیمات فیلد** → ولیدیشن، min/max، steps و …
6. **ذخیره** → نام فرم + ذخیره در Vuex و خروجی JSON
7. **بازسازی** → رندر دقیق فرم از روی JSON
8. **جدول** → لیست فرم‌ها + صفحه‌بندی + جستجو بر اساس نام
9. **پایداری** → ذخیرهٔ محلی/Vuex برای پنل و فرم‌ها
10. **ظاهر و Responsive** → زیبایی و واکنش‌گرایی
---
## تکنولوژی‌های پروژه
- **Vue 3** + **TypeScript** + **Vite**
- **Vue Router** — مسیریابی (لاگین / داشبورد)
- **Vuex** — state و ذخیرهٔ محلی فرم‌ها و اطلاعات پنل
- **Tailwind CSS** — استایل
- **Ant Design Vue** — کامپوننت‌های آماده (دکمه، جدول، فرم، Input و …)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/main.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Megashid_sedighy</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1882
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "megashid_sedighy",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"ant-design-vue": "^4.2.6",
"vue": "^3.5.25",
"vue-router": "^4.6.4",
"vuex": "^4.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"less": "^4.6.4",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"vite": "^8.0.0-beta.13",
"vue-tsc": "^3.1.5"
},
"overrides": {
"vite": "^8.0.0-beta.13"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
</script>
<template>
<div dir="rtl" class="min-h-screen"
>
<AConfigProvider
direction="ltr"
:theme="{
token: {
fontFamily: 'iran-sans'
}}">
<router-view/>
</AConfigProvider>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
@font-face {
font-family: 'iran-sans';
src: url("../font/iranSans/woff2/IRANSansWeb_FaNum.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'iran-sans';
src: url("../font/iranSans/woff2/IRANSansWeb_FaNum_Light.woff2") format("woff2");
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'iran-sans';
src: url("../font/iranSans/woff2/IRANSansWeb_FaNum_UltraLight.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'iran-sans';
src: url("../font/iranSans/woff2/IRANSansWeb_FaNum_Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'iran-sans';
src: url("../font/iranSans/woff2/IRANSansWeb_FaNum_Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'iran-sans';
src: url("../font/iranSans/woff2/IRANSansWeb_FaNum_Black.woff2") format("woff2");
font-weight: 900;
font-style: normal;
font-display: swap;
}
+12
View File
@@ -0,0 +1,12 @@
@import './fonts.less';
@import './var.css';
@import "./tailwind.css";
body {
font-family: iran-sans, sans-serif !important;
}
#app {
min-height: 100vh;
}
+30
View File
@@ -0,0 +1,30 @@
@import "tailwindcss";
@import "./var.css";
@theme {
--color-primary: var(--color-primary);
--color-secondary: var(--color-secondary);
--color-third: var(--color-third);
--color-forth: var(--color-forth);
}
@layer base {
h1 {
@apply text-4xl font-bold;
}
h2 {
@apply text-3xl font-semibold;
}
h3 {
@apply text-2xl font-medium;
}
h4 {
@apply text-xl font-normal;
}
h5 {
@apply text-lg font-light;
}
h6 {
@apply text-base font-thin;
}
}
+6
View File
@@ -0,0 +1,6 @@
:root {
--color-primary: #171717;
--color-secondary: #262626;
--color-third: #0c549c;
--color-forth: #FF6467FF;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,175 @@
<template>
<div class="h-full">
<div
ref="canvasRef"
class="canvas-page relative mx-auto w-full max-w-[960px] min-h-[720px] rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden"
@dragover="onCanvasDragOver"
@drop="onCanvasDrop"
>
<template v-for="field in fields" :key="field.id">
<CanvasInputField
v-if="field.type === 'input'"
:field="field"
/>
<CanvasSelectField
v-else-if="field.type === 'select'"
:field="field"
/>
<CanvasSliderField
v-else
:field="field"
/>
</template>
<div
v-if="!fields.length"
class="absolute inset-0 flex flex-col items-center justify-center gap-3 px-6 text-center"
>
<div class="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-8 py-10">
<p class="mb-1! text-sm font-semibold text-slate-700">
بوم هنوز خالی است
</p>
<p class="mb-0! text-xs leading-6 text-slate-500">
از پالت سمت راست یک المان را بکشید و اینجا رها کنید تا تنظیماتش باز شود.
</p>
</div>
</div>
<div
class="pointer-events-none absolute bottom-3 left-3 rounded-lg bg-slate-900/80 px-3 py-2 text-[11px] text-white"
>
{{ editMode ? 'Edit Mode: On' : 'Preview Mode: On' }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
DRAG_TYPE,
clampFieldPosition,
createDefaultField,
getDropPosition,
parseDragPayload,
serializeDragPayload,
} from '@/handler/formBuilder.handler'
import {FormFieldConfig} from "@/models/form";
import CanvasInputField from '@/components/FormBuilder/Fields/CanvasInputField.vue'
import CanvasSelectField from '@/components/FormBuilder/Fields/CanvasSelectField.vue'
import CanvasSliderField from '@/components/FormBuilder/Fields/CanvasSliderField.vue'
const props = defineProps<{
fields: FormFieldConfig[]
editMode: boolean
}>()
const emit = defineEmits<{
(e: 'request-create', field: FormFieldConfig): void
(e: 'request-edit', field: FormFieldConfig): void
(e: 'move-field', payload: { id: string; x: number; y: number }): void
}>()
const canvasRef = ref<HTMLElement | null>(null)
function getFieldStyle(field: FormFieldConfig) {
return {
left: `${field.x}px`,
top: `${field.y}px`,
width: `${field.width}px`,
height: `${field.height}px`,
}
}
function onCanvasDragOver(e: DragEvent) {
e.preventDefault()
if (!e.dataTransfer) return
e.dataTransfer.dropEffect = 'move'
}
function onCanvasElementDragStart(e: DragEvent, field: FormFieldConfig) {
if (!props.editMode) return
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(
'text/plain',
serializeDragPayload({
source: DRAG_TYPE.canvas,
id: field.id,
})
)
}
function onCanvasDrop(e: DragEvent) {
e.preventDefault()
if (!canvasRef.value || !e.dataTransfer) return
const raw =
e.dataTransfer.getData('text/plain') ||
e.dataTransfer.getData('application/json')
if (!raw) return
const payload = parseDragPayload(raw)
if (!payload) return
const dropPosition = getDropPosition(canvasRef.value, e)
const canvasWidth = canvasRef.value.clientWidth
const canvasHeight = canvasRef.value.clientHeight
if (payload.source === DRAG_TYPE.palette) {
const draftField = createDefaultField(
payload.fieldType,
dropPosition.x,
dropPosition.y
)
const clamped = clampFieldPosition(
draftField.x,
draftField.y,
draftField.width,
draftField.height,
canvasWidth,
canvasHeight
)
emit('request-create', {
...draftField,
x: clamped.x,
y: clamped.y,
})
return
}
if (payload.source === DRAG_TYPE.canvas) {
if (!props.editMode) return
const existingField = props.fields.find((item) => item.id === payload.id)
if (!existingField) return
const clamped = clampFieldPosition(
dropPosition.x,
dropPosition.y,
existingField.width,
existingField.height,
canvasWidth,
canvasHeight
)
emit('move-field', {
id: payload.id,
x: clamped.x,
y: clamped.y,
})
}
}
function onFieldClick(field: FormFieldConfig) {
if (!props.editMode) return
emit('request-edit', field)
}
</script>
@@ -0,0 +1,280 @@
<template>
<div class="h-full min-h-0 flex flex-col bg-white">
<div class="shrink-0 border-b border-slate-200 p-4">
<div class="mb-4">
<h2 class="text-base font-semibold text-slate-800 mb-1!">
پالت المان‌ها
</h2>
<p class="text-xs text-slate-500 mb-0! leading-6">
المان موردنظر را بکشید و داخل بوم رها کنید. بعد از رها کردن، پنجره تنظیمات باز می‌شود.
</p>
</div>
<div class="space-y-3">
<div
class="rounded-xl border border-slate-200 bg-slate-50 p-3 cursor-grab active:cursor-grabbing transition hover:border-blue-300 hover:bg-blue-50"
draggable="true"
@dragstart="onPaletteDragStart($event, 'input')"
>
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-slate-800 mb-1!">Input</h3>
<p class="text-xs text-slate-500 mb-0! leading-5">
متن، عدد، رنگ، چک‌باکس و رادیوباتن
</p>
</div>
<span class="rounded-lg bg-white px-2 py-1 text-[10px] font-medium text-slate-500 border border-slate-200">
Drag
</span>
</div>
</div>
<div
class="rounded-xl border border-slate-200 bg-slate-50 p-3 cursor-grab active:cursor-grabbing transition hover:border-blue-300 hover:bg-blue-50"
draggable="true"
@dragstart="onPaletteDragStart($event, 'select')"
>
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-slate-800 mb-1!">Select</h3>
<p class="text-xs text-slate-500 mb-0! leading-5">
سلکت تکی یا چندتایی همراه با گزینه‌ها
</p>
</div>
<span class="rounded-lg bg-white px-2 py-1 text-[10px] font-medium text-slate-500 border border-slate-200">
Drag
</span>
</div>
</div>
<div
class="rounded-xl border border-slate-200 bg-slate-50 p-3 cursor-grab active:cursor-grabbing transition hover:border-blue-300 hover:bg-blue-50"
draggable="true"
@dragstart="onPaletteDragStart($event, 'slider')"
>
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-slate-800 mb-1!">Range Slider</h3>
<p class="text-xs text-slate-500 mb-0! leading-5">
اسلایدر بازه با min / max / step
</p>
</div>
<span class="rounded-lg bg-white px-2 py-1 text-[10px] font-medium text-slate-500 border border-slate-200">
Drag
</span>
</div>
</div>
</div>
</div>
<div class="shrink-0 border-b border-slate-200 p-4">
<div class="mb-3">
<h2 class="text-sm font-semibold text-slate-800 mb-1!">
اطلاعات فرم جاری
</h2>
<p class="text-xs text-slate-500 mb-0!">
نام فرم را وارد کنید و بعد آن را ذخیره کنید.
</p>
</div>
<div class="space-y-3">
<a-input
:value="formName"
size="large"
placeholder="نام فرم"
@update:value="emit('update:form-name', $event)"
/>
<a-button type="primary" block size="large" @click="emit('save-form')">
ذخیره فرم جاری
</a-button>
</div>
</div>
<div class="min-h-0 flex-1 overflow-hidden">
<div class="h-full min-h-0 flex flex-col">
<div class="border-b border-slate-200 p-4">
<div class="flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold text-slate-800 mb-0!">
المان‌های موجود در بوم
</h2>
<span class="rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600">
{{ fields.length }} مورد
</span>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-4">
<FormElementList
:fields="fields"
@edit="emit('edit', $event)"
@remove="emit('remove', $event)"
/>
</div>
</div>
</div>
<div class="shrink-0 border-t border-slate-200 bg-slate-50">
<div class="p-4 border-b border-slate-200">
<div class="flex items-center justify-between gap-3 mb-3">
<h2 class="text-sm font-semibold text-slate-800 mb-0!">
فرم‌های ذخیره‌شده
</h2>
<span class="text-xs text-slate-500">
{{ filteredSavedForms.length }} مورد
</span>
</div>
<a-input
v-model:value="search"
allow-clear
size="large"
placeholder="جستجو بر اساس نام فرم"
/>
</div>
<div class="max-h-80 overflow-auto p-4">
<div v-if="paginatedSavedForms.length" class="space-y-3">
<div
v-for="item in paginatedSavedForms"
:key="item.id"
class="rounded-xl border border-slate-200 bg-white p-3"
>
<div class="flex items-start justify-between gap-3 mb-2">
<div class="min-w-0">
<h3 class="text-sm font-semibold text-slate-800 truncate mb-1!">
{{ item.name }}
</h3>
<p class="text-[11px] text-slate-500 mb-0! leading-5">
{{ item.fields.length }} المان
</p>
</div>
<span class="shrink-0 rounded-lg bg-slate-100 px-2 py-1 text-[10px] text-slate-500">
{{ formatDate(item.createdAt) }}
</span>
</div>
<div class="flex items-center gap-2">
<a-button size="small" type="primary" @click="emit('load-saved-form', item.id)">
بارگذاری
</a-button>
<a-popconfirm
title="این فرم حذف شود؟"
ok-text="بله"
cancel-text="خیر"
@confirm="emit('delete-saved-form', item.id)"
>
<a-button size="small" danger>
حذف
</a-button>
</a-popconfirm>
</div>
</div>
</div>
<a-empty v-else description="فرم ذخیره‌شده‌ای پیدا نشد" />
</div>
<div
v-if="filteredSavedForms.length > pageSize"
class="border-t border-slate-200 bg-white px-4 py-3"
>
<a-pagination
v-model:current="currentPage"
:total="filteredSavedForms.length"
:page-size="pageSize"
size="small"
:show-size-changer="false"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import FormElementList from '@/components/FormBuilder/FormElementList.vue'
import {
DRAG_TYPE,
serializeDragPayload,
} from '@/handler/formBuilder.handler'
import {FormFieldConfig, SavedForm} from "@/models/form";
type FieldType = 'input' | 'select' | 'slider'
const props = defineProps<{
fields: FormFieldConfig[]
savedForms: SavedForm[]
formName: string
}>()
const emit = defineEmits<{
(e: 'update:form-name', value: string): void
(e: 'edit', field: FormFieldConfig): void
(e: 'remove', id: string): void
(e: 'save-form'): void
(e: 'load-saved-form', id: string): void
(e: 'delete-saved-form', id: string): void
}>()
const search = ref('')
const currentPage = ref(1)
const pageSize = 5
const filteredSavedForms = computed(() => {
const keyword = search.value.trim().toLowerCase()
if (!keyword) return props.savedForms
return props.savedForms.filter((item) =>
item.name.toLowerCase().includes(keyword)
)
})
const paginatedSavedForms = computed(() => {
const start = (currentPage.value - 1) * pageSize
const end = start + pageSize
return filteredSavedForms.value.slice(start, end)
})
watch(search, () => {
currentPage.value = 1
})
watch(
() => filteredSavedForms.value.length,
(length) => {
const maxPage = Math.max(1, Math.ceil(length / pageSize))
if (currentPage.value > maxPage) {
currentPage.value = maxPage
}
}
)
function onPaletteDragStart(e: DragEvent, fieldType: FieldType) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData(
'text/plain',
serializeDragPayload({
source: DRAG_TYPE.palette,
fieldType,
})
)
}
function formatDate(timestamp: number) {
try {
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(timestamp)
} catch {
return '-'
}
}
</script>
@@ -0,0 +1,448 @@
<template>
<a-modal
:open="open"
:title="modalTitle"
:ok-text="mode === 'create' ? 'ثبت المان' : 'ذخیره تغییرات'"
cancel-text="انصراف"
width="720px"
centered
destroy-on-close
@ok="handleSubmit"
@cancel="handleCancel"
>
<div v-if="localDraft" class="space-y-5">
<!-- اطلاعات عمومی -->
<section class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<h3 class="text-sm font-semibold text-slate-800 mb-4">تنظیمات عمومی</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="block text-xs font-medium text-slate-600 mb-2">
عنوان فیلد
</label>
<a-input
v-model:value="localDraft.label"
size="large"
placeholder="مثلاً نام کاربر"
/>
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-2">
عرض
</label>
<a-input-number
v-model:value="localDraft.width"
size="large"
class="!w-full"
:min="120"
:max="900"
/>
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-2">
ارتفاع
</label>
<a-input-number
v-model:value="localDraft.height"
size="large"
class="!w-full"
:min="48"
:max="300"
/>
</div>
</div>
</section>
<!-- INPUT -->
<section
v-if="localDraft.type === 'input'"
class="rounded-xl border border-slate-200 bg-white p-4"
>
<h3 class="text-sm font-semibold text-slate-800 mb-4">تنظیمات Input</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-slate-600 mb-2">
نوع ورودی
</label>
<a-select
v-model:value="localDraft.inputType"
size="large"
:options="inputTypeOptions"
@change="handleInputTypeChange"
/>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-slate-700">
<a-checkbox v-model:checked="localDraft.required" />
<span>فیلد اجباری باشد</span>
</label>
</div>
<template v-if="localDraft.inputType === 'text'">
<div>
<label class="block text-xs font-medium text-slate-600 mb-2">
حداقل طول متن
</label>
<a-input-number
v-model:value="localDraft.minLength"
size="large"
class="!w-full"
:min="0"
:max="1000"
placeholder="مثلاً 3"
/>
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-2">
حداکثر طول متن
</label>
<a-input-number
v-model:value="localDraft.maxLength"
size="large"
class="!w-full"
:min="0"
:max="1000"
placeholder="مثلاً 50"
/>
</div>
</template>
<template v-if="localDraft.inputType === 'number'">
<div>
<label class="block text-xs font-medium text-slate-600 mb-2">
حداقل مقدار
</label>
<a-input-number
v-model:value="localDraft.min"
size="large"
class="!w-full"
placeholder="مثلاً 0"
/>
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-2">
حداکثر مقدار
</label>
<a-input-number
v-model:value="localDraft.max"
size="large"
class="!w-full"
placeholder="مثلاً 100"
/>
</div>
<div class="md:col-span-2">
<label class="block text-xs font-medium text-slate-600 mb-2">
Step
</label>
<a-input-number
v-model:value="localDraft.step"
size="large"
class="!w-full"
:min="1"
placeholder="مثلاً 1"
/>
</div>
</template>
</div>
</section>
<!-- SELECT -->
<section
v-else-if="localDraft.type === 'select'"
class="rounded-xl border border-slate-200 bg-white p-4"
>
<h3 class="text-sm font-semibold text-slate-800 mb-4">تنظیمات Select</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-slate-700">
<a-checkbox v-model:checked="localDraft.required" />
<span>فیلد اجباری باشد</span>
</label>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-slate-700">
<a-checkbox v-model:checked="localDraft.multiple" />
<span>انتخاب چندتایی فعال باشد</span>
</label>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<label class="block text-xs font-medium text-slate-600">
گزینه‌ها
</label>
<a-button size="small" type="dashed" @click="addOption">
افزودن گزینه
</a-button>
</div>
<div
v-for="(option, index) in localDraft.options"
:key="`option-${index}`"
class="flex items-center gap-2"
>
<a-input
v-model:value="localDraft.options[index]"
size="large"
:placeholder="`گزینه ${index + 1}`"
/>
<a-button
danger
@click="removeOption(index)"
:disabled="(localDraft.options?.length || 0) <= 1"
>
حذف
</a-button>
</div>
</div>
</section>
<!-- SLIDER -->
<section
v-else-if="localDraft.type === 'slider'"
class="rounded-xl border border-slate-200 bg-white p-4"
>
<h3 class="text-sm font-semibold text-slate-800 mb-4">تنظیمات Slider</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-end md:col-span-2">
<label class="flex items-center gap-2 text-sm text-slate-700">
<a-checkbox v-model:checked="localDraft.required" />
<span>فیلد اجباری باشد</span>
</label>
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-2">
حداقل مقدار
</label>
<a-input-number
v-model:value="localDraft.min"
size="large"
class="!w-full"
/>
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-2">
حداکثر مقدار
</label>
<a-input-number
v-model:value="localDraft.max"
size="large"
class="!w-full"
/>
</div>
<div class="md:col-span-2">
<label class="block text-xs font-medium text-slate-600 mb-2">
Step
</label>
<a-input-number
v-model:value="localDraft.step"
size="large"
class="!w-full"
:min="1"
/>
</div>
</div>
</section>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { cloneField, sanitizeField } from '@/handler/formBuilder.handler'
import {FormFieldConfig} from "@/models/form";
const props = defineProps<{
open: boolean
mode: 'create' | 'edit'
field: FormFieldConfig | null
}>()
const emit = defineEmits<{
(e: 'submit', field: FormFieldConfig): void
(e: 'cancel'): void
(e: 'update:open', value: boolean): void
}>()
const localDraft = ref<FormFieldConfig | null>(null)
const inputTypeOptions = [
{ label: 'متنی', value: 'text' },
{ label: 'عددی', value: 'number' },
{ label: 'رنگ', value: 'color' },
{ label: 'چک‌باکس', value: 'checkbox' },
{ label: 'رادیوباتن', value: 'radio' },
]
const modalTitle = computed(() => {
if (!localDraft.value) return 'تنظیمات المان'
const action = props.mode === 'create' ? 'افزودن' : 'ویرایش'
if (localDraft.value.type === 'input') return `${action} Input`
if (localDraft.value.type === 'select') return `${action} Select`
return `${action} Slider`
})
watch(
() => props.field,
(value) => {
localDraft.value = value ? cloneField(value) : null
if (localDraft.value?.type === 'select' && !localDraft.value.options?.length) {
localDraft.value.options = ['گزینه ۱']
}
},
{ immediate: true }
)
function handleInputTypeChange() {
if (!localDraft.value || localDraft.value.type !== 'input') return
if (localDraft.value.inputType !== 'text') {
localDraft.value.minLength = undefined
localDraft.value.maxLength = undefined
}
if (localDraft.value.inputType !== 'number') {
localDraft.value.min = undefined
localDraft.value.max = undefined
localDraft.value.step = undefined
}
if (localDraft.value.inputType === 'number' && typeof localDraft.value.step !== 'number') {
localDraft.value.step = 1
}
}
function addOption() {
if (!localDraft.value || localDraft.value.type !== 'select') return
if (!localDraft.value.options) {
localDraft.value.options = []
}
localDraft.value.options.push(`گزینه ${localDraft.value.options.length + 1}`)
}
function removeOption(index: number) {
if (!localDraft.value || localDraft.value.type !== 'select') return
if (!localDraft.value.options) return
if (localDraft.value.options.length <= 1) return
localDraft.value.options.splice(index, 1)
}
function validateDraft(field: FormFieldConfig) {
if (!field.label?.trim()) {
message.error('عنوان فیلد را وارد کنید')
return false
}
if (!field.width || field.width < 120) {
message.error('عرض فیلد باید حداقل 120 باشد')
return false
}
if (!field.height || field.height < 48) {
message.error('ارتفاع فیلد باید حداقل 48 باشد')
return false
}
if (field.type === 'input') {
if (!field.inputType) {
message.error('نوع input را انتخاب کنید')
return false
}
if (
field.inputType === 'text' &&
typeof field.minLength === 'number' &&
typeof field.maxLength === 'number' &&
field.minLength > field.maxLength
) {
message.error('حداقل طول متن نمی‌تواند از حداکثر بیشتر باشد')
return false
}
if (
field.inputType === 'number' &&
typeof field.min === 'number' &&
typeof field.max === 'number' &&
field.min > field.max
) {
message.error('حداقل مقدار نمی‌تواند از حداکثر بیشتر باشد')
return false
}
if (
field.inputType === 'number' &&
typeof field.step === 'number' &&
field.step <= 0
) {
message.error('step باید بزرگ‌تر از صفر باشد')
return false
}
}
if (field.type === 'select') {
const validOptions = (field.options || []).map((item) => item.trim()).filter(Boolean)
if (!validOptions.length) {
message.error('حداقل یک گزینه برای select وارد کنید')
return false
}
}
if (field.type === 'slider') {
if (
typeof field.min === 'number' &&
typeof field.max === 'number' &&
field.min >= field.max
) {
message.error('مقدار max باید از min بزرگ‌تر باشد')
return false
}
if (typeof field.step === 'number' && field.step <= 0) {
message.error('step باید بزرگ‌تر از صفر باشد')
return false
}
}
return true
}
function handleSubmit() {
if (!localDraft.value) return
const cleaned = sanitizeField(cloneField(localDraft.value))
if (!validateDraft(cleaned)) return
emit('submit', cleaned)
emit('update:open', false)
}
function handleCancel() {
emit('cancel')
emit('update:open', false)
}
</script>
@@ -0,0 +1,150 @@
<template>
<div class="flex h-full flex-col overflow-hidden">
<div class="border-b border-slate-100 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<p class="mb-0! truncate text-xs font-semibold text-slate-800">
{{ field.label || 'بدون عنوان' }}
</p>
<p class="mb-0! mt-1 text-[10px] text-slate-400">
Input / {{ inputTypeLabel }}
</p>
</div>
<span
v-if="field.required"
class="shrink-0 rounded-full bg-red-50 px-2 py-1 text-[10px] font-medium text-red-600"
>
required
</span>
</div>
</div>
<div class="flex-1 px-3 py-3 overflow-hidden">
<div class="space-y-2">
<a-input
v-if="field.inputType === 'text'"
disabled
size="small"
placeholder="متن"
/>
<a-input-number
v-else-if="field.inputType === 'number'"
disabled
size="small"
class="!w-full"
:min="field.min"
:max="field.max"
:step="field.step"
placeholder="عدد"
/>
<div
v-else-if="field.inputType === 'color'"
class="flex items-center gap-2"
>
<input
type="color"
disabled
class="h-8 w-10 rounded border border-slate-200 bg-white p-1"
/>
<span class="text-xs text-slate-500">انتخاب رنگ</span>
</div>
<label
v-else-if="field.inputType === 'checkbox'"
class="flex items-center gap-2 text-xs text-slate-600"
>
<a-checkbox disabled />
<span>{{ field.label || 'چک‌باکس' }}</span>
</label>
<label
v-else-if="field.inputType === 'radio'"
class="flex items-center gap-2 text-xs text-slate-600"
>
<a-radio disabled />
<span>{{ field.label || 'رادیوباتن' }}</span>
</label>
<a-input
v-else
disabled
size="small"
placeholder="ورودی"
/>
<div class="text-[10px] text-slate-400 leading-5">
<template v-if="field.inputType === 'text'">
<span v-if="typeof field.minLength === 'number'">
minLength: {{ field.minLength }}
</span>
<span
v-if="typeof field.minLength === 'number' && typeof field.maxLength === 'number'"
>
|
</span>
<span v-if="typeof field.maxLength === 'number'">
maxLength: {{ field.maxLength }}
</span>
</template>
<template v-else-if="field.inputType === 'number'">
<span v-if="typeof field.min === 'number'">min: {{ field.min }}</span>
<span
v-if="typeof field.min === 'number' && typeof field.max === 'number'"
>
|
</span>
<span v-if="typeof field.max === 'number'">max: {{ field.max }}</span>
<span
v-if="(typeof field.min === 'number' || typeof field.max === 'number') && typeof field.step === 'number'"
>
|
</span>
<span v-if="typeof field.step === 'number'">step: {{ field.step }}</span>
</template>
<template v-else-if="field.inputType === 'checkbox'">
<span>boolean input</span>
</template>
<template v-else-if="field.inputType === 'radio'">
<span>single choice input</span>
</template>
<template v-else-if="field.inputType === 'color'">
<span>color picker</span>
</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {FormFieldConfig} from "@/models/form";
const props = defineProps<{
field: FormFieldConfig
}>()
const inputTypeLabel = computed(() => {
switch (props.field.inputType) {
case 'text':
return 'متنی'
case 'number':
return 'عددی'
case 'color':
return 'رنگ'
case 'checkbox':
return 'چک‌باکس'
case 'radio':
return 'رادیوباتن'
default:
return 'پیش‌فرض'
}
})
</script>
@@ -0,0 +1,84 @@
<template>
<div class="flex h-full flex-col overflow-hidden">
<div class="border-b border-slate-100 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<p class="mb-0! truncate text-xs font-semibold text-slate-800">
{{ field.label || 'بدون عنوان' }}
</p>
<p class="mb-0! mt-1 text-[10px] text-slate-400">
Select / {{ field.multiple ? 'چندتایی' : 'تکی' }}
</p>
</div>
<span
v-if="field.required"
class="shrink-0 rounded-full bg-red-50 px-2 py-1 text-[10px] font-medium text-red-600"
>
required
</span>
</div>
</div>
<div class="flex-1 px-3 py-3 overflow-hidden">
<div class="space-y-2">
<a-select
disabled
size="small"
class="w-full"
:mode="field.multiple ? 'multiple' : undefined"
:options="selectOptions"
:placeholder="field.multiple ? 'انتخاب چندتایی' : 'انتخاب گزینه'"
/>
<div class="text-[10px] text-slate-400 leading-5">
<span>{{ field.multiple ? 'multiple' : 'single' }}</span>
<span> | </span>
<span>{{ optionCount }} گزینه</span>
</div>
<div
v-if="optionCount"
class="flex flex-wrap gap-1 pt-1"
>
<span
v-for="option in safeOptions"
:key="option"
class="inline-flex items-center rounded-full bg-slate-100 px-2 py-1 text-[10px] text-slate-600"
>
{{ option }}
</span>
</div>
<div
v-else
class="text-[10px] text-amber-600"
>
گزینه‌ای برای این select ثبت نشده است
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {FormFieldConfig} from "@/models/form";
const props = defineProps<{
field: FormFieldConfig
}>()
const safeOptions = computed(() =>
(props.field.options || []).map((item) => item.trim()).filter(Boolean)
)
const optionCount = computed(() => safeOptions.value.length)
const selectOptions = computed(() =>
safeOptions.value.map((item) => ({
label: item,
value: item,
}))
)
</script>
@@ -0,0 +1,67 @@
<template>
<div class="flex h-full flex-col overflow-hidden">
<div class="border-b border-slate-100 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<p class="mb-0! truncate text-xs font-semibold text-slate-800">
{{ field.label || 'بدون عنوان' }}
</p>
<p class="mb-0! mt-1 text-[10px] text-slate-400">
Range Slider
</p>
</div>
<span
v-if="field.required"
class="shrink-0 rounded-full bg-red-50 px-2 py-1 text-[10px] font-medium text-red-600"
>
required
</span>
</div>
</div>
<div class="flex-1 px-3 py-3 overflow-hidden">
<div class="space-y-3">
<a-slider
disabled
:min="safeMin"
:max="safeMax"
:step="safeStep"
/>
<div class="flex items-center justify-between text-[10px] text-slate-400">
<span>min: {{ safeMin }}</span>
<span>step: {{ safeStep }}</span>
<span>max: {{ safeMax }}</span>
</div>
<div class="rounded-lg bg-slate-50 px-3 py-2 text-[10px] text-slate-500 border border-slate-200">
بازه: {{ safeMin }} تا {{ safeMax }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {FormFieldConfig} from "@/models/form";
const props = defineProps<{
field: FormFieldConfig
}>()
const safeMin = computed(() =>
typeof props.field.min === 'number' ? props.field.min : 0
)
const safeMax = computed(() =>
typeof props.field.max === 'number' ? props.field.max : 100
)
const safeStep = computed(() =>
typeof props.field.step === 'number' && props.field.step > 0
? props.field.step
: 1
)
</script>
@@ -0,0 +1,93 @@
<template>
<div class="space-y-3">
<template v-if="fields.length">
<div
v-for="field in fields"
:key="field.id"
class="rounded-xl border border-slate-200 bg-white p-3 shadow-sm transition hover:border-blue-300"
>
<div class="flex items-start justify-between gap-3 mb-3">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-semibold text-slate-800 mb-0! truncate">
{{ field.label || 'بدون عنوان' }}
</h3>
<span
class="inline-flex items-center rounded-full bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700 border border-blue-100"
>
{{ getFieldTypeLabel(field) }}
</span>
</div>
<p class="text-[11px] text-slate-500 mb-0! mt-1 break-all">
ID: {{ field.id }}
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-2 text-xs text-slate-600">
<div class="rounded-lg bg-slate-50 px-3 py-2 border border-slate-200">
<span class="font-medium text-slate-700">موقعیت:</span>
<span class="mr-1">x: {{ Math.round(field.x) }} | y: {{ Math.round(field.y) }}</span>
</div>
<div class="rounded-lg bg-slate-50 px-3 py-2 border border-slate-200">
<span class="font-medium text-slate-700">ابعاد:</span>
<span class="mr-1">{{ field.width }} × {{ field.height }}</span>
</div>
<div class="rounded-lg bg-slate-50 px-3 py-2 border border-slate-200 leading-6">
<span class="font-medium text-slate-700">تنظیمات:</span>
<span class="mr-1">{{ getFieldMetaSummary(field) }}</span>
</div>
<div class="rounded-lg bg-slate-50 px-3 py-2 border border-slate-200 leading-6">
<span class="font-medium text-slate-700">ولیدیشن:</span>
<span class="mr-1">{{ getFieldValidationSummary(field) }}</span>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<a-button size="small" type="primary" @click="emit('edit', field)">
ویرایش
</a-button>
<a-popconfirm
title="این المان حذف شود؟"
ok-text="بله"
cancel-text="خیر"
@confirm="emit('remove', field.id)"
>
<a-button size="small" danger>
حذف
</a-button>
</a-popconfirm>
</div>
</div>
</template>
<a-empty
v-else
description="هنوز المانی داخل بوم ثبت نشده است"
/>
</div>
</template>
<script setup lang="ts">
import {
getFieldMetaSummary,
getFieldTypeLabel,
getFieldValidationSummary,
} from '@/handler/formBuilder.handler'
import {FormFieldConfig} from "@/models/form";
defineProps<{
fields: FormFieldConfig[]
}>()
const emit = defineEmits<{
(e: 'edit', field: FormFieldConfig): void
(e: 'remove', id: string): void
}>()
</script>
@@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped>
</style>
@@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped>
</style>
@@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped>
</style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import {computed} from "vue";
interface Prop{
size: number | string
}
const props = defineProps<Prop>()
const size = computed(() => `${props.size}px`)
</script>
<template>
<img src="@/assets/img/main.jpg" alt="megashid" class="logo_size">
</template>
<style scoped>
.logo_size{
width: v-bind(size);
height: v-bind(size);
}
</style>
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module 'vuex' {
export interface Store<S> {
state: S
getters: Record<string, unknown>
commit(type: string, payload?: unknown): void
install(app: unknown): void
}
export function createStore<S>(options: {
state: S
mutations?: Record<string, (state: S, ...args: any[]) => void>
getters?: Record<string, (state: S) => unknown>
}): Store<S>
export function useStore<S = unknown>(): Store<S>
}
+183
View File
@@ -0,0 +1,183 @@
import type {FormFieldConfig} from "../models/form.ts";
export const DRAG_TYPE = {
palette: 'palette',
canvas: 'canvas',
} as const
export type FieldType = FormFieldConfig['type']
type PalettePayload = {
source: typeof DRAG_TYPE.palette
fieldType: FieldType
}
type CanvasPayload = {
source: typeof DRAG_TYPE.canvas
id: string
}
export type DragPayload = PalettePayload | CanvasPayload
export function genId() {
return `f-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
export function cloneField<T>(data: T): T {
return JSON.parse(JSON.stringify(data))
}
export function createDefaultField(
fieldType: FieldType,
x: number,
y: number
): FormFieldConfig {
const base = {
id: genId(),
type: fieldType,
label:
fieldType === 'input'
? 'فیلد ورودی'
: fieldType === 'select'
? 'فیلد انتخاب'
: 'اسلایدر',
x,
y,
width: fieldType === 'slider' ? 260 : 220,
height: fieldType === 'slider' ? 64 : 72,
required: false,
} as FormFieldConfig
if (fieldType === 'input') {
return {
...base,
inputType: 'text',
minLength: undefined,
maxLength: undefined,
}
}
if (fieldType === 'select') {
return {
...base,
options: ['گزینه ۱', 'گزینه ۲'],
multiple: false,
}
}
return {
...base,
min: 0,
max: 100,
step: 1,
}
}
export function serializeDragPayload(payload: DragPayload) {
return JSON.stringify(payload)
}
export function parseDragPayload(raw: string): DragPayload | null {
try {
return JSON.parse(raw) as DragPayload
} catch {
return null
}
}
export function getDropPosition(canvas: HTMLElement, e: DragEvent) {
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
return { x, y }
}
export function clampFieldPosition(
x: number,
y: number,
width: number,
height: number,
canvasWidth: number,
canvasHeight: number
) {
return {
x: Math.max(0, Math.min(x, canvasWidth - width)),
y: Math.max(0, Math.min(y, canvasHeight - height)),
}
}
export function sanitizeField(field: FormFieldConfig): FormFieldConfig {
const clean = cloneField(field)
if (clean.type === 'input') {
clean.options = undefined
clean.multiple = undefined
if (clean.inputType !== 'text') {
clean.minLength = undefined
clean.maxLength = undefined
}
if (clean.inputType !== 'number') {
clean.min = undefined
clean.max = undefined
clean.step = undefined
}
if (clean.inputType === 'checkbox' || clean.inputType === 'radio' || clean.inputType === 'color') {
clean.minLength = undefined
clean.maxLength = undefined
}
}
if (clean.type === 'select') {
clean.inputType = undefined
clean.minLength = undefined
clean.maxLength = undefined
clean.min = undefined
clean.max = undefined
clean.step = undefined
clean.options = (clean.options || []).filter(Boolean)
}
if (clean.type === 'slider') {
clean.inputType = undefined
clean.minLength = undefined
clean.maxLength = undefined
clean.options = undefined
clean.multiple = undefined
}
return clean
}
export function getFieldTypeLabel(field: FormFieldConfig) {
if (field.type === 'input') return 'Input'
if (field.type === 'select') return 'Select'
return 'Slider'
}
export function getFieldMetaSummary(field: FormFieldConfig) {
if (field.type === 'input') {
return `نوع: ${field.inputType || '-'}`
}
if (field.type === 'select') {
return `گزینه‌ها: ${(field.options || []).length} | ${field.multiple ? 'چندتایی' : 'تکی'}`
}
return `min:${field.min ?? 0} max:${field.max ?? 100} step:${field.step ?? 1}`
}
export function getFieldValidationSummary(field: FormFieldConfig) {
const parts: string[] = []
if (field.required) parts.push('required')
if (typeof field.minLength === 'number') parts.push(`minLength:${field.minLength}`)
if (typeof field.maxLength === 'number') parts.push(`maxLength:${field.maxLength}`)
if (typeof field.min === 'number') parts.push(`min:${field.min}`)
if (typeof field.max === 'number') parts.push(`max:${field.max}`)
if (typeof field.step === 'number') parts.push(`step:${field.step}`)
return parts.length ? parts.join(' | ') : 'بدون ولیدیشن'
}
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import './assets/css/main.less'
import './assets/css/tailwind.css'
import App from './App.vue'
import router from './router'
import { store } from './store'
const app = createApp(App)
app.use(Antd)
app.use(router)
app.use(store)
app.mount('#app')
+31
View File
@@ -0,0 +1,31 @@
export interface MLogin{
username: string
password: string
}
export interface FormFieldConfig {
id: string
type: 'input' | 'select' | 'slider'
label?: string
inputType?: 'text' | 'number' | 'color' | 'checkbox' | 'radio'
required?: boolean
minLength?: number
maxLength?: number
min?: number
max?: number
step?: number
x: number
y: number
width: number
height: number
options?: string[]
multiple?: boolean
}
export interface SavedForm {
id: string
name: string
fields: FormFieldConfig[]
createdAt: number
}
+42
View File
@@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
import { store } from '../store'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
meta: { requiresGuest: true },
},
{
path: '/',
name: 'dashboard',
component: () => import('../views/DashboardView.vue'),
meta: { requiresAuth: true },
},
{
path: '/preview',
name: 'preview',
component: () => import('../views/FormPreviewView.vue'),
meta: { requiresAuth: true },
},
],
})
router.beforeEach((to, _from, next) => {
const isLoggedIn = store.getters.isLoggedIn
if (to.meta.requiresAuth && !isLoggedIn) {
return next({ name: 'login' })
}
if (to.meta.requiresGuest && isLoggedIn) {
return next({ name: 'dashboard' })
}
next()
})
export default router
+208
View File
@@ -0,0 +1,208 @@
import { createStore } from 'vuex'
import type {FormFieldConfig, SavedForm} from "../models/form.ts";
interface State {
isLoggedIn: boolean
savedForms: SavedForm[]
currentFormFields: FormFieldConfig[]
panelInfo: { elementCount: number }
}
const LS_KEYS = {
isLoggedIn: 'isLoggedIn',
savedForms: 'fb_savedForms',
currentFormFields: 'fb_currentFormFields',
currentFormJson: 'fb_currentFormJson',
}
function readLocalStorage<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch {
return fallback
}
}
const initialSavedForms = readLocalStorage<SavedForm[]>(LS_KEYS.savedForms, [])
const initialCurrentFields = readLocalStorage<FormFieldConfig[]>(LS_KEYS.currentFormFields, [])
const persistPlugin = (store: any) => {
store.subscribe((_mutation: unknown, state: State) => {
localStorage.setItem(LS_KEYS.isLoggedIn, JSON.stringify(state.isLoggedIn))
localStorage.setItem(LS_KEYS.savedForms, JSON.stringify(state.savedForms))
localStorage.setItem(LS_KEYS.currentFormFields, JSON.stringify(state.currentFormFields))
localStorage.setItem(
LS_KEYS.currentFormJson,
JSON.stringify(state.currentFormFields, null, 2)
)
})
}
export const store = createStore<State>({
state: {
isLoggedIn: readLocalStorage<boolean>(LS_KEYS.isLoggedIn, false),
savedForms: initialSavedForms,
currentFormFields: initialCurrentFields,
panelInfo: { elementCount: initialCurrentFields.length },
},
mutations: {
LOGIN(state: State) {
state.isLoggedIn = true
},
LOGOUT(state: State) {
state.isLoggedIn = false
},
ADD_FIELD(state: State, field: FormFieldConfig) {
state.currentFormFields.push(field)
state.panelInfo.elementCount = state.currentFormFields.length
},
UPDATE_FIELD(
state: State,
payload: { id: string; updates: Partial<FormFieldConfig> }
) {
const i = state.currentFormFields.findIndex((f) => f.id === payload.id)
if (i !== -1) {
state.currentFormFields[i] = {
...state.currentFormFields[i],
...payload.updates,
} as FormFieldConfig
}
state.panelInfo.elementCount = state.currentFormFields.length
},
REMOVE_FIELD(state: State, id: string) {
state.currentFormFields = state.currentFormFields.filter((f) => f.id !== id)
state.panelInfo.elementCount = state.currentFormFields.length
},
SET_CURRENT_FIELDS(state: State, fields: FormFieldConfig[]) {
state.currentFormFields = fields
state.panelInfo.elementCount = fields.length
},
CLEAR_CURRENT_FIELDS(state: State) {
state.currentFormFields = []
state.panelInfo.elementCount = 0
},
SAVE_FORM(state: State, form: SavedForm) {
const index = state.savedForms.findIndex((item) => item.id === form.id)
if (index === -1) {
state.savedForms.push(form)
} else {
state.savedForms[index] = form
}
},
DELETE_SAVED_FORM(state: State, id: string) {
state.savedForms = state.savedForms.filter((item) => item.id !== id)
},
LOAD_SAVED_FORM(state: State, id: string) {
const found = state.savedForms.find((item) => item.id === id)
if (found) {
state.currentFormFields = JSON.parse(JSON.stringify(found.fields))
state.panelInfo.elementCount = found.fields.length
}
},
},
getters: {
isLoggedIn: (s: State) => s.isLoggedIn,
savedForms: (s: State) => s.savedForms,
currentFormFields: (s: State) => s.currentFormFields,
panelInfo: (s: State) => s.panelInfo,
currentFormJson: (s: State) => JSON.stringify(s.currentFormFields, null, 2),
},
plugins: [persistPlugin],
})
// /*
// import { createStore } from 'vuex'
//
// export interface FormFieldConfig {
// id: string
// type: 'input' | 'select' | 'slider'
// label?: string
// inputType?: 'text' | 'number' | 'color' | 'checkbox' | 'radio'
// required?: boolean
// minLength?: number
// maxLength?: number
// min?: number
// max?: number
// step?: number
// x: number
// y: number
// width: number
// height: number
// options?: string[]
// multiple?: boolean
// }
//
// export interface SavedForm {
// id: string
// name: string
// fields: FormFieldConfig[]
// createdAt: number
// }
//
// interface State {
// isLoggedIn: boolean
// savedForms: SavedForm[]
// currentFormFields: FormFieldConfig[]
// panelInfo: { elementCount: number }
// }
//
// export const store = createStore<State>({
// state: {
// isLoggedIn: JSON.parse(localStorage.getItem('isLoggedIn') || 'false'),
// savedForms: [],
// currentFormFields: [],
// panelInfo: { elementCount: 0 },
// },
// mutations: {
// LOGIN(state: State) {
// state.isLoggedIn = true
// localStorage.setItem('isLoggedIn', 'true') // ذخیره وضعیت در localStorage
// },
// LOGOUT(state: State) {
// state.isLoggedIn = false
// localStorage.removeItem('isLoggedIn') // حذف وضعیت از localStorage
// },
// ADD_FIELD(state: State, field: FormFieldConfig) {
// state.currentFormFields.push(field)
// state.panelInfo.elementCount = state.currentFormFields.length
// },
// UPDATE_FIELD(state: State, { id, updates }: { id: string; updates: Partial<FormFieldConfig> }) {
// const i = state.currentFormFields.findIndex((f: FormFieldConfig) => f.id === id)
// if (i !== -1) state.currentFormFields[i] = { ...state.currentFormFields[i], ...updates } as FormFieldConfig
// },
// REMOVE_FIELD(state: State, id: string) {
// state.currentFormFields = state.currentFormFields.filter((f: FormFieldConfig) => f.id !== id)
// state.panelInfo.elementCount = state.currentFormFields.length
// },
// SET_CURRENT_FIELDS(state: State, fields: FormFieldConfig[]) {
// state.currentFormFields = fields
// state.panelInfo.elementCount = fields.length
// },
// SAVE_FORM(state: State, form: SavedForm) {
// state.savedForms.push(form)
// },
// },
// getters: {
// isLoggedIn: (s: State) => s.isLoggedIn,
// savedForms: (s: State) => s.savedForms,
// currentFormFields: (s: State) => s.currentFormFields,
// panelInfo: (s: State) => s.panelInfo,
// },
// })
// */
+5
View File
@@ -0,0 +1,5 @@
export function useRequired(fieldName?: string) {
if (fieldName)
return {required: true, message: `لطفا ${fieldName} را وارد کنید`}
else return {required: true, message: 'لطفا این مورد را وارد کنید '}
}
+345
View File
@@ -0,0 +1,345 @@
<template>
<div class="min-h-screen bg-slate-50 flex flex-col">
<header class="bg-white border-b border-slate-200 px-4 py-4 sticky top-0">
<div class="flex gap-4 items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<Logo :size="44" />
<div class="min-w-0">
<h1 class="text-xl font-semibold text-slate-800 mb-0! truncate">
داشبورد فرم‌ساز مگاشید
</h1>
<p class="text-sm text-slate-500 mb-0!">
طراحی، ویرایش و ذخیره فرم به‌صورت محلی
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<div class="w-full sm:w-[260px]">
<a-input
v-model:value="formName"
size="large"
placeholder="نام فرم را وارد کنید"
/>
</div>
<div class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<span class="text-sm text-slate-600">حالت ویرایش</span>
<a-switch v-model:checked="editMode" />
</div>
<a-button type="primary" size="large" @click="saveCurrentForm">
ذخیره فرم
</a-button>
<a-button danger size="large" @click="handleConfirmLogout">
<div class="flex items-center gap-2">
<LogoutOutlined />
<span>خروج</span>
</div>
</a-button>
</div>
</div>
</header>
<div class="flex-1 min-h-0 flex flex-col xl:flex-row overflow-hidden">
<section class="flex-1 min-h-0 flex flex-col border-b xl:border-b-0 xl:border-l border-slate-200">
<div class="bg-white border-b border-slate-200 px-4 py-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<span class="text-sm text-slate-600">تعداد المان‌ها:</span>
<span class="inline-flex min-w-8 items-center justify-center rounded-full bg-blue-100 px-2 py-1 text-xs font-semibold text-blue-700">
{{ panelInfo.elementCount }}
</span>
</div>
<p class="text-xs text-slate-500 mb-0!">
{{ editMode ? 'حالت ویرایش فعال است؛ می‌توانید جابه‌جا و ویرایش کنید.' : 'حالت مشاهده فعال است.' }}
</p>
</div>
</div>
<div class="flex-1 min-h-0 overflow-auto p-4">
<BuilderCanvas
:fields="currentFormFields"
:edit-mode="editMode"
@request-create="openCreateModal"
@request-edit="openEditModal"
@move-field="handleMoveField"
/>
</div>
<div class="border-t border-slate-200 bg-white p-4">
<div class="flex items-center justify-between gap-3 mb-3">
<h2 class="text-sm font-semibold text-slate-800 mb-0!">
خروجی JSON زنده
</h2>
<span class="text-xs text-slate-500">با هر تغییر آپدیت می‌شود</span>
</div>
<pre
class="max-h-64 overflow-auto rounded-xl bg-slate-950 p-4 text-xs leading-6 text-slate-100"
>{{ currentFormJson }}</pre>
</div>
</section>
<aside class="w-full xl:w-[360px] shrink-0 min-h-0 bg-white">
<BuilderPalette
:fields="currentFormFields"
:saved-forms="savedForms"
:form-name="formName"
@update:form-name="handleFormNameChange"
@edit="openEditModal"
@remove="handleRemoveField"
@save-form="saveCurrentForm"
@load-saved-form="handleLoadSavedForm"
@delete-saved-form="handleDeleteSavedForm"
/>
</aside>
</div>
<FieldConfigModal
:open="modalOpen"
:mode="modalMode"
:field="draftField"
@submit="handleModalSubmit"
@cancel="handleModalCancel"
@update:open="handleModalOpenChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed, createVNode, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { message, Modal } from 'ant-design-vue'
import { ExclamationCircleOutlined, LogoutOutlined } from '@ant-design/icons-vue'
import Logo from '@/components/Logo.vue'
import BuilderCanvas from '@/components/FormBuilder/BuilderCanvas.vue'
import BuilderPalette from '@/components/FormBuilder/BuilderPalette.vue'
import FieldConfigModal from '@/components/FormBuilder/FieldConfigModal.vue'
import {
cloneField,
genId,
sanitizeField,
} from '@/handler/formBuilder.handler'
import {FormFieldConfig, SavedForm} from "@/models/form";
const store = useStore()
const router = useRouter()
const CURRENT_FORM_META_KEY = 'fb_currentFormMeta'
type ModalMode = 'create' | 'edit'
type CurrentFormMeta = {
id: string
name: string
}
function readCurrentFormMeta(): CurrentFormMeta {
try {
const raw = localStorage.getItem(CURRENT_FORM_META_KEY)
if (!raw) {
return {
id: genId(),
name: 'فرم جدید',
}
}
const parsed = JSON.parse(raw) as Partial<CurrentFormMeta>
return {
id: parsed.id || genId(),
name: parsed.name || 'فرم جدید',
}
} catch {
return {
id: genId(),
name: 'فرم جدید',
}
}
}
function writeCurrentFormMeta(meta: CurrentFormMeta) {
localStorage.setItem(CURRENT_FORM_META_KEY, JSON.stringify(meta))
}
const initialMeta = readCurrentFormMeta()
const activeFormId = ref(initialMeta.id)
const formName = ref(initialMeta.name)
const editMode = ref(true)
const modalOpen = ref(false)
const modalMode = ref<ModalMode>('create')
const draftField = ref<FormFieldConfig | null>(null)
const currentFormFields = computed(
() => store.getters.currentFormFields as FormFieldConfig[]
)
const savedForms = computed(
() => store.getters.savedForms as SavedForm[]
)
const panelInfo = computed(
() => store.getters.panelInfo as { elementCount: number }
)
const currentFormJson = computed(
() => store.getters.currentFormJson as string
)
watch(
[activeFormId, formName],
() => {
writeCurrentFormMeta({
id: activeFormId.value,
name: formName.value || 'فرم جدید',
})
},
{ immediate: true }
)
function handleFormNameChange(value: string) {
formName.value = value
}
function openCreateModal(field: FormFieldConfig) {
modalMode.value = 'create'
draftField.value = cloneField(field)
modalOpen.value = true
}
function openEditModal(field: FormFieldConfig) {
modalMode.value = 'edit'
draftField.value = cloneField(field)
modalOpen.value = true
}
function handleModalSubmit(field: FormFieldConfig) {
const cleanField = sanitizeField(field)
if (modalMode.value === 'create') {
store.commit('ADD_FIELD', cleanField)
message.success('المان با موفقیت اضافه شد')
} else {
store.commit('UPDATE_FIELD', {
id: cleanField.id,
updates: cleanField,
})
message.success('المان با موفقیت ویرایش شد')
}
modalOpen.value = false
draftField.value = null
}
function handleModalCancel() {
modalOpen.value = false
draftField.value = null
}
function handleModalOpenChange(value: boolean) {
modalOpen.value = value
if (!value) {
draftField.value = null
}
}
function handleRemoveField(id: string) {
store.commit('REMOVE_FIELD', id)
message.success('المان حذف شد')
}
function handleMoveField(payload: { id: string; x: number; y: number }) {
if (!editMode.value) return
store.commit('UPDATE_FIELD', {
id: payload.id,
updates: {
x: payload.x,
y: payload.y,
},
})
}
function saveCurrentForm() {
const trimmedName = formName.value.trim()
if (!trimmedName) {
message.error('نام فرم را وارد کنید')
return
}
if (!currentFormFields.value.length) {
message.warning('هنوز هیچ المانی به فرم اضافه نشده است')
return
}
const form: SavedForm = {
id: activeFormId.value,
name: trimmedName,
fields: cloneField(currentFormFields.value),
createdAt: Date.now(),
}
store.commit('SAVE_FORM', form)
writeCurrentFormMeta({
id: activeFormId.value,
name: trimmedName,
})
message.success('فرم با موفقیت ذخیره شد')
}
function handleLoadSavedForm(id: string) {
store.commit('LOAD_SAVED_FORM', id)
const found = savedForms.value.find((item) => item.id === id)
if (found) {
activeFormId.value = found.id
formName.value = found.name
writeCurrentFormMeta({
id: found.id,
name: found.name,
})
message.success('فرم بارگذاری شد')
}
}
function handleDeleteSavedForm(id: string) {
const isCurrentForm = activeFormId.value === id
store.commit('DELETE_SAVED_FORM', id)
if (isCurrentForm) {
activeFormId.value = genId()
formName.value = 'فرم جدید'
writeCurrentFormMeta({
id: activeFormId.value,
name: formName.value,
})
}
message.success('فرم ذخیره‌شده حذف شد')
}
function logout() {
store.commit('LOGOUT')
router.push({ name: 'login' })
}
function handleConfirmLogout() {
Modal.confirm({
title: 'از خروج خود اطمینان دارید؟',
icon: createVNode(ExclamationCircleOutlined),
okText: 'بله',
cancelText: 'خیر',
centered: true,
async onOk() {
logout()
message.success('با موفقیت از پنل خارج شدید')
},
})
}
</script>
+195
View File
@@ -0,0 +1,195 @@
<template>
<div class="min-h-screen flex flex-col bg-slate-50">
<header class="bg-white border-b border-slate-200 px-4 py-3 flex items-center justify-between shrink-0">
<div class="flex items-center gap-2">
<Logo :size="50"/>
<h1 class="text-xl font-semibold mb-0! text-slate-800">داشبورد فرم‌ساز مگاشید</h1>
</div>
<a-button danger @click="handleConfirmLogout">
<div class="flex items-center gap-2">
<logoutOutlined />
<span>خروج</span>
</div>
</a-button>
</header>
<div class="flex-1 flex overflow-hidden min-h-0">
<!-- بوم: یک صفحه با position relative؛ المان‌ها absolute با left/top/width/height -->
<main class="flex-1 overflow-auto p-4 border-r border-slate-200 bg-slate-100">
<div
ref="canvasRef"
class="canvas-page relative bg-white rounded-lg shadow-sm border border-slate-200 mx-auto"
style="min-height: 560px; width: 100%; max-width: 720px;"
@dragover="onCanvasDragOver"
@drop="onCanvasDrop"
>
<!-- المان‌های فرم با v-for و موقعیت مطلق -->
<template v-for="field in currentFormFields" :key="field.id">
<div
class="absolute border border-slate-300 rounded bg-white shadow-sm cursor-grab active:cursor-grabbing flex items-center justify-center overflow-hidden hover:border-blue-400 hover:ring-1 hover:ring-blue-300"
:style="{
left: field.x + 'px',
top: field.y + 'px',
width: field.width + 'px',
height: field.height + 'px',
}"
draggable="true"
@dragstart="onCanvasElementDragStart($event, field.id)"
>
<span class="text-slate-500 text-xs truncate px-1">
{{ field.type === 'input' ? 'Input' : field.type === 'select' ? 'Select' : 'Slider' }}
</span>
</div>
</template>
<div
v-if="currentFormFields.length === 0"
class="absolute inset-0 flex items-center justify-center pointer-events-none text-slate-400 text-sm"
>
المان‌ها را از پالت بکشید و اینجا رها کنید.
</div>
</div>
</main>
<!-- پالت: المان‌های قابل کشیدن -->
<aside class="w-64 shrink-0 border-l border-slate-200 bg-white overflow-auto">
<div class="p-4">
<h2 class="font-semibold text-slate-800 mb-3">پالت المان‌ها</h2>
<div class="space-y-2">
<div
class="palette-item p-3 rounded-lg bg-slate-100 border border-slate-200 cursor-grab active:cursor-grabbing text-sm text-slate-700 select-none hover:border-blue-300 hover:bg-slate-50"
draggable="true"
@dragstart="onPaletteDragStart($event, 'input')"
>
Input (متن / عدد / رنگ / چک‌باکس / رادیو)
</div>
<div
class="palette-item p-3 rounded-lg bg-slate-100 border border-slate-200 cursor-grab active:cursor-grabbing text-sm text-slate-700 select-none hover:border-blue-300 hover:bg-slate-50"
draggable="true"
@dragstart="onPaletteDragStart($event, 'select')"
>
Select (تکی / چندتایی)
</div>
<div
class="palette-item p-3 rounded-lg bg-slate-100 border border-slate-200 cursor-grab active:cursor-grabbing text-sm text-slate-700 select-none hover:border-blue-300 hover:bg-slate-50"
draggable="true"
@dragstart="onPaletteDragStart($event, 'slider')"
>
Range Slider
</div>
</div>
<p class="text-xs text-slate-500 mt-4">
هر المان را بکشید و روی صفحهٔ سفید رها کنید؛ برای جابه‌جایی دوباره بکشید.
</p>
</div>
</aside>
</div>
</div>
</template>
<script setup lang="ts">
import {ref, computed, createVNode} from 'vue'
import {useStore} from 'vuex'
import type {FormFieldConfig} from '@/store'
import Logo from "@/components/Logo.vue";
import { LogoutOutlined , ExclamationCircleOutlined} from '@ant-design/icons-vue'
import {message, Modal} from "ant-design-vue";
const store = useStore()
const canvasRef = ref<HTMLElement | null>(null)
const currentFormFields = computed(() => store.getters.currentFormFields as FormFieldConfig[])
const DRAG_TYPE = {
palette: 'palette',
canvas: 'canvas',
} as const
function genId() {
return `f-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function createNewField(
fieldType: 'input' | 'select' | 'slider',
left: number,
top: number
): FormFieldConfig {
const w = fieldType === 'slider' ? 240 : 220
const h = fieldType === 'slider' ? 48 : 40
const base: FormFieldConfig = {
id: genId(),
type: fieldType,
x: left,
y: top,
width: w,
height: h,
}
if (fieldType === 'input') return {...base, inputType: 'text'}
if (fieldType === 'select') return {...base, options: ['گزینه ۱', 'گزینه ۲']}
return {...base, min: 0, max: 100, step: 1}
}
function onPaletteDragStart(e: DragEvent, fieldType: 'input' | 'select' | 'slider') {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData(
'application/json',
JSON.stringify({source: DRAG_TYPE.palette, fieldType})
)
}
function onCanvasElementDragStart(e: DragEvent, id: string) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('application/json', JSON.stringify({source: DRAG_TYPE.canvas, id}))
}
function onCanvasDragOver(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
function onCanvasDrop(e: DragEvent) {
e.preventDefault()
if (!canvasRef.value || !e.dataTransfer) return
const data = e.dataTransfer.getData('application/json')
if (!data) return
const rect = canvasRef.value.getBoundingClientRect()
const left = e.clientX - rect.left + (canvasRef.value.scrollLeft ?? 0)
const top = e.clientY - rect.top + (canvasRef.value.scrollTop ?? 0)
try {
const payload = JSON.parse(data)
if (payload.source === DRAG_TYPE.palette && payload.fieldType) {
const field = createNewField(payload.fieldType, left, top)
store.commit('ADD_FIELD', field)
} else if (payload.source === DRAG_TYPE.canvas && payload.id) {
store.commit('UPDATE_FIELD', {
id: payload.id,
updates: {x: left, y: top},
})
}
} catch (_) {
}
}
function logout() {
store.commit('LOGOUT')
window.location.href = '/login'
}
const handleConfirmLogout = () => {
console.log('sss')
Modal.confirm({
title: 'از خروج خود اطمینان دارید؟',
icon: createVNode(ExclamationCircleOutlined),
okText: 'بله',
cancelText: 'خیر',
centered: true,
async onOk() {
logout()
message.success('با موفقیت از پنل خارج شدید')
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
onCancel() {
},
});
};
</script>
+7
View File
@@ -0,0 +1,7 @@
<template>
<div class="p-6">
<h1>Preview Page</h1>
</div>
</template>
<script setup lang="ts"></script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-slate-100">
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
<h1 class="text-2xl font-bold text-center mb-6 text-slate-800">ورود به فرم‌ساز</h1>
<a-form ref="formRef" layout="vertical" class="space-y-4" :model="form" @keydown.enter="login">
<a-form-item label="نام کاربری" name="username" :rules="[useRequired('نام کاربری')]">
<a-input v-model:value="form.username" placeholder="نام کاربری" size="large"/>
</a-form-item>
<a-form-item label="رمز عبور" name="password" :rules="[useRequired('رمز عبور')]">
<a-input-password v-model:value="form.password" placeholder="رمز عبور" size="large"/>
</a-form-item>
<a-button type="primary" block size="large" @click="login">
ورود
</a-button>
</a-form>
<p class="text-center text-slate-500 text-sm mt-3!">
نام کاربری و رمز عبور megashid می باشد
</p>
</div>
</div>
</template>
<script setup lang="ts">
import {reactive, ref} from 'vue'
import {useRouter} from 'vue-router'
import {useStore} from 'vuex'
import {message} from "ant-design-vue";
import type {MLogin} from "../models/form.ts";
import type {FormInstance} from "ant-design-vue/lib";
import {useRequired} from "../utils/useRule.ts";
const store = useStore()
const router = useRouter()
const formRef = ref<FormInstance>()
const form = reactive<Partial<MLogin>>({
username: undefined,
password: undefined
})
function login() {
formRef.value?.validateFields().then(() => {
if (form.username === 'megashid' && form.password === 'megashid') {
store.commit('LOGIN')
router.push({name: 'dashboard'})
} else {
message.error('نام کاربری یا رمز عبور اشتباه است')
}
})
}
</script>
+15
View File
@@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
theme: {
extend: {
fontFamily: {
sans: ['system-ui', 'Avenir', 'Helvetica', 'Arial', 'sans-serif'],
},
},
},
plugins: [],
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client", "@ant-design/icons-vue/global"],
"jsx": "preserve",
"sourceMap": true,
"isolatedModules": true,
"noEmit": true,
"allowJs": true,
"checkJs": false
},
"include": ["src/**/*", "env.d.ts", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import {fileURLToPath, URL} from "node:url";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
port: 3030,
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
})
+1005
View File
File diff suppressed because it is too large Load Diff