pre form builder handler
This commit is contained in:
+24
@@ -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?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
@@ -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>
|
||||
Generated
+1882
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
:root {
|
||||
--color-primary: #171717;
|
||||
--color-secondary: #262626;
|
||||
--color-third: #0c549c;
|
||||
--color-forth: #FF6467FF;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+16
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
// },
|
||||
// })
|
||||
// */
|
||||
@@ -0,0 +1,5 @@
|
||||
export function useRequired(fieldName?: string) {
|
||||
if (fieldName)
|
||||
return {required: true, message: `لطفا ${fieldName} را وارد کنید`}
|
||||
else return {required: true, message: 'لطفا این مورد را وارد کنید '}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1>Preview Page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
@@ -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>
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user