Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49f30a96b1 | ||
|
|
789d224ea0 | ||
|
|
31076174e2 | ||
|
|
0eb23b40e5 | ||
|
|
bb7f1e3378 | ||
|
|
e31ef5a21f |
@@ -1,11 +1,16 @@
|
||||
node_modules
|
||||
.next
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.output
|
||||
**/.nuxt
|
||||
**/.turbo
|
||||
**/*.tsbuildinfo
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
apps/api/src/generated
|
||||
.git
|
||||
.gitignore
|
||||
src
|
||||
public
|
||||
_old_vite_backup
|
||||
*.md
|
||||
out
|
||||
backups
|
||||
محصولات
|
||||
_dev.log
|
||||
.env*
|
||||
npm-debug.log*
|
||||
*.skill
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Production settings for docker-compose.yml — copy to .env and change every secret.
|
||||
# Generate secrets with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
|
||||
# domains (TLS is terminated in front of the gateway: host nginx, Traefik or the CDN)
|
||||
SITE_DOMAIN=imenpishgaman.ir
|
||||
ADMIN_DOMAIN=admin.imenpishgaman.ir
|
||||
# host port of the gateway container
|
||||
HTTP_PORT=8080
|
||||
# reverse proxies in front of the API: gateway (1) + host TLS proxy (1)
|
||||
TRUST_PROXY=2
|
||||
# set to false only if the admin is served over plain http
|
||||
COOKIE_SECURE=true
|
||||
|
||||
# database
|
||||
POSTGRES_USER=imen
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_DB=imen
|
||||
|
||||
# object storage (MinIO container); bucket is created automatically
|
||||
S3_BUCKET=imen-media
|
||||
S3_ACCESS_KEY=imen
|
||||
S3_SECRET_KEY=change-me-at-least-8-chars
|
||||
|
||||
# auth + cache purge secrets
|
||||
JWT_SECRET=change-me-48-random-bytes
|
||||
REVALIDATE_SECRET=change-me-too
|
||||
|
||||
# first admin user (created by: docker compose exec api node dist/seed/seed.js)
|
||||
ADMIN_PHONE=09120000000
|
||||
ADMIN_PASSWORD=change-me
|
||||
ADMIN_NAME=مدیر سایت
|
||||
|
||||
# npm registry used while building the images (set a mirror if npmjs.org is slow or blocked)
|
||||
NPM_REGISTRY=https://registry.npmjs.org/
|
||||
@@ -1,10 +1,16 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
# out/ عمداً در git نگه داشته میشود (بیلد محلی → دیپلوی با Docker)
|
||||
next-env.d.ts
|
||||
# build output
|
||||
dist/
|
||||
.output/
|
||||
.nuxt/
|
||||
.data/
|
||||
.turbo/
|
||||
*.tsbuildinfo
|
||||
|
||||
# prisma generated client
|
||||
apps/api/src/generated/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -12,9 +18,12 @@ next-env.d.ts
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
!.env.example
|
||||
|
||||
# old vite backup (remove once migration is verified and no longer needed)
|
||||
_old_vite_backup/
|
||||
# database dumps from the backup service
|
||||
backups/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
out
|
||||
dist
|
||||
.output
|
||||
.nuxt
|
||||
pnpm-lock.yaml
|
||||
apps/api/src/generated
|
||||
محصولات
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM docker.arvancloud.ir/nginx:latest
|
||||
|
||||
# خروجی static export (npm run build → پوشه out)
|
||||
COPY out /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,138 +1,155 @@
|
||||
# آریا ایمن — لندینگ (Next.js)
|
||||
# ایمن پیشگامان — مونوریپو (سایت + API + پنل مدیریت)
|
||||
|
||||
لندینگ شرکت آریا ایمن، مهاجرتیافته از Vite/React به Next.js (App Router) با خروجی کاملاً استاتیک، بهینه برای سئو و سرعت.
|
||||
سایت ایمن پیشگامان بههمراه بکند و پنل مدیریت محتوا. همهی متنها، تصاویر، دستهها و محصولات سایت از پنل مدیریت قابل ویرایشاند.
|
||||
|
||||
## نصب
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## اجرا در حالت توسعه
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
سپس آدرس `http://localhost:3000` را باز کنید.
|
||||
|
||||
## Build (تولید نسخه نهایی استاتیک)
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
خروجی در پوشه `out/` ساخته میشود — مجموعهای از فایلهای HTML/CSS/JS کاملاً استاتیک که نیازی به سرور Node ندارند.
|
||||
|
||||
برای پیشنمایش محلی خروجی build:
|
||||
|
||||
```bash
|
||||
npx serve out
|
||||
```
|
||||
|
||||
## دیپلوی پروداکشن با Docker
|
||||
|
||||
بیلد روی ماشین توسعه انجام میشود؛ خروجی `out/` داخل گیت commit میشود و روی سرور فقط image ساخته و اجرا میشود (بدون `npm install` / `npm run build` در سرور).
|
||||
|
||||
### روی سیستم توسعه (قبل از push)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
git add out Dockerfile nginx.conf docker-compose.yml
|
||||
git commit -m "build: ..."
|
||||
git push
|
||||
```
|
||||
|
||||
### روی سرور پروداکشن
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
پیشفرض `docker-compose.yml` پورت **8080** را به کانتینر map میکند؛ در reverse proxy (مثلاً Traefik یا Nginx روی host) به `http://127.0.0.1:8080` اشاره کنید، یا پورت را در compose به `80:80` تغییر دهید.
|
||||
|
||||
بدون compose:
|
||||
|
||||
```bash
|
||||
docker build -t aria-imen-landing .
|
||||
docker run -d --name aria-imen-landing -p 8080:80 --restart unless-stopped aria-imen-landing
|
||||
```
|
||||
|
||||
تنظیمات Nginx داخل image مطابق export استاتیک Next.js است (`try_files`، کش `/_next/static/`، صفحه `404.html`).
|
||||
|
||||
## آپلود روی هاست (Static Hosting)
|
||||
|
||||
این پروژه با `output: 'export'` پیکربندی شده، یعنی:
|
||||
|
||||
1. `npm run build` را اجرا کنید.
|
||||
2. کل محتوای پوشه `out/` را (نه خود پوشه، بلکه فایلهای داخلش) در ریشه هاست (public_html، یا مشابه آن در cPanel/Nginx) آپلود کنید.
|
||||
3. سایت بدون نیاز به Node.js یا هیچ سرور اختصاصی کار میکند.
|
||||
|
||||
### نکات مهم هاست استاتیک (cPanel / Nginx / هاست ایرانی)
|
||||
|
||||
- **ریدایرکتها و هدرها** در `next.config.ts` روی static export اجرا نمیشوند. اگر بعداً نیاز به ریدایرکت ۳۰۱ داشتید (مثلاً از `/index.html`)، باید در سطح هاست تنظیم شود:
|
||||
|
||||
**Apache (.htaccess):**
|
||||
```apache
|
||||
Redirect 301 /index.html /
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
ExpiresByType font/woff2 "access plus 1 year"
|
||||
</IfModule>
|
||||
ErrorDocument 404 /404.html
|
||||
```
|
||||
|
||||
**Nginx:**
|
||||
```nginx
|
||||
location /_next/static/ { expires 1y; add_header Cache-Control "public, immutable"; }
|
||||
location / { try_files $uri $uri.html $uri/index.html =404; }
|
||||
error_page 404 /404.html;
|
||||
gzip on; gzip_types text/css application/javascript image/svg+xml;
|
||||
```
|
||||
|
||||
ریدایرکت `location = /index.html { return 301 /; }` را نگذارید؛ با `index index.html` حلقهٔ ۳۰۱ روی `/` میسازد.
|
||||
|
||||
## پیش از دیپلوی واقعی — کارهایی که باید انجام دهید
|
||||
|
||||
این پروژه فعلاً با دادههای تستی/placeholder برای موارد زیر ساخته شده و **باید قبل از انتشار واقعی بررسی و اصلاح شوند**:
|
||||
|
||||
1. **دامنه واقعی**: در فایل [`src/config/site.ts`](src/config/site.ts) مقدار `url: "https://ariaiman.ir"` باید با دامنه نهایی واقعی جایگزین شود (این مقدار روی canonical، Open Graph، sitemap و JSON-LD اثر مستقیم دارد).
|
||||
2. **لوگوی رسمی**: در همان فایل، `logo` فعلاً به تصویر hero اشاره میکند؛ یک فایل PNG/SVG لوگوی واقعی جایگزین شود.
|
||||
3. **آیکونهای PWA**: `src/app/manifest.ts` فعلاً بدون آیکون است. دو فایل `icon-192.png` و `icon-512.png` در `public/` قرار داده و در manifest معرفی شوند.
|
||||
4. **تصویر Open Graph**: یک فایل `opengraph-image.png` با ابعاد ۱۲۰۰×۶۳۰ در `src/app/` قرار دهید (Next.js آن را بهصورت خودکار شناسایی میکند).
|
||||
5. **شبکههای اجتماعی**: در `src/config/site.ts`، آرایه `socials` خالی است. لینکهای واقعی اینستاگرام/تلگرام/لینکدین را اضافه کنید تا در Schema.org (`sameAs`) درج شود.
|
||||
6. **فرم تماس**: فرم بخش «تماس» فعلاً فقط یک شبیهسازی محلی است (بدون ارسال واقعی به سرور یا سرویس ایمیل) — دقیقاً همان رفتاری که در نسخه قبلی هم وجود داشت. برای فعالسازی واقعی باید به یک سرویس فرم (مثل فرماسپری، ایمیل، یا API اختصاصی) متصل شود.
|
||||
7. **npm audit**: یک آسیبپذیری «high» در نسخه postcss داخلی Next.js 15.5 گزارش میشود (`npm audit`). این مربوط به مرحله build/dev است، نه کد سمت کاربر نهایی. رفع کامل آن نیازمند ارتقا به Next.js 16 (breaking change) است — در صورت تمایل بهروزرسانی جداگانه انجام شود.
|
||||
|
||||
## چکلیست سئوی پیادهسازیشده
|
||||
|
||||
| مورد | وضعیت | فایل |
|
||||
| بخش | مسیر | فناوری |
|
||||
|---|---|---|
|
||||
| یک H1 با کلمه کلیدی اصلی | ✅ | `src/components/sections/Hero.tsx` |
|
||||
| سلسلهمراتب H2/H3 منطقی، بدون پرش سطح | ✅ | `src/components/ui/SectionHead.tsx` و همه سکشنها |
|
||||
| عنوان و توضیحات با طول مناسب | ✅ | `src/config/site.ts` |
|
||||
| Canonical، metadataBase، Open Graph، Twitter Card | ✅ | `src/app/layout.tsx` |
|
||||
| `lang="fa"` و `dir="rtl"` | ✅ | `src/app/layout.tsx` |
|
||||
| JSON-LD (LocalBusiness + WebSite) | ✅ | `src/lib/seo/schema.ts`, `src/components/seo/JsonLd.tsx` |
|
||||
| `sitemap.xml` | ✅ | `src/app/sitemap.ts` |
|
||||
| `robots.txt` | ✅ | `src/app/robots.ts` |
|
||||
| `manifest.webmanifest` | ✅ (بدون آیکون — نیاز به تکمیل) | `src/app/manifest.ts` |
|
||||
| صفحه ۴۰۴ واقعی (بدون ریدایرکت به خانه) | ✅ | `src/app/not-found.tsx` |
|
||||
| alt توصیفی روی همه تصاویر محتوایی | ✅ | همه کامپوننتهای سکشن |
|
||||
| تمام متن در HTML بدون نیاز به اجرای JS | ✅ | تأیید شده با بررسی خروجی build |
|
||||
| لینکهای داخلی سالم (`#anchor`) | ✅ | بدون تغییر نسبت به نسخه اصلی |
|
||||
| فونت خودمیزبان (self-hosted) بدون CDN خارجی | ✅ | `public/fonts/`, `src/app/layout.tsx` |
|
||||
| تصاویر بدون وابستگی به CDN خارجی | ✅ | تصاویر Industries از pexels دانلود و داخلی شدند |
|
||||
| سایت | `apps/web` | Nuxt 4 (SSR + کش SWR)، Vue 3، Tailwind v4، Ant Design Vue، Pinia |
|
||||
| API | `apps/api` | NestJS 12، Prisma 7، PostgreSQL، ذخیرهساز S3 (MinIO) |
|
||||
| پنل مدیریت | `apps/admin` | Vue 3 + Vite، Ant Design Vue، Tailwind v4، Pinia، Vue Router |
|
||||
| کد مشترک | `packages/shared` | اسکیماهای Zod و تایپها (مشترک بین هر سه اپ) |
|
||||
|
||||
## معماری فنی مهاجرت
|
||||
ابزار مونوریپو: **pnpm workspaces + Turborepo**.
|
||||
|
||||
- **Server Components برای محتوا**: تمام بخشهای بدون تعامل (Hero, About, Standards, TapeMarquee, Footer) بهصورت Server Component هستند و در HTML build نهایی کامل قابل مشاهدهاند.
|
||||
- **Client Components فقط برای تعامل**: منو موبایل، فرم تماس، اسلایدر نظرات، پنلهای تعاملی محصولات/خدمات، شمارنده آمار، کرسر سفارشی و Preloader، هرکدام جداگانه `'use client'` هستند تا حجم JS اولیه صفحه کمینه بماند.
|
||||
- **بدون بکاند**: هیچ `app/api`، دیتابیس یا server action اضافه نشده؛ طبق درخواست پروژه.
|
||||
- **انیمیشن ورودی Hero با CSS خالص**: برخلاف نسخه اصلی (که H1 تا پایان انیمیشن Preloader با `opacity:0` مخفی بود)، اکنون محتوای Hero همیشه در HTML اولیه قابل مشاهده است و فقط با CSS keyframes (بدون وابستگی به اجرای JS) وارد صفحه میشود — این تغییر برای رفع مشکل LCP/SEO با تأیید شما انجام شد.
|
||||
```
|
||||
مرورگر ──► gateway (nginx)
|
||||
├─ imenpishgaman.ir → web (Nuxt SSR، صفحات کششده)
|
||||
├─ admin.imenpishgaman.ir → admin (فایلهای استاتیک)
|
||||
├─ /api/* → api (NestJS) ──► PostgreSQL
|
||||
└─ /media/* → MinIO (تصاویر آپلودشده)
|
||||
|
||||
ذخیره در پنل ──► API ──► POST web:/_internal/revalidate ──► کش صفحات سایت پاک میشود
|
||||
```
|
||||
|
||||
## ساختار پوشهها
|
||||
|
||||
```
|
||||
apps/
|
||||
web/ سایت (همان طراحی قبلی؛ دادهها از API)
|
||||
app/stores/site.ts محتوای سایت از GET /api/public/site (یک بار در هر رندر)
|
||||
app/plugins/api.ts $api — در SSR آدرس داخلی API، در مرورگر /api
|
||||
server/routes/ sitemap.xml (از دیتابیس)، robots.txt، manifest، _internal/revalidate
|
||||
api/
|
||||
prisma/schema.prisma مدل داده؛ migrations/ کنار آن
|
||||
src/ ماژولها: auth, users, catalog, media, sections, contact, public, dashboard
|
||||
src/seed/ مقداردهی اولیه (محتوای فعلی سایت + ۸ دسته / ۸۹ محصول)
|
||||
seed-assets/ تصاویر اولیه که seed در MinIO آپلود میکند
|
||||
test/ تستهای e2e
|
||||
admin/
|
||||
src/pages/ داشبورد، محصولات، دستهها، بخشهای سایت، پیامها، رسانه، کاربران
|
||||
src/sections/ فرم اختصاصی هر بخش سایت
|
||||
packages/shared/src/ sections.ts (اسکیمای بخشها)، catalog.ts، admin.ts، icons.ts
|
||||
docker/ Dockerfile هر اپ و پیکربندی nginx
|
||||
docker-compose.yml استقرار production
|
||||
docker-compose.dev.yml PostgreSQL + MinIO برای توسعه
|
||||
```
|
||||
|
||||
## راهاندازی محیط توسعه
|
||||
|
||||
پیشنیاز: Node.js 22.19 یا جدیدتر (۲۴ توصیه میشود)، Docker.
|
||||
|
||||
```bash
|
||||
corepack enable pnpm # در ویندوز بدون دسترسی ادمین:
|
||||
# corepack enable pnpm --install-directory "%APPDATA%\npm"
|
||||
pnpm install
|
||||
|
||||
pnpm infra:up # PostgreSQL روی 5433 و MinIO روی 9000 (کنسول: 9001)
|
||||
|
||||
cp apps/api/.env.example apps/api/.env
|
||||
cp apps/web/.env.example apps/web/.env
|
||||
|
||||
pnpm db:migrate # ساخت جدولها
|
||||
pnpm db:seed # محتوای اولیه + کاربر مدیر (ADMIN_PHONE / ADMIN_PASSWORD در apps/api/.env)
|
||||
|
||||
pnpm dev # هر سه اپ با هم
|
||||
```
|
||||
|
||||
| آدرس | چیست |
|
||||
|---|---|
|
||||
| http://localhost:3000 | سایت |
|
||||
| http://localhost:5173 | پنل مدیریت |
|
||||
| http://localhost:4000/api | API |
|
||||
| http://localhost:9001 | کنسول MinIO |
|
||||
|
||||
در توسعه، سرور Nuxt و Vite مسیرهای `/api` و `/media` را به API و MinIO پراکسی میکنند؛ در production همین کار را nginx انجام میدهد. به همین دلیل آدرس تصاویر همهجا بهصورت `/media/...` ذخیره میشود.
|
||||
|
||||
### اسکریپتها (از ریشهی پروژه)
|
||||
|
||||
| دستور | کار |
|
||||
|---|---|
|
||||
| `pnpm dev` | اجرای همهی اپها در حالت توسعه |
|
||||
| `pnpm build` | build همهی پکیجها |
|
||||
| `pnpm typecheck` | بررسی TypeScript همهی پکیجها |
|
||||
| `pnpm test` | تستهای e2e بکند (روی دیتابیس توسعه؛ دادههای تست پاک میشوند) |
|
||||
| `pnpm db:migrate` | ساخت/اعمال migration (`prisma migrate dev`) |
|
||||
| `pnpm db:seed` | مقداردهی اولیه؛ اجرای دوباره چیزی را بازنویسی نمیکند |
|
||||
| `pnpm db:studio` | Prisma Studio برای دیدن دادهها |
|
||||
| `pnpm infra:up` / `infra:down` | روشن/خاموش کردن PostgreSQL و MinIO |
|
||||
|
||||
برای برگرداندن متنهای سایت به محتوای اولیه: `SEED_OVERWRITE_SECTIONS=1 pnpm db:seed`.
|
||||
|
||||
## پنل مدیریت
|
||||
|
||||
- **محصولات**: افزودن/ویرایش، تصویر اصلی و گالری، توضیحات کامل، جدول مشخصات فنی، ویژگیهای کلیدی، کاربردها، استانداردها، سایزها و مدلها، برند و کشور سازنده، بستهبندی و حداقل سفارش، سئو، انتشار یا پیشنویس، کپی، مرتبسازی (با فیلتر یک دسته). هر محصول صفحهی اختصاصی `/products/<اسلاگ>` دارد (گالری با بزرگنمایی، مشخصات، محصولات مرتبط، دکمهی درخواست قیمت که فرم تماس را با نام محصول پر میکند، JSON-LD نوع Product). بخشهایی که پر نشدهاند در صفحه نمایش داده نمیشوند.
|
||||
- **دستهبندیها**: کاور، زیرگروهها (فیلترهای صفحهی دسته)، سئو، ترتیب نمایش.
|
||||
- **بخشهای سایت**: Hero، نوار متحرک، آمار، معرفی محصولات، درباره ما، خدمات، استانداردها، صنایع، نظرات، تماس، فوتر و تنظیمات سایت (تلفن، ایمیل، نشانی، ساعات کاری، شبکههای اجتماعی، سئوی پیشفرض).
|
||||
- **پیامهای تماس**: درخواستهای فرم مشاورهی سایت، با وضعیت جدید / خواندهشده / بایگانی.
|
||||
- **کتابخانه رسانه**: همهی تصاویر بهصورت خودکار بهینه و به WebP تبدیل میشوند. تصویری که جایی استفاده شده قابل حذف نیست.
|
||||
- **کاربران و نقشها**: «مدیر کل» به همهچیز دسترسی دارد. «ویرایشگر» بهجز کاربران و تنظیمات سایت، همهی بخشها را میبیند.
|
||||
|
||||
تغییرات پس از ذخیره، حداکثر ظرف چند ثانیه روی سایت دیده میشوند. اگر فراخوانی پاککردن کش به هر دلیلی نرسد، صفحهها در هر صورت هر ۶۰ ثانیه تازه میشوند.
|
||||
|
||||
**امنیت ورود**: access token کوتاهعمر (۱۵ دقیقه) فقط در حافظهی مرورگر نگه داشته میشود و refresh token در کوکی httpOnly است. refresh token در هر استفاده عوض میشود. اگر یک توکن باطلشده دوباره استفاده شود، همهی نشستهای آن کاربر بسته میشوند. رمزها با argon2 ذخیره میشوند و تعداد تلاش برای ورود و ارسال فرم تماس محدود است.
|
||||
|
||||
## راهنمای توسعه
|
||||
|
||||
**افزودن فیلد به یک بخش سایت** (مثلاً یک متن جدید در Hero):
|
||||
1. اسکیمای آن بخش را در `packages/shared/src/sections.ts` تغییر دهید. اگر فیلد اجباری است، مقدار پیشفرض بدهید (`text()`)، وگرنه دادههای فعلی دیتابیس دیگر معتبر نیستند.
|
||||
2. فیلد را به فرم همان بخش در `apps/admin/src/sections/` اضافه کنید.
|
||||
3. در کامپوننت سایت (`apps/web/app/components/sections/`) از آن استفاده کنید.
|
||||
4. مقدار اولیه را در `apps/api/src/seed/data/sections.ts` اضافه کنید.
|
||||
|
||||
یک اسکیمای Zod هم ورودی API را اعتبارسنجی میکند و هم فرم پنل را. برای همین پیامهای خطا در هر دو یکسان و فارسیاند.
|
||||
|
||||
**تغییر مدل دیتابیس**: `apps/api/prisma/schema.prisma` را ویرایش کنید و `pnpm db:migrate` را اجرا کنید. اسم migration را میپرسد. فایل migration را هم commit کنید.
|
||||
|
||||
**آیکونها**: آیکونهای قابلانتخاب در پنل در `packages/shared/src/icons.ts` تعریف شدهاند. برای افزودن آیکون جدید، آن را به همین لیست، به `apps/web/app/components/ui/DynamicIcon.vue` و به `apps/admin/src/components/icons.ts` اضافه کنید. این تکرار عمدی است تا فقط همین آیکونها وارد باندل شوند.
|
||||
|
||||
**Ant Design و Tailwind**: استایلهای Tailwind v4 داخل cascade layer هستند و CSS-in-JS آنتدیزاین بیرون از layer. بنابراین روی کامپوننتهای antd کلاس ظاهری Tailwind نگذارید. در سایت، پالت رنگ در دو جا تعریف شده است: `@theme` در `main.css` و `palette` در `app/design/theme.ts`. هنگام تغییر، هر دو را همگام کنید.
|
||||
|
||||
## استقرار (production)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # همهی رمزها و دامنهها را تنظیم کنید
|
||||
docker compose up -d --build
|
||||
docker compose exec api node dist/seed/seed.js # فقط بار اول
|
||||
```
|
||||
|
||||
- کانتینر `gateway` روی پورت `HTTP_PORT` (پیشفرض 8080) گوش میدهد و هر دو دامنه (`SITE_DOMAIN` و `ADMIN_DOMAIN`) را سرو میکند. TLS را nginx یا Traefik روی سرور یا CDN جلوی آن تأمین میکند. هر دو دامنه باید به سرور اشاره کنند.
|
||||
- سرویس یکبارهی `migrate` پیش از `api` اجرا میشود، migrationهای جدید را اعمال میکند و خارج میشود. Prisma CLI فقط در همین image هست و image اجرایی API سبک میماند.
|
||||
- نام پروژهی Compose ثابت و برابر `imen` است، تا با `docker-compose.dev.yml` (PostgreSQL و MinIO توسعه) هیچ کانتینر یا volume مشترکی نداشته باشد.
|
||||
- سرویس `backup` هر روز یک dump از دیتابیس در `./backups` میسازد و dumpهای قدیمیتر از ۱۴ روز را پاک میکند. بازگردانی:
|
||||
`docker compose exec -T postgres pg_restore -U imen -d imen --clean < backups/imen-YYYY-MM-DD.dump`
|
||||
- از فایلهای MinIO (volume `miniodata`) هم جداگانه بکاپ بگیرید.
|
||||
|
||||
**اگر سرور به رجیستری npm دسترسی ندارد**، دو راه هست:
|
||||
1. در `.env` مقدار `NPM_REGISTRY` را روی یک mirror در دسترس بگذارید.
|
||||
2. imageها را روی سیستم توسعه بسازید و به سرور منتقل کنید:
|
||||
```bash
|
||||
docker compose build
|
||||
docker save imen-api imen-migrate imen-web imen-admin | gzip > imen-images.tar.gz
|
||||
# روی سرور:
|
||||
gunzip -c imen-images.tar.gz | docker load && docker compose up -d
|
||||
```
|
||||
|
||||
**Object Storage ابرآروان (یا هر S3 دیگر) بهجای MinIO**: در سرویس `api`، مقادیر `S3_ENDPOINT`، `S3_BUCKET` و کلیدها را تنظیم کنید و `S3_ENSURE_BUCKET` را `false` بگذارید. `MEDIA_PUBLIC_URL` را روی آدرس عمومی باکت یا CDN بگذارید. بعد از آن به سرویس `minio` و مسیر `/media` در gateway نیازی نیست.
|
||||
|
||||
## کارهای باقیمانده و پیشنهادها
|
||||
|
||||
- **پر کردن جزئیات محصولات** (توضیحات کامل، مشخصات فنی، استانداردها و ...) از پنل؛ این فیلدها برای محصولات فعلی خالیاند و فعلاً صفحهی محصول توضیح دسته را نشان میدهد.
|
||||
- عکس برای ۱۷ محصول پوشاک کار و تجهیزات ترافیک (فعلاً کاور دسته نمایش داده میشود).
|
||||
- **ایمپورت/اکسپورت اکسل** محصولات برای ورود دستهجمعی.
|
||||
- **اعلان پیام تماس جدید** با ایمیل، پیامک یا ربات بله/تلگرام.
|
||||
- پوشهی `محصولات/` (عکسها و فایلهای Word خام) و فایلهای `_dev.log` و `landing-nextjs-seo.skill` بهتر است از ریپو خارج شوند.
|
||||
- لوگوی رسمی (`apps/web/public/images/logo.png`)، تصویر اختصاصی Open Graph با ابعاد ۱۲۰۰×۶۳۰ و آیکونهای PNG برای PWA.
|
||||
- CI روی سرور git (`pnpm typecheck && pnpm build && pnpm test`).
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>پنل مدیریت ایمن پیشگامان</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@imen/admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"typecheck": "vue-tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@imen/shared": "workspace:*",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"lucide-vue-next": "^0.475.0",
|
||||
"ofetch": "^1.5.1",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.43",
|
||||
"vue-router": "^5.3.1",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/node": "^24.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.3.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='#FF5A00'/><path d='M50 20 L72 28 V48 C72 66 62 76 50 82 C38 76 28 66 28 48 V28 Z' fill='#0A0A0C'/><path d='M40 50 L47 57 L62 42' stroke='#FF5A00' stroke-width='7' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>
|
||||
|
After Width: | Height: | Size: 337 B |
|
After Width: | Height: | Size: 279 KiB |
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { theme } from "ant-design-vue";
|
||||
import faIR from "ant-design-vue/es/locale/fa_IR";
|
||||
|
||||
const antdTheme = {
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#ff5a00",
|
||||
colorInfo: "#ff5a00",
|
||||
colorLink: "#e04f00",
|
||||
fontFamily: '"IRANSans", Tahoma, sans-serif',
|
||||
borderRadius: 8,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdTheme" direction="rtl" :locale="faIR">
|
||||
<RouterView />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { FetchError, type FetchOptions, ofetch } from "ofetch";
|
||||
import type { ValidationIssue } from "@imen/shared";
|
||||
|
||||
/** Raw client without auth handling (login / refresh / logout use it directly) */
|
||||
export const rawApi = ofetch.create({ baseURL: "/api", credentials: "include" });
|
||||
|
||||
type AuthHooks = {
|
||||
token: () => string;
|
||||
refresh: () => Promise<boolean>;
|
||||
onExpired: () => void;
|
||||
};
|
||||
let hooks: AuthHooks | null = null;
|
||||
|
||||
/** Wired by the auth store, which owns the access token */
|
||||
export const setAuthHooks = (h: AuthHooks) => {
|
||||
hooks = h;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticated request. On 401 it refreshes the access token once (using the httpOnly
|
||||
* refresh cookie) and retries; if that fails the session is over.
|
||||
*/
|
||||
export async function api<T>(url: string, opts: FetchOptions<"json"> = {}, retried = false): Promise<T> {
|
||||
const token = hooks?.token();
|
||||
try {
|
||||
return await rawApi<T>(url, {
|
||||
...opts,
|
||||
headers: { ...(opts.headers as Record<string, string>), ...(token && { Authorization: `Bearer ${token}` }) },
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof FetchError && e.status === 401 && !retried && hooks) {
|
||||
if (await hooks.refresh()) return api<T>(url, opts, true);
|
||||
hooks.onExpired();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiErrorBody {
|
||||
message?: string | string[];
|
||||
issues?: ValidationIssue[];
|
||||
}
|
||||
|
||||
export const errorBody = (e: unknown): ApiErrorBody =>
|
||||
e instanceof FetchError ? ((e.data as ApiErrorBody | undefined) ?? {}) : {};
|
||||
|
||||
/** Human-readable (Persian) error message for notifications */
|
||||
export function errorMessage(e: unknown): string {
|
||||
if (e instanceof FetchError) {
|
||||
if (!e.status) return "ارتباط با سرور برقرار نشد";
|
||||
const msg = errorBody(e).message;
|
||||
if (Array.isArray(msg)) return msg.join("، ");
|
||||
if (msg) return msg;
|
||||
if (e.status === 413) return "حجم فایل بیش از حد مجاز است";
|
||||
if (e.status === 429) return "تعداد درخواستها زیاد است؛ کمی بعد تلاش کنید";
|
||||
return `خطای سرور (${e.status})`;
|
||||
}
|
||||
return "خطای ناشناخته";
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { MediaDto, ProductAttribute, SectionKey, SiteSections } from "@imen/shared";
|
||||
|
||||
/** Shapes returned by the admin endpoints of the API */
|
||||
|
||||
export interface AdminCategory {
|
||||
id: number;
|
||||
slug: string;
|
||||
code: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
shortDesc: string;
|
||||
fullDesc: string;
|
||||
icon: string;
|
||||
coverImageId: number | null;
|
||||
coverImage: MediaDto | null;
|
||||
sortOrder: number;
|
||||
isPublished: boolean;
|
||||
seoTitle: string;
|
||||
seoDescription: string;
|
||||
productCount: number;
|
||||
groupCount: number;
|
||||
}
|
||||
|
||||
export interface AdminProductGroup {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
sortOrder: number;
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
export interface AdminCategoryDetail extends Omit<AdminCategory, "productCount" | "groupCount"> {
|
||||
groups: AdminProductGroup[];
|
||||
}
|
||||
|
||||
export interface AdminProduct {
|
||||
id: number;
|
||||
categoryId: number;
|
||||
groupId: number | null;
|
||||
code: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
desc: string;
|
||||
specs: string[];
|
||||
variants: string[];
|
||||
details: string;
|
||||
brand: string;
|
||||
origin: string;
|
||||
sizes: string[];
|
||||
standards: string[];
|
||||
features: string[];
|
||||
applications: string[];
|
||||
attributes: ProductAttribute[] | null;
|
||||
packaging: string;
|
||||
minOrder: string;
|
||||
seoTitle: string;
|
||||
seoDescription: string;
|
||||
imageId: number | null;
|
||||
image: MediaDto | null;
|
||||
sortOrder: number;
|
||||
isPublished: boolean;
|
||||
category: { id: number; name: string };
|
||||
group: { id: number; name: string } | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminProductDetail extends Omit<AdminProduct, "category" | "group"> {
|
||||
gallery: MediaDto[];
|
||||
}
|
||||
|
||||
export interface SectionResponse<K extends SectionKey = SectionKey> {
|
||||
key: K;
|
||||
data: SiteSections[K];
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Page shell inside the white panel:
|
||||
* - optional sticky head (toolbar / filters)
|
||||
* - body: scrolls by default, or locks height when `lockBody` (table fills + scrolls internally)
|
||||
*/
|
||||
defineProps<{ lockBody?: boolean }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ap">
|
||||
<header v-if="$slots.head" class="ap__head">
|
||||
<slot name="head" />
|
||||
</header>
|
||||
<div class="ap__body" :class="{ 'ap__body--locked': lockBody }">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ap__head {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.ap__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ap__body--locked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from "vue";
|
||||
import { FORM_ERRORS } from "@/composables/useZodForm";
|
||||
|
||||
/** a-form-item that shows the zod error for `path` (provided by useZodForm) */
|
||||
const props = defineProps<{ path: string; label?: string; hint?: string; required?: boolean }>();
|
||||
|
||||
const errors = inject(FORM_ERRORS, null);
|
||||
const error = computed(() => errors?.value[props.path]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-form-item
|
||||
:label="label"
|
||||
:required="required"
|
||||
:validate-status="error ? 'error' : undefined"
|
||||
:help="error || hint"
|
||||
>
|
||||
<slot />
|
||||
</a-form-item>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { ICON_NAMES, type IconName } from "@imen/shared";
|
||||
import { computed } from "vue";
|
||||
import { ICONS } from "./icons";
|
||||
|
||||
/** Picks one of the Lucide icons the website knows how to render */
|
||||
const value = defineModel<string>({ required: true });
|
||||
defineProps<{ allowClear?: boolean }>();
|
||||
|
||||
const options = ICON_NAMES.map((name) => ({ value: name, label: name }));
|
||||
const current = computed(() => ICONS[value.value as IconName]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex size-8 shrink-0 items-center justify-center rounded-md border border-gray-200 text-[#ff5a00]">
|
||||
<component :is="current" v-if="current" class="size-4" />
|
||||
</span>
|
||||
<a-select
|
||||
v-model:value="value"
|
||||
class="min-w-0 flex-1"
|
||||
show-search
|
||||
:allow-clear="allowClear"
|
||||
:options="options"
|
||||
placeholder="انتخاب آیکون"
|
||||
>
|
||||
<template #option="{ value: name }">
|
||||
<span class="flex items-center gap-2" dir="ltr">
|
||||
<component :is="ICONS[name as IconName]" class="size-4" />
|
||||
{{ name }}
|
||||
</span>
|
||||
</template>
|
||||
</a-select>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import type { MediaDto } from "@imen/shared";
|
||||
import { ref } from "vue";
|
||||
import MediaPicker from "./MediaPicker.vue";
|
||||
|
||||
/** Image preview with "choose" / "remove" buttons; the parent decides what to store */
|
||||
defineProps<{ url?: string | null; clearable?: boolean }>();
|
||||
const emit = defineEmits<{ pick: [media: MediaDto]; clear: [] }>();
|
||||
|
||||
const open = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex size-28 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-dashed border-gray-300 bg-gray-50"
|
||||
>
|
||||
<img v-if="url" :src="url" alt="" class="size-full object-contain" />
|
||||
<VuesaxIcon v-else name="gallery" :size="32" class="text-gray-300" />
|
||||
</div>
|
||||
<a-space direction="vertical">
|
||||
<a-button @click="open = true">
|
||||
<template #icon><VuesaxIcon name="gallery" :size="16" /></template>
|
||||
{{ url ? "تغییر تصویر" : "انتخاب یا آپلود تصویر" }}
|
||||
</a-button>
|
||||
<a-button v-if="url && clearable" danger @click="emit('clear')">
|
||||
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
|
||||
حذف تصویر
|
||||
</a-button>
|
||||
</a-space>
|
||||
<MediaPicker v-model:open="open" @select="emit('pick', $event)" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageRef } from "@imen/shared";
|
||||
import FormField from "./FormField.vue";
|
||||
import ImageField from "./ImageField.vue";
|
||||
|
||||
/** Edits an { url, alt } image stored inside section content */
|
||||
const image = defineModel<ImageRef>({ required: true });
|
||||
defineProps<{ path: string; label: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField :path="`${path}.url`" :label="label" required>
|
||||
<ImageField
|
||||
:url="image.url"
|
||||
@pick="(m) => (image = { url: m.url, alt: image.alt || m.alt })"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField :path="`${path}.alt`" label="متن جایگزین تصویر (alt)" hint="برای سئو و دسترسیپذیری؛ تصویر را در یک جمله توصیف کنید">
|
||||
<a-input v-model:value="image.alt" />
|
||||
</FormField>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
/** Textarea where every non-empty line is one list item (for longer items than tags allow) */
|
||||
const items = defineModel<string[]>({ required: true });
|
||||
defineProps<{ placeholder?: string; rows?: number }>();
|
||||
|
||||
const parse = (text: string) =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// keep the raw text locally so blank lines and trailing newlines survive while typing
|
||||
const text = ref(items.value.join("\n"));
|
||||
watch(items, (v) => {
|
||||
if (parse(text.value).join("\n") !== v.join("\n")) text.value = v.join("\n");
|
||||
});
|
||||
const onInput = () => {
|
||||
items.value = parse(text.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-textarea
|
||||
v-model:value="text"
|
||||
:placeholder="placeholder"
|
||||
:auto-size="{ minRows: rows ?? 3, maxRows: 14 }"
|
||||
@change="onInput"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
|
||||
/** Editable list: add, remove and reorder items; each item is rendered by the default slot */
|
||||
const items = defineModel<T[]>({ required: true });
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
create: () => T;
|
||||
addLabel?: string;
|
||||
max?: number;
|
||||
/** title shown on each card, e.g. (item, i) => item.title */
|
||||
itemTitle?: (item: T, index: number) => string;
|
||||
}>(),
|
||||
{ addLabel: "افزودن مورد", max: 50 },
|
||||
);
|
||||
|
||||
const move = (i: number, d: -1 | 1) => {
|
||||
const list = [...items.value];
|
||||
const [it] = list.splice(i, 1);
|
||||
list.splice(i + d, 0, it as T);
|
||||
items.value = list;
|
||||
};
|
||||
const remove = (i: number) => {
|
||||
items.value = items.value.filter((_, j) => j !== i);
|
||||
};
|
||||
const add = () => {
|
||||
items.value = [...items.value, props.create()];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-bold text-gray-600">
|
||||
{{ (i + 1).toLocaleString("fa-IR") }}. {{ itemTitle?.(item, i) ?? "" }}
|
||||
</span>
|
||||
<a-space :size="4">
|
||||
<a-button size="small" :disabled="i === 0" title="بالا" @click="move(i, -1)">
|
||||
<template #icon><VuesaxIcon name="arrow-up" :size="16" /></template>
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="i === items.length - 1" title="پایین" @click="move(i, 1)">
|
||||
<template #icon><VuesaxIcon name="arrow-down" :size="16" /></template>
|
||||
</a-button>
|
||||
<a-popconfirm title="این مورد حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(i)">
|
||||
<a-button size="small" danger title="حذف">
|
||||
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</div>
|
||||
<slot :item="item" :index="i" />
|
||||
</div>
|
||||
<a-button type="dashed" block :disabled="items.length >= max" @click="add">
|
||||
<template #icon><VuesaxIcon name="add" :size="16" /></template>
|
||||
{{ addLabel }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import type { MediaDto, Paginated } from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { ref, watch } from "vue";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
|
||||
/** Modal to upload a new image or pick one from the media library */
|
||||
const open = defineModel<boolean>("open", { required: true });
|
||||
const emit = defineEmits<{ select: [media: MediaDto] }>();
|
||||
|
||||
const tab = ref<"library" | "upload">("library");
|
||||
const items = ref<MediaDto[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const q = ref("");
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api<Paginated<MediaDto>>("/admin/media", {
|
||||
query: { page: page.value, pageSize: PAGE_SIZE, q: q.value || undefined },
|
||||
});
|
||||
items.value = res.items;
|
||||
total.value = res.total;
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (v) => v && load());
|
||||
|
||||
function choose(m: MediaDto) {
|
||||
emit("select", m);
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
/** Upload, then return the new file straight to the caller */
|
||||
async function upload(file: File) {
|
||||
uploading.value = true;
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const media = await api<MediaDto>("/admin/media", { method: "POST", body });
|
||||
void message.success("تصویر آپلود شد");
|
||||
choose(media);
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
return false; // stop a-upload's own request
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal v-model:open="open" title="انتخاب تصویر" :footer="null" width="900px" destroy-on-close>
|
||||
<a-tabs v-model:active-key="tab">
|
||||
<a-tab-pane key="library" tab="کتابخانه رسانه">
|
||||
<a-input-search
|
||||
v-model:value="q"
|
||||
placeholder="جستجو در نام فایل یا متن جایگزین"
|
||||
allow-clear
|
||||
class="mb-4"
|
||||
@search="() => { page = 1; load(); }"
|
||||
/>
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty v-if="!items.length && !loading" description="تصویری پیدا نشد" />
|
||||
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6">
|
||||
<button
|
||||
v-for="m in items"
|
||||
:key="m.id"
|
||||
type="button"
|
||||
class="group overflow-hidden rounded-lg border border-gray-200 bg-gray-50 text-start transition hover:border-[#ff5a00] focus:border-[#ff5a00] focus:outline-none"
|
||||
:title="m.originalName"
|
||||
@click="choose(m)"
|
||||
>
|
||||
<img :src="m.url" :alt="m.alt" class="aspect-square w-full object-contain" loading="lazy" />
|
||||
<div class="truncate px-1.5 py-1 text-[11px] text-gray-500" dir="ltr">{{ m.originalName }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</a-spin>
|
||||
<div class="mt-4 flex justify-center">
|
||||
<a-pagination
|
||||
v-model:current="page"
|
||||
:total="total"
|
||||
:page-size="PAGE_SIZE"
|
||||
:show-size-changer="false"
|
||||
@change="load"
|
||||
/>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="upload" tab="آپلود تصویر جدید">
|
||||
<a-spin :spinning="uploading">
|
||||
<a-upload-dragger
|
||||
accept="image/jpeg,image/png,image/webp,image/avif,image/gif"
|
||||
:show-upload-list="false"
|
||||
:before-upload="upload"
|
||||
>
|
||||
<p class="ant-upload-drag-icon"><VuesaxIcon name="document-upload" :size="40" /></p>
|
||||
<p class="ant-upload-text">تصویر را اینجا رها کنید یا کلیک کنید</p>
|
||||
<p class="ant-upload-hint">
|
||||
JPG، PNG، WebP، AVIF یا GIF — حداکثر ۱۵ مگابایت. تصویر بهصورت خودکار بهینه و به WebP تبدیل میشود.
|
||||
</p>
|
||||
</a-upload-dragger>
|
||||
</a-spin>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; subtitle?: string; back?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="page-header__main">
|
||||
<RouterLink v-if="back" :to="back" class="page-header__back" title="بازگشت">→</RouterLink>
|
||||
<div class="min-w-0">
|
||||
<h1 class="page-header__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.default" class="page-header__actions">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header__back {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.page-header__back:hover {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.page-header__subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionHeadData } from "@imen/shared";
|
||||
import FormField from "./FormField.vue";
|
||||
|
||||
/** Tag / title / highlight / description fields shared by most sections */
|
||||
const head = defineModel<SectionHeadData>({ required: true });
|
||||
withDefaults(defineProps<{ path?: string; withHighlight?: boolean; withDesc?: boolean }>(), {
|
||||
path: "head",
|
||||
withHighlight: true,
|
||||
withDesc: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`${path}.tag`" label="برچسب بالای عنوان" hint="مثلاً «۰۱ — محصولات»" required>
|
||||
<a-input v-model:value="head.tag" />
|
||||
</FormField>
|
||||
<FormField :path="`${path}.title`" label="عنوان" hint="برای شکستن خط، Enter بزنید" required>
|
||||
<a-textarea v-model:value="head.title" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</FormField>
|
||||
<FormField v-if="withHighlight" :path="`${path}.highlight`" label="خط رنگی انتهای عنوان (اختیاری)">
|
||||
<a-input v-model:value="head.highlight" />
|
||||
</FormField>
|
||||
<FormField v-if="withDesc" :path="`${path}.desc`" label="توضیح زیر عنوان" class="md:col-span-2">
|
||||
<a-textarea v-model:value="head.desc" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import icons from "@/icons/vuesax-linear.json";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Short name (`add`) or full key (`vuesax-linear:add`) */
|
||||
name: string;
|
||||
size?: number | string;
|
||||
}>(),
|
||||
{ size: 18 },
|
||||
);
|
||||
|
||||
const pack = icons as Record<string, string>;
|
||||
|
||||
const svgHtml = computed(() => {
|
||||
const key = props.name.startsWith("vuesax-linear:")
|
||||
? props.name
|
||||
: `vuesax-linear:${props.name}`;
|
||||
const raw = pack[key];
|
||||
if (!raw) return "";
|
||||
return raw
|
||||
.replace(/\swidth="24"/, "")
|
||||
.replace(/\sheight="24"/, "")
|
||||
.replace(/stroke="#[A-Fa-f0-9]{3,8}"/g, 'stroke="currentColor"')
|
||||
.replace(/fill="(?!none)#[A-Fa-f0-9]{3,8}"/g, 'fill="currentColor"');
|
||||
});
|
||||
|
||||
const box = computed(() =>
|
||||
typeof props.size === "number" ? `${props.size}px` : props.size,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="vx-icon"
|
||||
:style="{ width: box, height: box }"
|
||||
:aria-hidden="true"
|
||||
v-html="svgHtml"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vx-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vx-icon :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { IconName } from "@imen/shared";
|
||||
import {
|
||||
Award,
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
ClipboardCheck,
|
||||
Clock,
|
||||
Construction,
|
||||
Ear,
|
||||
Factory,
|
||||
FileCheck,
|
||||
FileSearch,
|
||||
Flame,
|
||||
Footprints,
|
||||
Fuel,
|
||||
Gauge,
|
||||
Glasses,
|
||||
Globe,
|
||||
GraduationCap,
|
||||
Hand,
|
||||
Handshake,
|
||||
HardHat,
|
||||
Headset,
|
||||
HeartPulse,
|
||||
Leaf,
|
||||
Lock,
|
||||
Mail,
|
||||
MapPin,
|
||||
Medal,
|
||||
Microscope,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Phone,
|
||||
Pickaxe,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldPlus,
|
||||
Shirt,
|
||||
Siren,
|
||||
Star,
|
||||
Target,
|
||||
ThumbsUp,
|
||||
TrafficCone,
|
||||
TriangleAlert,
|
||||
Truck,
|
||||
Users,
|
||||
Warehouse,
|
||||
Wind,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-vue-next";
|
||||
import type { Component } from "vue";
|
||||
|
||||
/** Same icon set as the website's DynamicIcon (ICON_NAMES in @imen/shared) */
|
||||
export const ICONS: Record<IconName, Component> = {
|
||||
ShieldCheck,
|
||||
ShieldPlus,
|
||||
ShieldAlert,
|
||||
BadgeCheck,
|
||||
Award,
|
||||
Medal,
|
||||
PackageCheck,
|
||||
Package,
|
||||
Truck,
|
||||
Warehouse,
|
||||
Globe,
|
||||
Users,
|
||||
Handshake,
|
||||
Headset,
|
||||
ClipboardCheck,
|
||||
FileCheck,
|
||||
FileSearch,
|
||||
GraduationCap,
|
||||
Gauge,
|
||||
Microscope,
|
||||
Wrench,
|
||||
HardHat,
|
||||
Shirt,
|
||||
Footprints,
|
||||
Hand,
|
||||
Glasses,
|
||||
Ear,
|
||||
Wind,
|
||||
TriangleAlert,
|
||||
Siren,
|
||||
TrafficCone,
|
||||
Construction,
|
||||
Factory,
|
||||
Building2,
|
||||
Fuel,
|
||||
Pickaxe,
|
||||
Flame,
|
||||
Zap,
|
||||
Leaf,
|
||||
HeartPulse,
|
||||
Clock,
|
||||
Phone,
|
||||
Mail,
|
||||
MapPin,
|
||||
Star,
|
||||
ThumbsUp,
|
||||
Target,
|
||||
Lock,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { h, type VNode } from "vue";
|
||||
import VuesaxIcon from "./VuesaxIcon.vue";
|
||||
|
||||
/** Render a Vuesax linear icon (for Ant menu `icon` factories, etc.) */
|
||||
export const vx = (name: string, size = 18): VNode => h(VuesaxIcon, { name, size });
|
||||
@@ -0,0 +1,47 @@
|
||||
import { toIssues, type ValidationIssue } from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { type InjectionKey, provide, type Ref, ref } from "vue";
|
||||
import type { z } from "zod";
|
||||
import { errorBody, errorMessage } from "@/api/http";
|
||||
|
||||
/** Field errors keyed by dot path ("items.2.title"), read by <FormField> */
|
||||
export const FORM_ERRORS: InjectionKey<Ref<Record<string, string>>> = Symbol("form-errors");
|
||||
|
||||
const toMap = (issues: ValidationIssue[]) =>
|
||||
Object.fromEntries(issues.map((i) => [i.path, i.message]));
|
||||
|
||||
/**
|
||||
* Form state validated with the same zod schema the API uses.
|
||||
* The model is typed as the schema output (every field present) since forms start fully filled.
|
||||
* `validate()` returns parsed data or null (and marks the invalid fields).
|
||||
*/
|
||||
export function useZodForm<S extends z.ZodType>(schema: S, initial: z.output<S>) {
|
||||
const model = ref(structuredClone(initial)) as Ref<z.output<S>>;
|
||||
const errors = ref<Record<string, string>>({});
|
||||
provide(FORM_ERRORS, errors);
|
||||
|
||||
function validate(): z.output<S> | null {
|
||||
const result = schema.safeParse(model.value);
|
||||
if (result.success) {
|
||||
errors.value = {};
|
||||
return result.data;
|
||||
}
|
||||
errors.value = toMap(toIssues(result.error));
|
||||
void message.error("لطفاً خطاهای فرم را برطرف کنید");
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Shows an API error; validation issues from the server are attached to their fields */
|
||||
function handleError(e: unknown) {
|
||||
const issues = errorBody(e).issues;
|
||||
if (issues?.length) errors.value = toMap(issues);
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
|
||||
function reset(value: z.output<S>) {
|
||||
model.value = structuredClone(value);
|
||||
errors.value = {};
|
||||
}
|
||||
|
||||
return { model, errors, validate, handleError, reset };
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
<script setup lang="ts">
|
||||
import { ROLE_LABELS } from "@imen/shared";
|
||||
import { computed, h, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { SITE_URL } from "@/utils/format";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const drawer = ref(false);
|
||||
|
||||
/** Icon badge box + Vuesax glyph (keeps pre-swap menu structure) */
|
||||
const menuIcon = (name: string) => () =>
|
||||
h("span", { class: "admin-menu__icon" }, [h(VuesaxIcon, { name, size: 16 })]);
|
||||
|
||||
const items = computed(() => [
|
||||
{ key: "dashboard", icon: menuIcon("element-3"), label: "داشبورد" },
|
||||
{ key: "products", icon: menuIcon("bag-2"), label: "محصولات" },
|
||||
{ key: "categories", icon: menuIcon("category"), label: "دستهبندیها" },
|
||||
{ key: "sections", icon: menuIcon("row-vertical"), label: "بخشهای سایت" },
|
||||
{ key: "messages", icon: menuIcon("sms"), label: "پیامهای تماس" },
|
||||
{ key: "media", icon: menuIcon("gallery"), label: "کتابخانه رسانه" },
|
||||
...(auth.isAdmin ? [{ key: "users", icon: menuIcon("profile-2user"), label: "کاربران" }] : []),
|
||||
]);
|
||||
|
||||
/** menu entry to highlight for nested pages (products/12 → products) */
|
||||
const MENU_OF: Record<string, string> = {
|
||||
"product-new": "products",
|
||||
"product-edit": "products",
|
||||
"category-new": "categories",
|
||||
"category-edit": "categories",
|
||||
"section-edit": "sections",
|
||||
};
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
dashboard: "داشبورد",
|
||||
products: "محصولات",
|
||||
"product-new": "محصول جدید",
|
||||
"product-edit": "ویرایش محصول",
|
||||
categories: "دستهبندیها",
|
||||
"category-new": "دستهبندی جدید",
|
||||
"category-edit": "ویرایش دستهبندی",
|
||||
sections: "بخشهای سایت",
|
||||
"section-edit": "ویرایش بخش",
|
||||
messages: "پیامهای تماس",
|
||||
media: "کتابخانه رسانه",
|
||||
users: "کاربران",
|
||||
profile: "حساب کاربری",
|
||||
};
|
||||
|
||||
const selected = computed(() => {
|
||||
const name = String(route.name ?? "");
|
||||
return [MENU_OF[name] ?? name];
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => PAGE_TITLES[String(route.name ?? "")] ?? "پنل مدیریت");
|
||||
const userInitial = computed(() => (auth.user?.name?.trim().charAt(0) || "ک").slice(0, 1));
|
||||
|
||||
const go = ({ key }: { key: string | number }) => {
|
||||
drawer.value = false;
|
||||
void router.push({ name: String(key) });
|
||||
};
|
||||
|
||||
watch(() => route.fullPath, () => (drawer.value = false));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout class="admin-shell">
|
||||
<a-layout-sider
|
||||
:width="248"
|
||||
breakpoint="lg"
|
||||
collapsed-width="0"
|
||||
:trigger="null"
|
||||
class="admin-sider !hidden lg:!block"
|
||||
>
|
||||
<div class="admin-sider__brand">
|
||||
<span class="admin-sider__logo">
|
||||
<img src="/logo.png" alt="" class="size-7 object-contain" />
|
||||
</span>
|
||||
<div class="leading-tight">
|
||||
<div class="text-[14px] font-bold text-[#0f172a]">ایمن پیشگامان</div>
|
||||
<div class="text-[10px] font-medium tracking-wide text-[#94a3b8]">پنل مدیریت</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-sider__nav">
|
||||
<div class="admin-sider__label">منوی اصلی</div>
|
||||
<a-menu
|
||||
class="admin-menu"
|
||||
mode="inline"
|
||||
:selected-keys="selected"
|
||||
:items="items"
|
||||
@click="go"
|
||||
/>
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-drawer
|
||||
v-model:open="drawer"
|
||||
placement="right"
|
||||
:width="280"
|
||||
:closable="false"
|
||||
class="admin-drawer"
|
||||
:body-style="{ padding: 0, background: '#ffffff' }"
|
||||
>
|
||||
<div class="admin-sider__brand">
|
||||
<span class="admin-sider__logo">
|
||||
<img src="/logo.png" alt="" class="size-7 object-contain" />
|
||||
</span>
|
||||
<div class="leading-tight">
|
||||
<div class="text-[14px] font-bold text-[#0f172a]">ایمن پیشگامان</div>
|
||||
<div class="text-[10px] font-medium text-[#94a3b8]">پنل مدیریت</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-sider__nav">
|
||||
<div class="admin-sider__label">منوی اصلی</div>
|
||||
<a-menu
|
||||
class="admin-menu"
|
||||
mode="inline"
|
||||
:selected-keys="selected"
|
||||
:items="items"
|
||||
@click="go"
|
||||
/>
|
||||
</div>
|
||||
</a-drawer>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-header class="admin-header">
|
||||
<div class="admin-header__start">
|
||||
<a-button class="admin-header__menu-btn lg:!hidden" @click="drawer = true">
|
||||
<template #icon><VuesaxIcon name="menu" :size="18" /></template>
|
||||
</a-button>
|
||||
|
||||
<div class="admin-header__crumb">
|
||||
<span class="admin-header__crumb-kicker">پنل مدیریت</span>
|
||||
<span class="admin-header__crumb-sep" aria-hidden="true">/</span>
|
||||
<span class="admin-header__crumb-page">{{ pageTitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-header__end">
|
||||
<a
|
||||
:href="SITE_URL"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="admin-header__site"
|
||||
>
|
||||
<VuesaxIcon name="export" :size="16" />
|
||||
<span>مشاهده سایت</span>
|
||||
</a>
|
||||
|
||||
<div class="admin-header__divider" aria-hidden="true" />
|
||||
|
||||
<a-dropdown placement="bottomLeft" :trigger="['click']">
|
||||
<button type="button" class="admin-header__user">
|
||||
<span class="admin-header__avatar">{{ userInitial }}</span>
|
||||
<span class="admin-header__user-meta">
|
||||
<span class="admin-header__user-name">{{ auth.user?.name }}</span>
|
||||
<span v-if="auth.user" class="admin-header__user-role">
|
||||
{{ ROLE_LABELS[auth.user.role] }}
|
||||
</span>
|
||||
</span>
|
||||
<VuesaxIcon name="arrow-down" :size="12" class="admin-header__chevron" />
|
||||
</button>
|
||||
<template #overlay>
|
||||
<a-menu class="admin-header__dropdown">
|
||||
<a-menu-item key="profile" @click="router.push({ name: 'profile' })">
|
||||
<VuesaxIcon name="user" :size="16" /> حساب کاربری
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout" danger @click="auth.logout()">
|
||||
<VuesaxIcon name="logout" :size="16" /> خروج
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout-content class="admin-content">
|
||||
<div class="admin-page">
|
||||
<RouterView v-slot="{ Component, route: r }">
|
||||
<component :is="Component" :key="r.path" />
|
||||
</RouterView>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-shell {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-shell > :deep(.ant-layout) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
height: 64px !important;
|
||||
line-height: 1.3 !important;
|
||||
padding-inline: 16px 20px !important;
|
||||
background: rgba(255, 255, 255, 0.96) !important;
|
||||
border-bottom: 1px solid #e8ecf2;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
background: #f5f6fa;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* White panel locked to remaining viewport; pages manage their own inner scroll */
|
||||
.admin-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e8ecf2;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.admin-page > :deep(*) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Pages that don't use AdminPage still scroll inside the panel */
|
||||
.admin-page > :deep(*:not(.ap)) {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.admin-header {
|
||||
padding-inline: 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-header__start,
|
||||
.admin-header__end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-header__menu-btn {
|
||||
border-radius: 10px !important;
|
||||
border-color: #e2e8f0 !important;
|
||||
color: #475569 !important;
|
||||
}
|
||||
|
||||
.admin-header__crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-header__crumb-kicker {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.admin-header__crumb-kicker {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-header__crumb-sep {
|
||||
display: none;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.admin-header__crumb-sep {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-header__crumb-page {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.admin-header__site {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding-inline: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.admin-header__site:hover {
|
||||
color: #e04f00;
|
||||
border-color: #ffd0b5;
|
||||
background: #fff7f2;
|
||||
box-shadow: 0 2px 8px rgba(255, 90, 0, 0.08);
|
||||
}
|
||||
|
||||
.admin-header__divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: #e8ecf2;
|
||||
}
|
||||
|
||||
.admin-header__user {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 260px;
|
||||
height: 44px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 4px 10px;
|
||||
border: 1px solid #e8ecf2;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.admin-header__user:hover {
|
||||
background: #ffffff;
|
||||
border-color: #ffd0b5;
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.admin-header__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #ff5a00, #ff8a3d);
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(255, 90, 0, 0.28);
|
||||
}
|
||||
|
||||
.admin-header__user-meta {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.admin-header__user-meta {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-header__user-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.admin-header__user-role {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.admin-header__chevron {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.admin-sider {
|
||||
height: 100vh !important;
|
||||
height: 100dvh !important;
|
||||
max-height: 100vh !important;
|
||||
max-height: 100dvh !important;
|
||||
position: sticky !important;
|
||||
top: 0;
|
||||
background: #ffffff !important;
|
||||
/* content-facing edge (left in RTL) — continuous vertical rail */
|
||||
border-inline-end: 1px solid #e8ecf2;
|
||||
box-shadow: -4px 0 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.admin-sider :deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-sider__brand {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 64px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.admin-sider__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: #fff7f2;
|
||||
border: 1px solid #ffe0cc;
|
||||
box-shadow: 0 1px 2px rgba(255, 90, 0, 0.06);
|
||||
}
|
||||
|
||||
.admin-sider__nav {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 64px);
|
||||
height: calc(100dvh - 64px);
|
||||
padding: 16px 12px 20px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-sider__label {
|
||||
margin: 0 8px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.admin-menu {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.ant-menu-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 44px !important;
|
||||
margin: 0 0 6px !important;
|
||||
padding-inline: 12px !important;
|
||||
border: 1px solid transparent !important;
|
||||
border-radius: 12px !important;
|
||||
color: #475569 !important;
|
||||
font-weight: 500;
|
||||
line-height: 44px !important;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.ant-menu-item-icon),
|
||||
.admin-menu :deep(.ant-menu-item .anticon) {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
margin-inline-end: 0 !important;
|
||||
font-size: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.admin-menu__icon) {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-inline-end: 10px;
|
||||
border-radius: 9px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.admin-menu__icon .vx-icon) {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.ant-menu-item:hover) {
|
||||
background: #f8fafc !important;
|
||||
color: #0f172a !important;
|
||||
border-color: #eef1f6 !important;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.ant-menu-item:hover .admin-menu__icon) {
|
||||
background: #e8eef7;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.ant-menu-item-selected) {
|
||||
background: #fff7f2 !important;
|
||||
border-color: #ffd7bf !important;
|
||||
color: #c2410c !important;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 14px rgba(255, 90, 0, 0.1);
|
||||
}
|
||||
|
||||
.admin-menu :deep(.ant-menu-item-selected .admin-menu__icon) {
|
||||
background: #ff5a00 !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 4px 10px rgba(255, 90, 0, 0.32);
|
||||
}
|
||||
|
||||
.admin-menu :deep(.ant-menu-item-selected::after) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import Antd from "ant-design-vue";
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import { z } from "zod";
|
||||
import App from "./App.vue";
|
||||
import { router } from "./router";
|
||||
import "./style.css";
|
||||
|
||||
// Persian default validation messages (custom messages in @imen/shared still win)
|
||||
z.config(z.locales.fa());
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(Antd).mount("#app");
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { message } from "ant-design-vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import type { AdminCategory } from "@/api/types";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { faNumber, SITE_URL } from "@/utils/format";
|
||||
|
||||
const rows = ref<AdminCategory[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = await api<AdminCategory[]>("/admin/categories");
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function move(index: number, d: -1 | 1) {
|
||||
const list = [...rows.value];
|
||||
const [row] = list.splice(index, 1);
|
||||
list.splice(index + d, 0, row!);
|
||||
rows.value = list;
|
||||
try {
|
||||
await api("/admin/categories/reorder", { method: "POST", body: { ids: list.map((r) => r.id) } });
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: AdminCategory) {
|
||||
try {
|
||||
await api(`/admin/categories/${row.id}`, { method: "DELETE" });
|
||||
void message.success("دسته حذف شد");
|
||||
await load();
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: "تصویر", key: "cover", width: 80 },
|
||||
{ title: "نام", key: "name" },
|
||||
{ title: "کد", dataIndex: "code", width: 100 },
|
||||
{ title: "محصولات", key: "products", width: 100 },
|
||||
{ title: "وضعیت", key: "status", width: 110 },
|
||||
{ title: "ترتیب", key: "order", width: 100 },
|
||||
{ title: "", key: "actions", width: 170 },
|
||||
];
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="دستهبندیها" subtitle="ترتیب دستهها همان ترتیب نمایش در سایت است">
|
||||
<RouterLink :to="{ name: 'category-new' }">
|
||||
<a-button type="primary">
|
||||
<template #icon><VuesaxIcon name="add" :size="16" /></template>
|
||||
دسته جدید
|
||||
</a-button>
|
||||
</RouterLink>
|
||||
</PageHeader>
|
||||
|
||||
<a-card :body-style="{ padding: 0 }">
|
||||
<a-table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 800 }">
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'cover'">
|
||||
<img v-if="record.coverImage" :src="record.coverImage.url" alt="" class="size-12 rounded object-cover" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'name'">
|
||||
<RouterLink :to="{ name: 'category-edit', params: { id: record.id } }" class="font-bold">
|
||||
{{ record.name }}
|
||||
</RouterLink>
|
||||
<div class="text-xs text-gray-400" dir="ltr">{{ record.slug }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'products'">
|
||||
<RouterLink :to="{ name: 'products', query: { categoryId: record.id } }">
|
||||
{{ faNumber(record.productCount) }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.isPublished ? 'green' : 'default'">
|
||||
{{ record.isPublished ? "منتشرشده" : "مخفی" }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'order'">
|
||||
<a-space :size="4">
|
||||
<a-button size="small" :disabled="index === 0" @click="move(index, -1)">
|
||||
<template #icon><VuesaxIcon name="arrow-up" :size="16" /></template>
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="index === rows.length - 1" @click="move(index, 1)">
|
||||
<template #icon><VuesaxIcon name="arrow-down" :size="16" /></template>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space :size="4">
|
||||
<RouterLink :to="{ name: 'category-edit', params: { id: record.id } }">
|
||||
<a-button size="small"><template #icon><VuesaxIcon name="edit-2" :size="16" /></template></a-button>
|
||||
</RouterLink>
|
||||
<a :href="`${SITE_URL}/categories/${record.slug}`" target="_blank" rel="noopener">
|
||||
<a-button size="small" title="مشاهده در سایت"><template #icon><VuesaxIcon name="export" :size="16" /></template></a-button>
|
||||
</a>
|
||||
<a-popconfirm
|
||||
title="این دسته حذف شود؟"
|
||||
ok-text="حذف"
|
||||
cancel-text="انصراف"
|
||||
@confirm="remove(record as AdminCategory)"
|
||||
>
|
||||
<a-button size="small" danger :disabled="record.productCount > 0" :title="record.productCount > 0 ? 'ابتدا محصولات این دسته را حذف یا جابهجا کنید' : 'حذف'">
|
||||
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { categoryInputSchema, type MediaDto } from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { z } from "zod";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import type { AdminCategoryDetail } from "@/api/types";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import IconSelect from "@/components/IconSelect.vue";
|
||||
import ImageField from "@/components/ImageField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useZodForm } from "@/composables/useZodForm";
|
||||
import { toSlug } from "@/utils/slug";
|
||||
|
||||
type Form = z.output<typeof categoryInputSchema>;
|
||||
type Group = Form["groups"][number];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = computed(() => (route.params.id ? Number(route.params.id) : null));
|
||||
|
||||
const empty: Form = {
|
||||
slug: "",
|
||||
code: "",
|
||||
name: "",
|
||||
nameEn: "",
|
||||
shortDesc: "",
|
||||
fullDesc: "",
|
||||
icon: "",
|
||||
coverImageId: null,
|
||||
isPublished: true,
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
groups: [],
|
||||
};
|
||||
|
||||
const { model, validate, handleError, reset } = useZodForm(categoryInputSchema, empty);
|
||||
const cover = ref<MediaDto | null>(null);
|
||||
/** product count per existing group id, to warn before deleting a group */
|
||||
const groupProducts = ref<Record<number, number>>({});
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
async function load() {
|
||||
if (!id.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const c = await api<AdminCategoryDetail>(`/admin/categories/${id.value}`);
|
||||
reset({
|
||||
slug: c.slug,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
nameEn: c.nameEn,
|
||||
shortDesc: c.shortDesc,
|
||||
fullDesc: c.fullDesc,
|
||||
icon: c.icon,
|
||||
coverImageId: c.coverImageId,
|
||||
isPublished: c.isPublished,
|
||||
seoTitle: c.seoTitle,
|
||||
seoDescription: c.seoDescription,
|
||||
groups: c.groups.map((g) => ({ id: g.id, slug: g.slug, name: g.name, desc: g.desc })),
|
||||
});
|
||||
cover.value = c.coverImage;
|
||||
groupProducts.value = Object.fromEntries(c.groups.map((g) => [g.id, g.productCount]));
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickCover(m: MediaDto) {
|
||||
cover.value = m;
|
||||
model.value.coverImageId = m.id;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const data = validate();
|
||||
if (!data) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const saved = await api<AdminCategoryDetail>(
|
||||
id.value ? `/admin/categories/${id.value}` : "/admin/categories",
|
||||
{ method: id.value ? "PUT" : "POST", body: data },
|
||||
);
|
||||
void message.success("دسته ذخیره شد");
|
||||
if (!id.value) await router.replace({ name: "category-edit", params: { id: saved.id } });
|
||||
await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const newGroup = (): Group => ({ slug: "", name: "", desc: "" });
|
||||
const groupTitle = (g: Group) => {
|
||||
const count = g.id ? groupProducts.value[g.id] : 0;
|
||||
return count ? `${g.name} (${count} محصول)` : g.name;
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader :title="id ? `ویرایش دسته: ${model.name}` : 'دسته جدید'" back="/categories">
|
||||
<a-button type="primary" :loading="saving" @click="save">
|
||||
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
|
||||
ذخیره
|
||||
</a-button>
|
||||
</PageHeader>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-form layout="vertical" @submit.prevent="save">
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<div class="space-y-6 xl:col-span-2">
|
||||
<a-card title="اطلاعات اصلی">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="name" label="نام دسته" required>
|
||||
<a-input v-model:value="model.name" />
|
||||
</FormField>
|
||||
<FormField path="nameEn" label="نام انگلیسی" hint="زیر نام دسته نمایش داده میشود">
|
||||
<a-input v-model:value="model.nameEn" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField path="slug" label="اسلاگ (آدرس صفحه)" :hint="`آدرس: /categories/${model.slug || '...'}`" required>
|
||||
<a-input v-model:value="model.slug" class="ltr-input">
|
||||
<template #suffix>
|
||||
<a class="text-xs" @click="model.slug = toSlug(model.nameEn)">ساخت از نام انگلیسی</a>
|
||||
</template>
|
||||
</a-input>
|
||||
</FormField>
|
||||
<FormField path="code" label="کد دسته" required>
|
||||
<a-input v-model:value="model.code" class="ltr-input" placeholder="IP-C09" />
|
||||
</FormField>
|
||||
<FormField path="shortDesc" label="توضیح کوتاه" class="md:col-span-2">
|
||||
<a-textarea v-model:value="model.shortDesc" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</FormField>
|
||||
<FormField path="fullDesc" label="توضیح کامل (بالای صفحه دسته)" class="md:col-span-2">
|
||||
<a-textarea v-model:value="model.fullDesc" :auto-size="{ minRows: 3, maxRows: 8 }" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="زیرگروهها" :body-style="{ background: '#fafafa' }">
|
||||
<p class="mb-4 text-sm text-gray-500">
|
||||
زیرگروهها در صفحه دسته بهصورت فیلتر نمایش داده میشوند. با حذف یک زیرگروه، محصولات آن بدون زیرگروه میمانند.
|
||||
</p>
|
||||
<ListEditor
|
||||
v-model="model.groups"
|
||||
:create="newGroup"
|
||||
add-label="افزودن زیرگروه"
|
||||
:item-title="groupTitle"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`groups.${index}.name`" label="نام" required>
|
||||
<a-input v-model:value="item.name" />
|
||||
</FormField>
|
||||
<FormField :path="`groups.${index}.slug`" label="اسلاگ" required>
|
||||
<a-input v-model:value="item.slug" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField :path="`groups.${index}.desc`" label="توضیح" class="md:col-span-2">
|
||||
<a-textarea v-model:value="item.desc" :auto-size="{ minRows: 1, maxRows: 4 }" />
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<a-card title="انتشار">
|
||||
<FormField path="isPublished">
|
||||
<a-switch v-model:checked="model.isPublished" />
|
||||
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده میشود" : "مخفی" }}</span>
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="تصویر کاور">
|
||||
<FormField path="coverImageId">
|
||||
<ImageField :url="cover?.url" clearable @pick="pickCover" @clear="cover = null; model.coverImageId = null" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="آیکون">
|
||||
<FormField path="icon">
|
||||
<IconSelect v-model="model.icon" allow-clear />
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="سئو">
|
||||
<FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام دسته">
|
||||
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
|
||||
</FormField>
|
||||
<FormField path="seoDescription" label="توضیحات متا" hint="خالی = توضیح کامل">
|
||||
<a-textarea v-model:value="model.seoDescription" :maxlength="300" show-count :auto-size="{ minRows: 3 }" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { type DashboardDto, MESSAGE_STATUS_LABELS } from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { activityLabel, faNumber, jalali } from "@/utils/format";
|
||||
|
||||
const data = ref<DashboardDto | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
data.value = await api<DashboardDto>("/admin/dashboard");
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="داشبورد" subtitle="خلاصه وضعیت محتوای سایت">
|
||||
<RouterLink :to="{ name: 'product-new' }">
|
||||
<a-button type="primary">
|
||||
<template #icon><VuesaxIcon name="add" :size="16" /></template>
|
||||
محصول جدید
|
||||
</a-button>
|
||||
</RouterLink>
|
||||
</PageHeader>
|
||||
|
||||
<a-spin :spinning="!data">
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
|
||||
<RouterLink :to="{ name: 'products' }">
|
||||
<a-card hoverable><a-statistic title="محصولات" :value="faNumber(data?.products ?? 0)" /></a-card>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'products', query: { status: 'draft' } }">
|
||||
<a-card hoverable><a-statistic title="پیشنویس" :value="faNumber(data?.draftProducts ?? 0)" /></a-card>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'categories' }">
|
||||
<a-card hoverable><a-statistic title="دستهبندیها" :value="faNumber(data?.categories ?? 0)" /></a-card>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'messages', query: { status: 'NEW' } }">
|
||||
<a-card hoverable>
|
||||
<a-statistic
|
||||
title="پیامهای جدید"
|
||||
:value="faNumber(data?.newMessages ?? 0)"
|
||||
:value-style="data?.newMessages ? { color: '#ff5a00' } : undefined"
|
||||
/>
|
||||
</a-card>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'media' }">
|
||||
<a-card hoverable><a-statistic title="تصاویر" :value="faNumber(data?.media ?? 0)" /></a-card>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<a-card title="آخرین پیامهای تماس">
|
||||
<template #extra><RouterLink :to="{ name: 'messages' }">همه پیامها</RouterLink></template>
|
||||
<a-empty v-if="!data?.recentMessages.length" description="پیامی ثبت نشده است" />
|
||||
<a-list v-else :data-source="data.recentMessages" item-layout="horizontal">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :title="`${item.name} — ${item.org}`" :description="item.message || item.phone" />
|
||||
<div class="text-left text-xs text-gray-400">
|
||||
<a-tag :color="item.status === 'NEW' ? 'orange' : 'default'">{{ MESSAGE_STATUS_LABELS[item.status as keyof typeof MESSAGE_STATUS_LABELS] }}</a-tag>
|
||||
<div class="mt-1">{{ jalali(item.createdAt) }}</div>
|
||||
</div>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<a-card title="آخرین فعالیتها">
|
||||
<a-empty v-if="!data?.recentActivity.length" description="فعالیتی ثبت نشده است" />
|
||||
<a-timeline v-else class="!mt-2">
|
||||
<a-timeline-item v-for="a in data.recentActivity" :key="a.id">
|
||||
<div class="text-sm">
|
||||
<b>{{ activityLabel(a.action, a.entity) }}</b>
|
||||
<span v-if="a.summary" class="text-gray-600"> — {{ a.summary }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">{{ a.user ?? "سیستم" }} · {{ jalali(a.createdAt) }}</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-spin>
|
||||
</template>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { loginSchema } from "@imen/shared";
|
||||
import { ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import { useZodForm } from "@/composables/useZodForm";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { model, validate, handleError } = useZodForm(loginSchema, { phone: "", password: "" });
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
const data = validate();
|
||||
if (!data) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.login(data);
|
||||
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
|
||||
await router.replace(redirect.startsWith("/") ? redirect : "/");
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page relative flex min-h-screen overflow-hidden text-[#edede6]">
|
||||
<!-- atmosphere -->
|
||||
<div class="pointer-events-none absolute inset-0 bg-[#0b1224]" />
|
||||
<div class="login-grid pointer-events-none absolute inset-0 opacity-50" />
|
||||
<div
|
||||
class="pointer-events-none absolute -start-24 top-1/4 size-[420px] rounded-full bg-[#ff5a00]/15 blur-[120px]"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute -end-16 bottom-0 size-[360px] rounded-full bg-[#ffb800]/10 blur-[100px]"
|
||||
/>
|
||||
<div class="login-tape pointer-events-none absolute bottom-0 end-0 z-10 h-48 w-1.5 opacity-70" />
|
||||
<div class="login-noise pointer-events-none absolute inset-0 z-[5]" />
|
||||
|
||||
<div
|
||||
class="relative z-10 mx-auto grid w-full max-w-[1120px] flex-1 items-center gap-10 px-5 py-12 md:px-8 lg:grid-cols-12 lg:gap-16"
|
||||
>
|
||||
<!-- form column -->
|
||||
<div class="login-fade-3 lg:col-span-6">
|
||||
<div
|
||||
class="login-panel w-full max-w-md border border-[#2a3d63]/70 bg-[#1a2744]/80 p-7 backdrop-blur-md md:p-8 lg:me-auto"
|
||||
>
|
||||
<div class="mb-7 text-center">
|
||||
<div class="font-mono text-[10px] tracking-[0.28em] text-[#8e9ab0]">SIGN IN</div>
|
||||
<h2 class="mt-2 text-xl font-bold text-[#edede6]">ورود به پنل</h2>
|
||||
<p class="mt-1.5 text-sm text-[#8e9ab0]">شماره موبایل و رمز عبور خود را وارد کنید</p>
|
||||
</div>
|
||||
|
||||
<a-form class="login-form" layout="vertical" @submit.prevent="submit">
|
||||
<FormField path="phone" label="شماره موبایل">
|
||||
<a-input
|
||||
v-model:value="model.phone"
|
||||
size="large"
|
||||
class="ltr-input"
|
||||
autocomplete="tel"
|
||||
autofocus
|
||||
inputmode="tel"
|
||||
placeholder="09121234567"
|
||||
>
|
||||
<template #prefix><VuesaxIcon name="mobile" :size="16" class="text-[#8e9ab0]" /></template>
|
||||
</a-input>
|
||||
</FormField>
|
||||
<FormField path="password" label="رمز عبور">
|
||||
<a-input-password
|
||||
v-model:value="model.password"
|
||||
size="large"
|
||||
class="ltr-input"
|
||||
autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
<template #prefix><VuesaxIcon name="lock" :size="16" class="text-[#8e9ab0]" /></template>
|
||||
</a-input-password>
|
||||
</FormField>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
class="!mt-2 !h-12 !rounded-full !font-bold !shadow-[0_8px_32px_rgba(255,90,0,0.35)]"
|
||||
>
|
||||
ورود به پنل
|
||||
</a-button>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- brand column -->
|
||||
<div class="login-fade-1 lg:col-span-6">
|
||||
<div
|
||||
class="inline-flex items-center gap-2.5 rounded-full border border-[#ff5a00]/30 bg-[#ff5a00]/10 px-4 py-2 backdrop-blur"
|
||||
>
|
||||
<span class="size-1.5 animate-pulse rounded-full bg-[#ff5a00]" />
|
||||
<span class="text-xs font-medium text-[#ff5a00]">پنل مدیریت ایمن پیشگامان</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center gap-4">
|
||||
<span
|
||||
class="flex size-14 items-center justify-center rounded-xl bg-white shadow-[0_0_28px_rgba(10,30,92,0.45)]"
|
||||
>
|
||||
<img src="/logo.png" alt="ایمن پیشگامان" class="size-9 object-contain" />
|
||||
</span>
|
||||
<div class="leading-tight">
|
||||
<div class="text-2xl font-black tracking-tight md:text-3xl">ایمن پیشگامان</div>
|
||||
<div class="mt-1 font-mono text-[10px] tracking-[0.32em] text-[#8e9ab0]">
|
||||
IMEN PISHGAMAN CO.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="mt-8 text-3xl font-black leading-[1.25] md:text-5xl md:leading-[1.2]">
|
||||
ورود امن
|
||||
<span
|
||||
class="mt-2 inline-block -rotate-1 rounded-lg bg-[#ffb800] px-3 py-1 text-[#0b1224] shadow-[0_10px_40px_rgba(255,184,0,0.22)]"
|
||||
>
|
||||
به سامانه
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-md text-sm leading-8 text-[#edede6]/70 md:text-base md:leading-9">
|
||||
مدیریت محصولات، محتوا و پیامهای سایت — فقط برای کاربران مجاز.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-grid {
|
||||
background-image:
|
||||
linear-gradient(to left, rgba(237, 237, 230, 0.045) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(237, 237, 230, 0.045) 1px, transparent 1px);
|
||||
background-size: 72px 72px;
|
||||
}
|
||||
|
||||
.login-tape {
|
||||
background-image: repeating-linear-gradient(45deg, #ffb800 0 10px, #0b1224 10px 20px);
|
||||
}
|
||||
|
||||
.login-noise {
|
||||
opacity: 0.045;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.login-fade-1,
|
||||
.login-fade-3 {
|
||||
animation: login-fade-up 0.9s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.login-fade-1 {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.login-fade-3 {
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
|
||||
@keyframes login-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(36px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-panel :deep(.ant-form-item-label > label) {
|
||||
color: #edede6 !important;
|
||||
}
|
||||
|
||||
.login-panel :deep(.ant-input-affix-wrapper),
|
||||
.login-panel :deep(.ant-input) {
|
||||
background: #152038 !important;
|
||||
border-color: #2a3d63 !important;
|
||||
color: #edede6 !important;
|
||||
}
|
||||
|
||||
.login-panel :deep(.ant-input-affix-wrapper:hover),
|
||||
.login-panel :deep(.ant-input-affix-wrapper-focused),
|
||||
.login-panel :deep(.ant-input-affix-wrapper:focus-within) {
|
||||
border-color: #ff5a00 !important;
|
||||
box-shadow: 0 0 0 2px rgba(255, 90, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.login-panel :deep(.ant-input::placeholder) {
|
||||
color: #8e9ab0 !important;
|
||||
}
|
||||
|
||||
.login-panel :deep(.ant-input-password-icon) {
|
||||
color: #8e9ab0 !important;
|
||||
}
|
||||
|
||||
.login-panel :deep(.ant-form-item-explain-error) {
|
||||
color: #ff7a2a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import type { MediaDto, Paginated } from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { faNumber, fileSize, jalali } from "@/utils/format";
|
||||
|
||||
const items = ref<MediaDto[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const q = ref("");
|
||||
const loading = ref(false);
|
||||
const uploading = ref(0);
|
||||
const selected = ref<MediaDto | null>(null);
|
||||
const altDraft = ref("");
|
||||
const PAGE_SIZE = 36;
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api<Paginated<MediaDto>>("/admin/media", {
|
||||
query: { page: page.value, pageSize: PAGE_SIZE, q: q.value || undefined },
|
||||
});
|
||||
items.value = res.items;
|
||||
total.value = res.total;
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(file: File) {
|
||||
uploading.value++;
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
await api<MediaDto>("/admin/media", { method: "POST", body });
|
||||
void message.success(`«${file.name}» آپلود شد`);
|
||||
page.value = 1;
|
||||
await load();
|
||||
} catch (e) {
|
||||
void message.error(`${file.name}: ${errorMessage(e)}`);
|
||||
} finally {
|
||||
uploading.value--;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function open(m: MediaDto) {
|
||||
selected.value = m;
|
||||
altDraft.value = m.alt;
|
||||
}
|
||||
|
||||
async function saveAlt() {
|
||||
if (!selected.value) return;
|
||||
try {
|
||||
const m = await api<MediaDto>(`/admin/media/${selected.value.id}`, { method: "PATCH", body: { alt: altDraft.value } });
|
||||
Object.assign(selected.value, m);
|
||||
void message.success("ذخیره شد");
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!selected.value) return;
|
||||
try {
|
||||
await api(`/admin/media/${selected.value.id}`, { method: "DELETE" });
|
||||
void message.success("تصویر حذف شد");
|
||||
selected.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function copyUrl(url: string) {
|
||||
await navigator.clipboard.writeText(new URL(url, location.origin).toString());
|
||||
void message.success("آدرس کپی شد");
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="کتابخانه رسانه" :subtitle="`${faNumber(total)} تصویر`">
|
||||
<a-upload accept="image/jpeg,image/png,image/webp,image/avif,image/gif" multiple :show-upload-list="false" :before-upload="upload">
|
||||
<a-button type="primary" :loading="uploading > 0">
|
||||
<template #icon><VuesaxIcon name="document-upload" :size="16" /></template>
|
||||
آپلود تصویر
|
||||
</a-button>
|
||||
</a-upload>
|
||||
</PageHeader>
|
||||
|
||||
<a-card>
|
||||
<a-input-search
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو در نام فایل یا متن جایگزین"
|
||||
class="mb-4 max-w-md"
|
||||
@search="() => { page = 1; load(); }"
|
||||
/>
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty v-if="!items.length && !loading" description="تصویری پیدا نشد" />
|
||||
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-9">
|
||||
<button
|
||||
v-for="m in items"
|
||||
:key="m.id"
|
||||
type="button"
|
||||
class="overflow-hidden rounded-lg border border-gray-200 bg-gray-50 transition hover:border-[#ff5a00]"
|
||||
:title="m.originalName"
|
||||
@click="open(m)"
|
||||
>
|
||||
<img :src="m.url" :alt="m.alt" class="aspect-square w-full object-contain" loading="lazy" />
|
||||
</button>
|
||||
</div>
|
||||
</a-spin>
|
||||
<div class="mt-4 flex justify-center">
|
||||
<a-pagination v-model:current="page" :total="total" :page-size="PAGE_SIZE" :show-size-changer="false" @change="load" />
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-drawer :open="!!selected" title="جزئیات تصویر" :width="380" placement="left" @close="selected = null">
|
||||
<template v-if="selected">
|
||||
<img :src="selected.url" :alt="selected.alt" class="w-full rounded-lg border border-gray-200 bg-gray-50 object-contain" />
|
||||
<a-descriptions :column="1" size="small" class="mt-4">
|
||||
<a-descriptions-item label="نام فایل"><span dir="ltr">{{ selected.originalName }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="ابعاد">
|
||||
<span dir="ltr">{{ selected.width }}×{{ selected.height }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="حجم">{{ fileSize(selected.size) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="تاریخ">{{ jalali(selected.createdAt) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-form layout="vertical" class="mt-4">
|
||||
<a-form-item label="متن جایگزین (alt)">
|
||||
<a-input v-model:value="altDraft" />
|
||||
</a-form-item>
|
||||
<a-space wrap>
|
||||
<a-button type="primary" @click="saveAlt">ذخیره</a-button>
|
||||
<a-button @click="copyUrl(selected.url)"><template #icon><VuesaxIcon name="copy" :size="16" /></template>کپی آدرس</a-button>
|
||||
<a-popconfirm title="این تصویر حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove">
|
||||
<a-button danger><template #icon><VuesaxIcon name="trash" :size="16" /></template>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ContactMessageDto,
|
||||
MESSAGE_STATUS_LABELS,
|
||||
MESSAGE_STATUSES,
|
||||
type MessageStatus,
|
||||
type Paginated,
|
||||
} from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { faNumber, jalali } from "@/utils/format";
|
||||
|
||||
const route = useRoute();
|
||||
/** "ALL" or one status */
|
||||
const status = ref<MessageStatus | "ALL">((route.query.status as MessageStatus) || "ALL");
|
||||
const STATUS_OPTIONS = MESSAGE_STATUSES.map((s) => ({ value: s, label: MESSAGE_STATUS_LABELS[s] }));
|
||||
const FILTER_OPTIONS = [{ value: "ALL", label: "همه" }, ...STATUS_OPTIONS];
|
||||
const page = ref(1);
|
||||
const rows = ref<ContactMessageDto[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api<Paginated<ContactMessageDto>>("/admin/messages", {
|
||||
query: { status: status.value === "ALL" ? undefined : status.value, page: page.value, pageSize: PAGE_SIZE },
|
||||
});
|
||||
rows.value = res.items;
|
||||
total.value = res.total;
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(row: ContactMessageDto, s: MessageStatus) {
|
||||
try {
|
||||
await api(`/admin/messages/${row.id}`, { method: "PATCH", body: { status: s } });
|
||||
row.status = s;
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** opening a new message marks it as read */
|
||||
function onExpand(expanded: boolean, row: ContactMessageDto) {
|
||||
if (expanded && row.status === "NEW") void setStatus(row, "READ");
|
||||
}
|
||||
|
||||
async function remove(row: ContactMessageDto) {
|
||||
try {
|
||||
await api(`/admin/messages/${row.id}`, { method: "DELETE" });
|
||||
void message.success("پیام حذف شد");
|
||||
await load();
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<MessageStatus, string> = { NEW: "orange", READ: "blue", ARCHIVED: "default" };
|
||||
const columns = [
|
||||
{ title: "نام", dataIndex: "name" },
|
||||
{ title: "سازمان", dataIndex: "org" },
|
||||
{ title: "تلفن", key: "phone", width: 150 },
|
||||
{ title: "تاریخ", key: "date", width: 170 },
|
||||
{ title: "وضعیت", key: "status", width: 140 },
|
||||
{ title: "", key: "actions", width: 60 },
|
||||
];
|
||||
|
||||
watch(status, () => {
|
||||
page.value = 1;
|
||||
void load();
|
||||
});
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="پیامهای تماس" :subtitle="`${faNumber(total)} پیام — درخواستهای ثبتشده از فرم مشاوره سایت`">
|
||||
<a-segmented v-model:value="status" :options="FILTER_OPTIONS" />
|
||||
</PageHeader>
|
||||
|
||||
<a-card :body-style="{ padding: 0 }">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
:scroll="{ x: 760 }"
|
||||
:pagination="{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, onChange: (p: number) => { page = p; load(); } }"
|
||||
@expand="onExpand"
|
||||
>
|
||||
<template #expandedRowRender="{ record }">
|
||||
<p class="whitespace-pre-line leading-7">{{ record.message || "— بدون توضیح —" }}</p>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'phone'">
|
||||
<a :href="`tel:${record.phone}`" dir="ltr">{{ record.phone }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'date'">{{ jalali(record.createdAt) }}</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-select
|
||||
:value="record.status"
|
||||
size="small"
|
||||
class="w-28"
|
||||
:options="STATUS_OPTIONS"
|
||||
@change="(v: unknown) => setStatus(record as ContactMessageDto, v as MessageStatus)"
|
||||
>
|
||||
<template #suffixIcon><a-badge :color="STATUS_COLORS[record.status as MessageStatus]" /></template>
|
||||
</a-select>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-popconfirm title="این پیام حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record as ContactMessageDto)">
|
||||
<a-button size="small" danger><template #icon><VuesaxIcon name="trash" :size="16" /></template></a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
import { type MediaDto, type ProductAttribute, productInputSchema } from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { z } from "zod";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import type { AdminCategory, AdminCategoryDetail, AdminProductDetail } from "@/api/types";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ImageField from "@/components/ImageField.vue";
|
||||
import LinesInput from "@/components/LinesInput.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import MediaPicker from "@/components/MediaPicker.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useZodForm } from "@/composables/useZodForm";
|
||||
import { SITE_URL } from "@/utils/format";
|
||||
import { toSlug } from "@/utils/slug";
|
||||
|
||||
type Form = z.output<typeof productInputSchema>;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = computed(() => (route.params.id ? Number(route.params.id) : null));
|
||||
|
||||
const empty = (): Form => ({
|
||||
categoryId: Number(route.query.categoryId) || (undefined as unknown as number),
|
||||
groupId: null,
|
||||
code: "",
|
||||
slug: "",
|
||||
name: "",
|
||||
nameEn: "",
|
||||
desc: "",
|
||||
specs: [],
|
||||
variants: [],
|
||||
details: "",
|
||||
brand: "",
|
||||
origin: "",
|
||||
sizes: [],
|
||||
standards: [],
|
||||
features: [],
|
||||
applications: [],
|
||||
attributes: [],
|
||||
packaging: "",
|
||||
minOrder: "",
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
imageId: null,
|
||||
galleryIds: [],
|
||||
isPublished: true,
|
||||
});
|
||||
|
||||
const { model, validate, handleError, reset } = useZodForm(productInputSchema, empty());
|
||||
const image = ref<MediaDto | null>(null);
|
||||
const gallery = ref<MediaDto[]>([]);
|
||||
const galleryPicker = ref(false);
|
||||
const categories = ref<AdminCategory[]>([]);
|
||||
const groups = ref<AdminCategoryDetail["groups"]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const category = computed(() => categories.value.find((c) => c.id === model.value.categoryId));
|
||||
/** a-select clears to undefined, the API expects null */
|
||||
const groupId = computed({
|
||||
get: () => model.value.groupId ?? undefined,
|
||||
set: (v: number | undefined) => (model.value.groupId = v ?? null),
|
||||
});
|
||||
const newAttribute = (): ProductAttribute => ({ name: "", value: "" });
|
||||
|
||||
async function loadGroups(clear: boolean) {
|
||||
if (clear) model.value.groupId = null;
|
||||
groups.value = model.value.categoryId
|
||||
? (await api<AdminCategoryDetail>(`/admin/categories/${model.value.categoryId}`)).groups
|
||||
: [];
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
categories.value = await api<AdminCategory[]>("/admin/categories");
|
||||
if (id.value) {
|
||||
const p = await api<AdminProductDetail>(`/admin/products/${id.value}`);
|
||||
reset({
|
||||
categoryId: p.categoryId,
|
||||
groupId: p.groupId,
|
||||
code: p.code,
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
nameEn: p.nameEn,
|
||||
desc: p.desc,
|
||||
specs: p.specs,
|
||||
variants: p.variants,
|
||||
details: p.details,
|
||||
brand: p.brand,
|
||||
origin: p.origin,
|
||||
sizes: p.sizes,
|
||||
standards: p.standards,
|
||||
features: p.features,
|
||||
applications: p.applications,
|
||||
attributes: p.attributes ?? [],
|
||||
packaging: p.packaging,
|
||||
minOrder: p.minOrder,
|
||||
seoTitle: p.seoTitle,
|
||||
seoDescription: p.seoDescription,
|
||||
imageId: p.imageId,
|
||||
galleryIds: p.gallery.map((g) => g.id),
|
||||
isPublished: p.isPublished,
|
||||
});
|
||||
image.value = p.image;
|
||||
gallery.value = p.gallery;
|
||||
} else {
|
||||
reset(empty());
|
||||
image.value = null;
|
||||
gallery.value = [];
|
||||
}
|
||||
await loadGroups(false);
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickImage(m: MediaDto) {
|
||||
image.value = m;
|
||||
model.value.imageId = m.id;
|
||||
}
|
||||
|
||||
function addToGallery(m: MediaDto) {
|
||||
if (gallery.value.some((g) => g.id === m.id)) return;
|
||||
gallery.value = [...gallery.value, m];
|
||||
}
|
||||
function removeFromGallery(mediaId: number) {
|
||||
gallery.value = gallery.value.filter((g) => g.id !== mediaId);
|
||||
}
|
||||
watch(gallery, (g) => (model.value.galleryIds = g.map((m) => m.id)));
|
||||
|
||||
async function save() {
|
||||
const data = validate();
|
||||
if (!data) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const saved = await api<AdminProductDetail>(id.value ? `/admin/products/${id.value}` : "/admin/products", {
|
||||
method: id.value ? "PUT" : "POST",
|
||||
body: data,
|
||||
});
|
||||
void message.success("محصول ذخیره شد");
|
||||
if (!id.value) await router.replace({ name: "product-edit", params: { id: saved.id } });
|
||||
else await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// /products/new and /products/:id share this component
|
||||
watch(id, load);
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader :title="id ? `ویرایش محصول: ${model.name}` : 'محصول جدید'" back="/products">
|
||||
<a
|
||||
v-if="id && category?.isPublished && model.isPublished"
|
||||
:href="`${SITE_URL}/products/${model.slug}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<a-button>مشاهده در سایت</a-button>
|
||||
</a>
|
||||
<a-button type="primary" :loading="saving" @click="save">
|
||||
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
|
||||
ذخیره
|
||||
</a-button>
|
||||
</PageHeader>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-form layout="vertical" @submit.prevent="save">
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<div class="space-y-6 xl:col-span-2">
|
||||
<a-card title="اطلاعات محصول">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="name" label="نام محصول" required>
|
||||
<a-input v-model:value="model.name" />
|
||||
</FormField>
|
||||
<FormField path="nameEn" label="نام انگلیسی">
|
||||
<a-input v-model:value="model.nameEn" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField path="code" label="کد محصول" required hint="یکتا، مثلاً IP-G20">
|
||||
<a-input v-model:value="model.code" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField path="slug" label="اسلاگ (آدرس صفحه)" required :hint="`آدرس: /products/${model.slug || '...'}`">
|
||||
<a-input v-model:value="model.slug" class="ltr-input">
|
||||
<template #suffix>
|
||||
<a class="text-xs" @click="model.slug = toSlug(model.code || model.nameEn || '')">ساخت از کد</a>
|
||||
</template>
|
||||
</a-input>
|
||||
</FormField>
|
||||
<FormField path="desc" label="توضیح کوتاه" hint="زیر عنوان صفحه محصول و روی کارت محصول نمایش داده میشود" class="md:col-span-2">
|
||||
<a-textarea v-model:value="model.desc" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
<FormField path="specs" label="برچسبهای کوتاه" hint="ویژگیهای یککلمهای روی کارت؛ هر مورد را بنویسید و Enter بزنید" class="md:col-span-2">
|
||||
<a-select v-model:value="model.specs" mode="tags" :open="false" placeholder="مثلاً: ضد برش" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="توضیحات کامل (صفحه محصول)">
|
||||
<FormField path="details" hint="برای شروع پاراگراف جدید یک خط خالی بگذارید. اگر خالی بماند، توضیح دسته نمایش داده میشود.">
|
||||
<a-textarea
|
||||
v-model:value="model.details"
|
||||
:auto-size="{ minRows: 6, maxRows: 20 }"
|
||||
placeholder="معرفی محصول، جنس و ساختار، مزایا، نکات نگهداری و ..."
|
||||
/>
|
||||
</FormField>
|
||||
</a-card>
|
||||
|
||||
<a-card title="ویژگیها و کاربردها">
|
||||
<FormField path="features" label="ویژگیهای کلیدی" hint="هر ویژگی در یک خط">
|
||||
<LinesInput v-model="model.features" :rows="4" placeholder="مثلاً: زیره ضد لغزش و مقاوم در برابر روغن" />
|
||||
</FormField>
|
||||
<FormField path="applications" label="کاربردها / محیطهای مناسب" hint="هر مورد را بنویسید و Enter بزنید">
|
||||
<a-select v-model:value="model.applications" mode="tags" :open="false" placeholder="مثلاً: پالایشگاه" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
|
||||
<a-card title="مشخصات فنی" :body-style="{ background: '#fafafa' }">
|
||||
<p class="mb-4 text-sm text-gray-500">
|
||||
ردیفهای جدول مشخصات صفحه محصول (مثلاً «جنس رویه» ← «چرم طبیعی»). کد، دسته، برند و موارد زیر بهصورت خودکار به جدول اضافه میشوند.
|
||||
</p>
|
||||
<ListEditor
|
||||
v-model="model.attributes"
|
||||
:create="newAttribute"
|
||||
:max="40"
|
||||
add-label="افزودن ردیف مشخصات"
|
||||
:item-title="(a) => a.name"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-5">
|
||||
<FormField :path="`attributes.${index}.name`" label="عنوان" required class="md:col-span-2">
|
||||
<a-input v-model:value="item.name" placeholder="مثلاً: جنس رویه" />
|
||||
</FormField>
|
||||
<FormField :path="`attributes.${index}.value`" label="مقدار" required class="md:col-span-3">
|
||||
<a-input v-model:value="item.value" placeholder="مثلاً: چرم طبیعی گاوی" />
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
<div class="mt-6 grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="standards" label="استانداردها و گواهیها" hint="مثلاً EN 388 — هر مورد + Enter">
|
||||
<a-select v-model:value="model.standards" mode="tags" :open="false" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField path="sizes" label="سایزها" hint="مثلاً 40، 41، XL — هر مورد + Enter">
|
||||
<a-select v-model:value="model.sizes" mode="tags" :open="false" />
|
||||
</FormField>
|
||||
<FormField path="variants" label="مدلها / رنگها" hint="هر مورد + Enter" class="md:col-span-2">
|
||||
<a-select v-model:value="model.variants" mode="tags" :open="false" placeholder="مثلاً: مشکی/زرد" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="گالری تصاویر">
|
||||
<p class="mb-4 text-sm text-gray-500">تصاویر بیشتر محصول؛ در صفحه محصول بعد از تصویر اصلی نمایش داده میشوند.</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div v-for="m in gallery" :key="m.id" class="relative size-24 overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
|
||||
<img :src="m.url" alt="" class="size-full object-contain" />
|
||||
<button
|
||||
type="button"
|
||||
class="absolute end-1 top-1 flex size-6 items-center justify-center rounded-full bg-white/90 text-red-500 shadow"
|
||||
title="حذف از گالری"
|
||||
@click="removeFromGallery(m.id)"
|
||||
>
|
||||
<VuesaxIcon name="close-circle" :size="16" class="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-24 items-center justify-center rounded-lg border border-dashed border-gray-300 text-gray-400 hover:border-[#ff5a00] hover:text-[#ff5a00]"
|
||||
@click="galleryPicker = true"
|
||||
>
|
||||
<VuesaxIcon name="add" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<MediaPicker v-model:open="galleryPicker" @select="addToGallery" />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<a-card title="انتشار">
|
||||
<FormField path="isPublished">
|
||||
<a-switch v-model:checked="model.isPublished" />
|
||||
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده میشود" : "پیشنویس (مخفی)" }}</span>
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="دستهبندی">
|
||||
<FormField path="categoryId" label="دسته" required>
|
||||
<a-select
|
||||
v-model:value="model.categoryId"
|
||||
placeholder="انتخاب دسته"
|
||||
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
|
||||
@change="loadGroups(true)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField path="groupId" label="زیرگروه">
|
||||
<a-select
|
||||
v-model:value="groupId"
|
||||
allow-clear
|
||||
:disabled="!groups.length"
|
||||
:placeholder="groups.length ? 'بدون زیرگروه' : 'این دسته زیرگروه ندارد'"
|
||||
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
|
||||
/>
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="تصویر اصلی">
|
||||
<FormField path="imageId" hint="بدون تصویر، کاور دسته نمایش داده میشود">
|
||||
<ImageField :url="image?.url" clearable @pick="pickImage" @clear="image = null; model.imageId = null" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="برند و سفارش">
|
||||
<FormField path="brand" label="برند">
|
||||
<a-input v-model:value="model.brand" />
|
||||
</FormField>
|
||||
<FormField path="origin" label="کشور سازنده">
|
||||
<a-input v-model:value="model.origin" placeholder="مثلاً: ایران" />
|
||||
</FormField>
|
||||
<FormField path="packaging" label="بستهبندی">
|
||||
<a-input v-model:value="model.packaging" placeholder="مثلاً: کارتن ۱۲ جفتی" />
|
||||
</FormField>
|
||||
<FormField path="minOrder" label="حداقل سفارش">
|
||||
<a-input v-model:value="model.minOrder" placeholder="مثلاً: ۱ کارتن" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="سئو">
|
||||
<FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام محصول">
|
||||
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
|
||||
</FormField>
|
||||
<FormField path="seoDescription" label="توضیحات متا" hint="خالی = توضیح کوتاه">
|
||||
<a-textarea v-model:value="model.seoDescription" :maxlength="300" show-count :auto-size="{ minRows: 3 }" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</template>
|
||||
@@ -0,0 +1,452 @@
|
||||
<script setup lang="ts">
|
||||
import type { Paginated } from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import type { AdminCategory, AdminCategoryDetail, AdminProduct } from "@/api/types";
|
||||
import AdminPage from "@/components/AdminPage.vue";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const tableShell = ref<HTMLElement | null>(null);
|
||||
const scrollY = ref(360);
|
||||
let tableResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function measureTableScroll() {
|
||||
const el = tableShell.value;
|
||||
if (!el) return;
|
||||
const pagination = el.querySelector(".ant-table-pagination") as HTMLElement | null;
|
||||
const header = el.querySelector(".ant-table-thead") as HTMLElement | null;
|
||||
const paginationH = pagination?.offsetHeight ?? (reorderable.value ? 0 : 48);
|
||||
const headerH = header?.offsetHeight ?? 55;
|
||||
const gap = 16;
|
||||
scrollY.value = Math.max(180, el.clientHeight - paginationH - headerH - gap);
|
||||
}
|
||||
|
||||
const num = (v: unknown) => (v ? Number(v) : undefined);
|
||||
const PAGE_SIZE_OPTIONS = ["10", "20", "50", "100"];
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
const filters = reactive({
|
||||
categoryId: num(route.query.categoryId),
|
||||
groupId: num(route.query.groupId),
|
||||
status: (route.query.status as "published" | "draft" | undefined) || undefined,
|
||||
q: typeof route.query.q === "string" ? route.query.q : "",
|
||||
page: num(route.query.page) ?? 1,
|
||||
pageSize: num(route.query.pageSize) ?? DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
|
||||
const rows = ref<AdminProduct[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const categories = ref<AdminCategory[]>([]);
|
||||
const groups = ref<AdminCategoryDetail["groups"]>([]);
|
||||
const filtersOpen = ref(false);
|
||||
|
||||
const activeFilterCount = computed(
|
||||
() => [filters.categoryId, filters.groupId, filters.status].filter(Boolean).length,
|
||||
);
|
||||
|
||||
/** products without their own photo show the category cover (same fallback as the website) */
|
||||
const thumbOf = (row: AdminProduct) =>
|
||||
row.image
|
||||
? { url: row.image.url, fallback: false }
|
||||
: {
|
||||
url: categories.value.find((c) => c.id === row.categoryId)?.coverImage?.url ?? null,
|
||||
fallback: true,
|
||||
};
|
||||
|
||||
/** with a single category selected (and no other filter) the whole list is shown and can be reordered */
|
||||
const reorderable = computed(
|
||||
() => !!filters.categoryId && !filters.groupId && !filters.status && !filters.q,
|
||||
);
|
||||
|
||||
const requestPageSize = computed(() => (reorderable.value ? 200 : filters.pageSize));
|
||||
|
||||
const tablePagination = computed(() => {
|
||||
if (reorderable.value) return false;
|
||||
return {
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t: number) => `${t.toLocaleString("fa-IR")} محصول`,
|
||||
onChange: (page: number, size: number) => {
|
||||
filters.page = page;
|
||||
if (size !== filters.pageSize) {
|
||||
filters.pageSize = size;
|
||||
filters.page = 1;
|
||||
}
|
||||
void load();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
void router.replace({
|
||||
query: Object.fromEntries(
|
||||
Object.entries({
|
||||
...filters,
|
||||
page: filters.page === 1 ? undefined : filters.page,
|
||||
pageSize: filters.pageSize === DEFAULT_PAGE_SIZE ? undefined : filters.pageSize,
|
||||
}).filter(([, v]) => v),
|
||||
),
|
||||
});
|
||||
try {
|
||||
const res = await api<Paginated<AdminProduct>>("/admin/products", {
|
||||
query: {
|
||||
...filters,
|
||||
q: filters.q || undefined,
|
||||
pageSize: requestPageSize.value,
|
||||
},
|
||||
});
|
||||
rows.value = res.items;
|
||||
total.value = res.total;
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
groups.value = filters.categoryId
|
||||
? (await api<AdminCategoryDetail>(`/admin/categories/${filters.categoryId}`)).groups
|
||||
: [];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => filters.categoryId,
|
||||
async () => {
|
||||
filters.groupId = undefined;
|
||||
await loadGroups();
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => [filters.categoryId, filters.groupId, filters.status],
|
||||
() => {
|
||||
filters.page = 1;
|
||||
void load();
|
||||
},
|
||||
);
|
||||
|
||||
async function togglePublish(row: AdminProduct, isPublished: boolean) {
|
||||
try {
|
||||
await api(`/admin/products/${row.id}/publish`, { method: "PATCH", body: { isPublished } });
|
||||
row.isPublished = isPublished;
|
||||
void message.success(isPublished ? "محصول منتشر شد" : "محصول از سایت مخفی شد");
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicate(row: AdminProduct) {
|
||||
try {
|
||||
const copy = await api<{ id: number }>(`/admin/products/${row.id}/duplicate`, {
|
||||
method: "POST",
|
||||
});
|
||||
void message.success("کپی پیشنویس ساخته شد");
|
||||
await router.push({ name: "product-edit", params: { id: copy.id } });
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: AdminProduct) {
|
||||
try {
|
||||
await api(`/admin/products/${row.id}`, { method: "DELETE" });
|
||||
void message.success("محصول حذف شد");
|
||||
await load();
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function move(index: number, d: -1 | 1) {
|
||||
const list = [...rows.value];
|
||||
const [row] = list.splice(index, 1);
|
||||
list.splice(index + d, 0, row!);
|
||||
rows.value = list;
|
||||
try {
|
||||
await api("/admin/products/reorder", { method: "POST", body: { ids: list.map((r) => r.id) } });
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: "تصویر", key: "image", width: 76 },
|
||||
{ title: "محصول", key: "name" },
|
||||
{ title: "دسته / زیرگروه", key: "category", width: 200 },
|
||||
{ title: "انتشار", key: "status", width: 90 },
|
||||
...(reorderable.value ? [{ title: "ترتیب", key: "order", width: 100 }] : []),
|
||||
{ title: "", key: "actions", width: 130 },
|
||||
]);
|
||||
|
||||
watch([reorderable, loading, rows], async () => {
|
||||
await nextTick();
|
||||
measureTableScroll();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
categories.value = await api<AdminCategory[]>("/admin/categories");
|
||||
await loadGroups();
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
await load();
|
||||
await nextTick();
|
||||
if (tableShell.value) {
|
||||
tableResizeObserver = new ResizeObserver(() => measureTableScroll());
|
||||
tableResizeObserver.observe(tableShell.value);
|
||||
measureTableScroll();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
tableResizeObserver?.disconnect();
|
||||
tableResizeObserver = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage lock-body>
|
||||
<template #head>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar__start">
|
||||
<a-popover
|
||||
v-model:open="filtersOpen"
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
:overlay-style="{ width: '280px' }"
|
||||
>
|
||||
<template #content>
|
||||
<div class="filter-panel">
|
||||
<a-select
|
||||
v-model:value="filters.categoryId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="همه دستهها"
|
||||
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.groupId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="همه زیرگروهها"
|
||||
:disabled="!groups.length"
|
||||
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="همه وضعیتها"
|
||||
:options="[
|
||||
{ value: 'published', label: 'منتشرشده' },
|
||||
{ value: 'draft', label: 'پیشنویس / مخفی' },
|
||||
]"
|
||||
/>
|
||||
<p v-if="filters.categoryId && !reorderable" class="filter-panel__hint">
|
||||
برای تغییر ترتیب، فقط فیلتر دسته را نگه دارید.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<a-badge :count="activeFilterCount" :offset="[-4, 4]" color="#ff5a00">
|
||||
<a-button>
|
||||
<template #icon><VuesaxIcon name="filter" :size="16" /></template>
|
||||
فیلتر
|
||||
</a-button>
|
||||
</a-badge>
|
||||
</a-popover>
|
||||
|
||||
<a-input-search
|
||||
v-model:value="filters.q"
|
||||
allow-clear
|
||||
class="toolbar__search"
|
||||
placeholder="جستجوی نام یا کد"
|
||||
@search="
|
||||
() => {
|
||||
filters.page = 1;
|
||||
load();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
class="toolbar__new"
|
||||
:to="{
|
||||
name: 'product-new',
|
||||
query: filters.categoryId ? { categoryId: filters.categoryId } : {},
|
||||
}"
|
||||
>
|
||||
<a-button type="primary">
|
||||
<template #icon><VuesaxIcon name="add" :size="16" /></template>
|
||||
محصول جدید
|
||||
</a-button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="tableShell" class="table-shell">
|
||||
<a-table
|
||||
class="table-shell__table"
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
:scroll="{ x: 760, y: scrollY }"
|
||||
:pagination="tablePagination"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'image'">
|
||||
<template v-if="thumbOf(record as AdminProduct).url">
|
||||
<img
|
||||
:src="thumbOf(record as AdminProduct).url!"
|
||||
alt=""
|
||||
:title="
|
||||
thumbOf(record as AdminProduct).fallback
|
||||
? 'این محصول عکس ندارد؛ کاور دسته نمایش داده شده'
|
||||
: undefined
|
||||
"
|
||||
:class="[
|
||||
'size-12 rounded bg-gray-50 object-contain',
|
||||
thumbOf(record as AdminProduct).fallback && 'opacity-40 grayscale',
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="size-12 rounded bg-gray-100" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'name'">
|
||||
<RouterLink
|
||||
:to="{ name: 'product-edit', params: { id: record.id } }"
|
||||
class="font-bold text-[#0f172a] hover:text-[#ff5a00]"
|
||||
>
|
||||
{{ record.name }}
|
||||
</RouterLink>
|
||||
<div class="text-xs text-gray-400" dir="ltr">{{ record.code }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'category'">
|
||||
<div>{{ record.category.name }}</div>
|
||||
<div v-if="record.group" class="text-xs text-gray-400">{{ record.group.name }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.isPublished"
|
||||
size="small"
|
||||
@change="(v: unknown) => togglePublish(record as AdminProduct, !!v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'order'">
|
||||
<a-space :size="4">
|
||||
<a-button size="small" :disabled="index === 0" @click="move(index, -1)">
|
||||
<template #icon><VuesaxIcon name="arrow-up" :size="14" /></template>
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="index === rows.length - 1" @click="move(index, 1)">
|
||||
<template #icon><VuesaxIcon name="arrow-down" :size="14" /></template>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space :size="4">
|
||||
<RouterLink :to="{ name: 'product-edit', params: { id: record.id } }">
|
||||
<a-button size="small" title="ویرایش"
|
||||
><template #icon><VuesaxIcon name="edit-2" :size="14" /></template
|
||||
></a-button>
|
||||
</RouterLink>
|
||||
<a-button size="small" title="کپی" @click="duplicate(record as AdminProduct)">
|
||||
<template #icon><VuesaxIcon name="copy" :size="14" /></template>
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="این محصول حذف شود؟"
|
||||
ok-text="حذف"
|
||||
cancel-text="انصراف"
|
||||
@confirm="remove(record as AdminProduct)"
|
||||
>
|
||||
<a-button size="small" danger title="حذف"
|
||||
><template #icon><VuesaxIcon name="trash" :size="14" /></template
|
||||
></a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-shell :deep(.ant-table-wrapper),
|
||||
.table-shell :deep(.ant-spin-nested-loading),
|
||||
.table-shell :deep(.ant-spin-container) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-shell :deep(.ant-table) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.table-shell :deep(.ant-table-pagination) {
|
||||
flex-shrink: 0;
|
||||
margin: 12px 0 0 !important;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar__start {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.toolbar__new {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-panel__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { changePasswordSchema, ROLE_LABELS } from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { ref } from "vue";
|
||||
import { api } from "@/api/http";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useZodForm } from "@/composables/useZodForm";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const { model, validate, handleError, reset } = useZodForm(changePasswordSchema, {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
});
|
||||
const saving = ref(false);
|
||||
|
||||
async function save() {
|
||||
const data = validate();
|
||||
if (!data) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api("/admin/auth/change-password", { method: "POST", body: data });
|
||||
void message.success("رمز عبور تغییر کرد");
|
||||
reset({ currentPassword: "", newPassword: "" });
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="حساب کاربری" />
|
||||
<div class="grid max-w-4xl gap-6 md:grid-cols-2">
|
||||
<a-card title="مشخصات">
|
||||
<a-descriptions :column="1">
|
||||
<a-descriptions-item label="نام">{{ auth.user?.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="موبایل"><span dir="ltr">{{ auth.user?.phone }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="نقش">{{ auth.user && ROLE_LABELS[auth.user.role] }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
<a-card title="تغییر رمز عبور">
|
||||
<a-form layout="vertical" @submit.prevent="save">
|
||||
<FormField path="currentPassword" label="رمز فعلی" required>
|
||||
<a-input-password v-model:value="model.currentPassword" class="ltr-input" autocomplete="current-password" />
|
||||
</FormField>
|
||||
<FormField path="newPassword" label="رمز جدید" hint="حداقل ۸ کاراکتر" required>
|
||||
<a-input-password v-model:value="model.newPassword" class="ltr-input" autocomplete="new-password" />
|
||||
</FormField>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">تغییر رمز</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { isSectionKey, SECTION_LABELS, type SectionKey, sectionSchemas } from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { type Component, computed, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import type { SectionResponse } from "@/api/types";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useZodForm } from "@/composables/useZodForm";
|
||||
import { jalali, SITE_URL } from "@/utils/format";
|
||||
import AboutForm from "@/sections/AboutForm.vue";
|
||||
import ContactForm from "@/sections/ContactForm.vue";
|
||||
import FooterForm from "@/sections/FooterForm.vue";
|
||||
import HeroForm from "@/sections/HeroForm.vue";
|
||||
import IndustriesForm from "@/sections/IndustriesForm.vue";
|
||||
import ProductsIntroForm from "@/sections/ProductsIntroForm.vue";
|
||||
import ServicesForm from "@/sections/ServicesForm.vue";
|
||||
import SettingsForm from "@/sections/SettingsForm.vue";
|
||||
import StandardsForm from "@/sections/StandardsForm.vue";
|
||||
import StatsForm from "@/sections/StatsForm.vue";
|
||||
import TapeForm from "@/sections/TapeForm.vue";
|
||||
import TestimonialsForm from "@/sections/TestimonialsForm.vue";
|
||||
|
||||
const FORMS: Record<SectionKey, Component> = {
|
||||
settings: SettingsForm,
|
||||
hero: HeroForm,
|
||||
tape: TapeForm,
|
||||
stats: StatsForm,
|
||||
productsIntro: ProductsIntroForm,
|
||||
about: AboutForm,
|
||||
services: ServicesForm,
|
||||
standards: StandardsForm,
|
||||
industries: IndustriesForm,
|
||||
testimonials: TestimonialsForm,
|
||||
contact: ContactForm,
|
||||
footer: FooterForm,
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const key = String(route.params.key);
|
||||
const valid = isSectionKey(key);
|
||||
const sectionKey = (valid ? key : "hero") as SectionKey;
|
||||
|
||||
// the page is re-created per key (RouterView keys by path), so one form instance per section
|
||||
const { model, validate, handleError, reset } = useZodForm(
|
||||
sectionSchemas[sectionKey] as (typeof sectionSchemas)["hero"],
|
||||
{} as never,
|
||||
);
|
||||
const loaded = ref(false);
|
||||
const saving = ref(false);
|
||||
const updatedAt = ref<string | null>(null);
|
||||
const label = computed(() => (valid ? SECTION_LABELS[sectionKey] : null));
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api<SectionResponse>(`/admin/sections/${sectionKey}`);
|
||||
reset(res.data as never);
|
||||
updatedAt.value = res.updatedAt;
|
||||
loaded.value = true;
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const data = validate();
|
||||
if (!data) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await api<SectionResponse>(`/admin/sections/${sectionKey}`, { method: "PUT", body: data });
|
||||
reset(res.data as never);
|
||||
updatedAt.value = res.updatedAt;
|
||||
void message.success("ذخیره شد؛ سایت تا چند لحظه دیگر بهروز میشود");
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (valid) void load();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-result v-if="!valid" status="404" title="بخش پیدا نشد" />
|
||||
<template v-else>
|
||||
<PageHeader
|
||||
:title="label!.title"
|
||||
:subtitle="updatedAt ? `آخرین ذخیره: ${jalali(updatedAt)}` : label!.desc"
|
||||
back="/sections"
|
||||
>
|
||||
<a :href="SITE_URL" target="_blank" rel="noopener"><a-button>مشاهده سایت</a-button></a>
|
||||
<a-button type="primary" :loading="saving" :disabled="!loaded" @click="save">
|
||||
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
|
||||
ذخیره
|
||||
</a-button>
|
||||
</PageHeader>
|
||||
<a-spin :spinning="!loaded">
|
||||
<a-form v-if="loaded" layout="vertical" @submit.prevent="save">
|
||||
<component :is="FORMS[sectionKey]" v-model="model" />
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { SECTION_KEYS, SECTION_LABELS } from "@imen/shared";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
// site-wide settings are editable by admins only
|
||||
const keys = SECTION_KEYS.filter((k) => k !== "settings" || auth.isAdmin);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader
|
||||
title="بخشهای سایت"
|
||||
subtitle="متنها، تصاویر و فهرستهای صفحه اصلی؛ تغییرات پس از ذخیره حداکثر ظرف یک دقیقه روی سایت دیده میشوند"
|
||||
/>
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<RouterLink v-for="key in keys" :key="key" :to="{ name: 'section-edit', params: { key } }">
|
||||
<a-card hoverable class="h-full">
|
||||
<div class="font-bold text-gray-800">{{ SECTION_LABELS[key].title }}</div>
|
||||
<p class="mt-2 text-sm leading-6 text-gray-500">{{ SECTION_LABELS[key].desc }}</p>
|
||||
</a-card>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type AdminUserDto,
|
||||
ROLE_LABELS,
|
||||
ROLES,
|
||||
toIssues,
|
||||
userCreateSchema,
|
||||
userUpdateSchema,
|
||||
} from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { computed, onMounted, type Ref, ref } from "vue";
|
||||
import type { z } from "zod";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useZodForm } from "@/composables/useZodForm";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { jalali } from "@/utils/format";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const rows = ref<AdminUserDto[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
/** null = closed, 0 = new user, id = editing */
|
||||
const editing = ref<number | null>(null);
|
||||
const saving = ref(false);
|
||||
|
||||
// one form model for both create and update; the matching schema validates on save
|
||||
type Form = z.input<typeof userCreateSchema> & { isActive: boolean };
|
||||
const empty: Form = { phone: "", name: "", role: "EDITOR", password: "", isActive: true };
|
||||
const form = useZodForm(userCreateSchema, empty);
|
||||
const model = form.model as unknown as Ref<Form>;
|
||||
const isNew = computed(() => editing.value === 0);
|
||||
const ROLE_OPTIONS = ROLES.map((r) => ({ value: r, label: ROLE_LABELS[r] }));
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = await api<AdminUserDto[]>("/admin/users");
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
form.reset(empty);
|
||||
editing.value = 0;
|
||||
}
|
||||
function openEdit(u: AdminUserDto) {
|
||||
form.reset({ phone: u.phone, name: u.name, role: u.role, isActive: u.isActive, password: "" } as Form);
|
||||
editing.value = u.id;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
await api("/admin/users", { method: "POST", body: data });
|
||||
} else {
|
||||
const { name, role, isActive, password } = model.value;
|
||||
const parsed = userUpdateSchema.safeParse({ name, role, isActive, password: password || undefined });
|
||||
if (!parsed.success) {
|
||||
form.errors.value = Object.fromEntries(toIssues(parsed.error).map((i) => [i.path, i.message]));
|
||||
return;
|
||||
}
|
||||
await api(`/admin/users/${editing.value}`, { method: "PUT", body: parsed.data });
|
||||
}
|
||||
void message.success("ذخیره شد");
|
||||
editing.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
form.handleError(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(u: AdminUserDto) {
|
||||
try {
|
||||
await api(`/admin/users/${u.id}`, { method: "DELETE" });
|
||||
void message.success("کاربر حذف شد");
|
||||
await load();
|
||||
} catch (e) {
|
||||
void message.error(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: "نام", dataIndex: "name" },
|
||||
{ title: "موبایل", key: "phone" },
|
||||
{ title: "نقش", key: "role", width: 110 },
|
||||
{ title: "وضعیت", key: "active", width: 100 },
|
||||
{ title: "آخرین ورود", key: "login", width: 170 },
|
||||
{ title: "", key: "actions", width: 100 },
|
||||
];
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader title="کاربران پنل" subtitle="مدیر کل به همه بخشها دسترسی دارد؛ ویرایشگر به جز کاربران و تنظیمات سایت">
|
||||
<a-button type="primary" @click="openNew">
|
||||
<template #icon><VuesaxIcon name="add" :size="16" /></template>
|
||||
کاربر جدید
|
||||
</a-button>
|
||||
</PageHeader>
|
||||
|
||||
<a-card :body-style="{ padding: 0 }">
|
||||
<a-table :columns="columns" :data-source="rows" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 700 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'phone'"><span dir="ltr">{{ record.phone }}</span></template>
|
||||
<template v-else-if="column.key === 'role'">
|
||||
<a-tag :color="record.role === 'ADMIN' ? 'orange' : 'blue'">{{ ROLE_LABELS[record.role as keyof typeof ROLE_LABELS] }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'active'">
|
||||
<a-tag :color="record.isActive ? 'green' : 'default'">{{ record.isActive ? "فعال" : "غیرفعال" }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'login'">{{ jalali(record.lastLoginAt) }}</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space :size="4">
|
||||
<a-button size="small" @click="openEdit(record as AdminUserDto)"><template #icon><VuesaxIcon name="edit-2" :size="16" /></template></a-button>
|
||||
<a-popconfirm
|
||||
v-if="record.id !== auth.user?.id"
|
||||
title="این کاربر حذف شود؟"
|
||||
ok-text="حذف"
|
||||
cancel-text="انصراف"
|
||||
@confirm="remove(record as AdminUserDto)"
|
||||
>
|
||||
<a-button size="small" danger><template #icon><VuesaxIcon name="trash" :size="16" /></template></a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
:open="editing !== null"
|
||||
:title="isNew ? 'کاربر جدید' : 'ویرایش کاربر'"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="saving"
|
||||
destroy-on-close
|
||||
@ok="save"
|
||||
@cancel="editing = null"
|
||||
>
|
||||
<a-form layout="vertical" class="mt-4" @submit.prevent="save">
|
||||
<FormField path="phone" label="شماره موبایل" required>
|
||||
<a-input
|
||||
v-model:value="model.phone"
|
||||
class="ltr-input"
|
||||
:disabled="!isNew"
|
||||
inputmode="tel"
|
||||
placeholder="09121234567"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField path="name" label="نام" required>
|
||||
<a-input v-model:value="model.name" />
|
||||
</FormField>
|
||||
<FormField path="role" label="نقش" required>
|
||||
<a-select
|
||||
v-model:value="model.role"
|
||||
:disabled="editing === auth.user?.id"
|
||||
:options="ROLE_OPTIONS"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField v-if="!isNew" path="isActive">
|
||||
<a-checkbox v-model:checked="model.isActive" :disabled="editing === auth.user?.id">حساب فعال است</a-checkbox>
|
||||
</FormField>
|
||||
<FormField path="password" :label="isNew ? 'رمز عبور' : 'رمز عبور جدید (اختیاری)'" :required="isNew" hint="حداقل ۸ کاراکتر">
|
||||
<a-input-password v-model:value="model.password" class="ltr-input" autocomplete="new-password" />
|
||||
</FormField>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Role } from "@imen/shared";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
declare module "vue-router" {
|
||||
interface RouteMeta {
|
||||
title?: string;
|
||||
public?: boolean;
|
||||
roles?: Role[];
|
||||
}
|
||||
}
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("@/pages/LoginPage.vue"),
|
||||
meta: { public: true, title: "ورود" },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("@/layouts/AdminLayout.vue"),
|
||||
children: [
|
||||
{ path: "", name: "dashboard", component: () => import("@/pages/DashboardPage.vue"), meta: { title: "داشبورد" } },
|
||||
{ path: "products", name: "products", component: () => import("@/pages/ProductsPage.vue"), meta: { title: "محصولات" } },
|
||||
{ path: "products/new", name: "product-new", component: () => import("@/pages/ProductEditPage.vue"), meta: { title: "محصول جدید" } },
|
||||
{ path: "products/:id", name: "product-edit", component: () => import("@/pages/ProductEditPage.vue"), meta: { title: "ویرایش محصول" } },
|
||||
{ path: "categories", name: "categories", component: () => import("@/pages/CategoriesPage.vue"), meta: { title: "دستهبندیها" } },
|
||||
{ path: "categories/new", name: "category-new", component: () => import("@/pages/CategoryEditPage.vue"), meta: { title: "دسته جدید" } },
|
||||
{ path: "categories/:id", name: "category-edit", component: () => import("@/pages/CategoryEditPage.vue"), meta: { title: "ویرایش دسته" } },
|
||||
{ path: "sections", name: "sections", component: () => import("@/pages/SectionsPage.vue"), meta: { title: "بخشهای سایت" } },
|
||||
{ path: "sections/:key", name: "section-edit", component: () => import("@/pages/SectionEditPage.vue"), meta: { title: "ویرایش بخش" } },
|
||||
{ path: "messages", name: "messages", component: () => import("@/pages/MessagesPage.vue"), meta: { title: "پیامهای تماس" } },
|
||||
{ path: "media", name: "media", component: () => import("@/pages/MediaPage.vue"), meta: { title: "کتابخانه رسانه" } },
|
||||
{ path: "users", name: "users", component: () => import("@/pages/UsersPage.vue"), meta: { title: "کاربران", roles: ["ADMIN"] } },
|
||||
{ path: "profile", name: "profile", component: () => import("@/pages/ProfilePage.vue"), meta: { title: "حساب کاربری" } },
|
||||
],
|
||||
},
|
||||
{ path: "/:pathMatch(.*)*", redirect: "/" },
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
await auth.init();
|
||||
|
||||
if (to.meta.public) return auth.user && to.name === "login" ? { name: "dashboard" } : true;
|
||||
if (!auth.user) return { name: "login", query: { redirect: to.fullPath } };
|
||||
if (to.meta.roles && !to.meta.roles.includes(auth.user.role)) return { name: "dashboard" };
|
||||
return true;
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} | پنل ایمن پیشگامان` : "پنل ایمن پیشگامان";
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import IconSelect from "@/components/IconSelect.vue";
|
||||
import ImageRefField from "@/components/ImageRefField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"about">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<div class="space-y-6 xl:col-span-2">
|
||||
<a-card title="عنوان و متن">
|
||||
<SectionHeadFields v-model="data.head" :with-desc="false" />
|
||||
<FormField path="body" label="متن درباره ما" required>
|
||||
<a-textarea v-model:value="data.body" :auto-size="{ minRows: 4, maxRows: 10 }" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="تایملاین" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.timeline" :create="() => ({ year: '', text: '' })" :max="12" add-label="افزودن رویداد" :item-title="(t) => t.year">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-4">
|
||||
<FormField :path="`timeline.${index}.year`" label="سال" required><a-input v-model:value="item.year" /></FormField>
|
||||
<FormField :path="`timeline.${index}.text`" label="رویداد" required class="md:col-span-3"><a-input v-model:value="item.text" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
<a-card title="ویژگیها" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.features" :create="() => ({ icon: 'ShieldCheck', label: '' })" :max="6" add-label="افزودن ویژگی" :item-title="(f) => f.label">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`features.${index}.label`" label="متن" required><a-input v-model:value="item.label" /></FormField>
|
||||
<FormField :path="`features.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<a-card title="تصویر">
|
||||
<ImageRefField v-model="data.image" path="image" label="تصویر" />
|
||||
</a-card>
|
||||
<a-card title="نشان روی تصویر">
|
||||
<FormField path="badge.value" label="عدد" required><a-input v-model:value="data.badge.value" /></FormField>
|
||||
<FormField path="badge.label" label="متن" hint="برای شکستن خط Enter بزنید" required>
|
||||
<a-textarea v-model:value="data.badge.label" :auto-size="{ minRows: 2 }" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"contact">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان بخش">
|
||||
<SectionHeadFields v-model="data.head" />
|
||||
<p class="text-sm text-gray-500">نشانی، تلفن، ایمیل و ساعات کاری از «تنظیمات سایت» خوانده میشوند.</p>
|
||||
</a-card>
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<a-card title="کادر پاسخگویی اضطراری">
|
||||
<FormField path="emergencyTitle" label="عنوان" required><a-input v-model:value="data.emergencyTitle" /></FormField>
|
||||
<FormField path="emergencyText" label="متن" required>
|
||||
<a-textarea v-model:value="data.emergencyText" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
<a-card title="فرم">
|
||||
<FormField path="formTag" label="برچسب بالای فرم"><a-input v-model:value="data.formTag" /></FormField>
|
||||
<FormField path="formTitle" label="عنوان فرم" hint="برای شکستن خط Enter بزنید" required>
|
||||
<a-textarea v-model:value="data.formTitle" :auto-size="{ minRows: 2 }" />
|
||||
</FormField>
|
||||
<FormField path="successTitle" label="عنوان پیام موفقیت" required><a-input v-model:value="data.successTitle" /></FormField>
|
||||
<FormField path="successText" label="متن پیام موفقیت" required>
|
||||
<a-textarea v-model:value="data.successText" :auto-size="{ minRows: 2 }" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
|
||||
const data = defineModel<SectionData<"footer">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="فوتر">
|
||||
<FormField path="about" label="متن معرفی" required>
|
||||
<a-textarea v-model:value="data.about" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
<div class="grid gap-x-4 md:grid-cols-3">
|
||||
<FormField path="copyright" label="متن کپیرایت" required><a-input v-model:value="data.copyright" /></FormField>
|
||||
<FormField path="certLine" label="خط گواهینامهها"><a-input v-model:value="data.certLine" class="ltr-input" /></FormField>
|
||||
<FormField path="emergencyNote" label="یادداشت پشتیبانی"><a-input v-model:value="data.emergencyNote" /></FormField>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">لینک دستهها و اطلاعات تماس فوتر بهصورت خودکار از دستهبندیها و تنظیمات سایت ساخته میشوند.</p>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import IconSelect from "@/components/IconSelect.vue";
|
||||
import ImageRefField from "@/components/ImageRefField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
|
||||
const data = defineModel<SectionData<"hero">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<div class="space-y-6 xl:col-span-2">
|
||||
<a-card title="متنها">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="badge" label="نشان بالای تیتر" required class="md:col-span-2">
|
||||
<a-input v-model:value="data.badge" />
|
||||
</FormField>
|
||||
<FormField path="title" label="تیتر (خط اول)" required>
|
||||
<a-input v-model:value="data.title" />
|
||||
</FormField>
|
||||
<FormField path="titleHighlight" label="تیتر (خط زرد)" required>
|
||||
<a-input v-model:value="data.titleHighlight" />
|
||||
</FormField>
|
||||
<FormField path="intro" label="متن معرفی" required class="md:col-span-2">
|
||||
<a-textarea v-model:value="data.intro" :auto-size="{ minRows: 3, maxRows: 6 }" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card title="دکمهها">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="primaryCta.label" label="دکمه اصلی — متن" required>
|
||||
<a-input v-model:value="data.primaryCta.label" />
|
||||
</FormField>
|
||||
<FormField path="primaryCta.href" label="دکمه اصلی — لینک" hint="مثلاً #contact یا /categories/..." required>
|
||||
<a-input v-model:value="data.primaryCta.href" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField path="secondaryCta.label" label="دکمه دوم — متن" required>
|
||||
<a-input v-model:value="data.secondaryCta.label" />
|
||||
</FormField>
|
||||
<FormField path="secondaryCta.href" label="دکمه دوم — لینک" required>
|
||||
<a-input v-model:value="data.secondaryCta.href" class="ltr-input" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card title="نشانهای گواهی" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.chips" :create="() => ({ icon: 'ShieldCheck', label: '' })" :max="6" add-label="افزودن نشان" :item-title="(c) => c.label">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`chips.${index}.label`" label="متن" required><a-input v-model:value="item.label" /></FormField>
|
||||
<FormField :path="`chips.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
<a-card title="آمار پایین Hero" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.miniStats" :create="() => ({ value: '', label: '' })" :max="4" add-label="افزودن آمار" :item-title="(s) => s.value">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-3">
|
||||
<FormField :path="`miniStats.${index}.value`" label="عدد" required><a-input v-model:value="item.value" placeholder="۱۸+" /></FormField>
|
||||
<FormField :path="`miniStats.${index}.label`" label="توضیح" required class="md:col-span-2"><a-input v-model:value="item.label" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<a-card title="تصویر پسزمینه">
|
||||
<ImageRefField v-model="data.image" path="image" label="تصویر" />
|
||||
</a-card>
|
||||
<a-card title="کارت وضعیت">
|
||||
<FormField path="lastInspection" label="تاریخ آخرین بازرسی" hint="خالی = نمایش داده نمیشود">
|
||||
<a-input v-model:value="data.lastInspection" placeholder="۱۴۰۴/۰۸/۱۲" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ImageRefField from "@/components/ImageRefField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"industries">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان بخش">
|
||||
<SectionHeadFields v-model="data.head" />
|
||||
</a-card>
|
||||
<a-card title="صنایع" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor
|
||||
v-model="data.items"
|
||||
:create="() => ({ name: '', nameEn: '', count: '', image: { url: '', alt: '' } })"
|
||||
:max="12"
|
||||
add-label="افزودن صنعت"
|
||||
:item-title="(s) => s.name"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<FormField :path="`items.${index}.name`" label="نام" required><a-input v-model:value="item.name" /></FormField>
|
||||
<FormField :path="`items.${index}.nameEn`" label="نام انگلیسی"><a-input v-model:value="item.nameEn" class="ltr-input" /></FormField>
|
||||
<FormField :path="`items.${index}.count`" label="تعداد پروژه" hint="مثلاً ۱۴۰+ — خالی = نمایش داده نمیشود"><a-input v-model:value="item.count" /></FormField>
|
||||
</div>
|
||||
<div>
|
||||
<ImageRefField v-model="item.image" :path="`items.${index}.image`" label="تصویر" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import IconSelect from "@/components/IconSelect.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"productsIntro">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان بخش">
|
||||
<SectionHeadFields v-model="data.head" />
|
||||
<FormField path="ctaLabel" label="متن دکمه کاتالوگ" required>
|
||||
<a-input v-model:value="data.ctaLabel" />
|
||||
</FormField>
|
||||
<p class="text-sm text-gray-500">کارتهای دستهبندی از بخش «دستهبندیها» مدیریت میشوند.</p>
|
||||
</a-card>
|
||||
<a-card title="کارتهای اعتماد" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.trust" :create="() => ({ icon: 'BadgeCheck', title: '', desc: '' })" :max="6" add-label="افزودن کارت" :item-title="(t) => t.title">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-3">
|
||||
<FormField :path="`trust.${index}.title`" label="عنوان" required><a-input v-model:value="item.title" /></FormField>
|
||||
<FormField :path="`trust.${index}.desc`" label="توضیح"><a-input v-model:value="item.desc" /></FormField>
|
||||
<FormField :path="`trust.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import IconSelect from "@/components/IconSelect.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
type Service = SectionData<"services">["items"][number];
|
||||
|
||||
const data = defineModel<SectionData<"services">>({ required: true });
|
||||
const newService = (): Service => ({ icon: "ClipboardCheck", title: "", desc: "", items: [] });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان بخش">
|
||||
<SectionHeadFields v-model="data.head" />
|
||||
</a-card>
|
||||
<a-card title="خدمات" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.items" :create="newService" :max="10" add-label="افزودن خدمت" :item-title="(s) => s.title">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`items.${index}.title`" label="عنوان" required><a-input v-model:value="item.title" /></FormField>
|
||||
<FormField :path="`items.${index}.icon`" label="آیکون"><IconSelect v-model="item.icon" /></FormField>
|
||||
<FormField :path="`items.${index}.desc`" label="توضیح" required class="md:col-span-2">
|
||||
<a-textarea v-model:value="item.desc" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
<FormField :path="`items.${index}.items`" label="موارد (فهرست)" hint="هر مورد را بنویسید و Enter بزنید" class="md:col-span-2">
|
||||
<a-select v-model:value="item.items" mode="tags" :open="false" />
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { type MediaDto, type SectionData, SOCIAL_TYPES, type SocialType, WEEK_DAYS } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ImageField from "@/components/ImageField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
|
||||
const data = defineModel<SectionData<"settings">>({ required: true });
|
||||
|
||||
const DAY_LABELS: Record<(typeof WEEK_DAYS)[number], string> = {
|
||||
Saturday: "شنبه",
|
||||
Sunday: "یکشنبه",
|
||||
Monday: "دوشنبه",
|
||||
Tuesday: "سهشنبه",
|
||||
Wednesday: "چهارشنبه",
|
||||
Thursday: "پنجشنبه",
|
||||
Friday: "جمعه",
|
||||
};
|
||||
const SOCIAL_LABELS: Record<SocialType, string> = {
|
||||
website: "وبسایت",
|
||||
telegram: "تلگرام",
|
||||
instagram: "اینستاگرام",
|
||||
linkedin: "لینکدین",
|
||||
whatsapp: "واتساپ",
|
||||
bale: "بله",
|
||||
eitaa: "ایتا",
|
||||
aparat: "آپارات",
|
||||
youtube: "یوتیوب",
|
||||
email: "ایمیل (mailto:)",
|
||||
phone: "تلفن (tel:)",
|
||||
};
|
||||
|
||||
type Social = SectionData<"settings">["socials"][number];
|
||||
const URL_PLACEHOLDER = "https://t.me/...";
|
||||
const newSocial = (): Social => ({ type: "telegram", url: "" });
|
||||
const socialTitle = (s: Social) => SOCIAL_LABELS[s.type];
|
||||
const SOCIAL_OPTIONS = SOCIAL_TYPES.map((t) => ({ value: t, label: SOCIAL_LABELS[t] }));
|
||||
const DAY_OPTIONS = WEEK_DAYS.map((d) => ({ value: d, label: DAY_LABELS[d] }));
|
||||
|
||||
function pickOgImage(m: MediaDto) {
|
||||
data.value.ogImage = { url: m.url, alt: data.value.ogImage.alt || m.alt, width: m.width, height: m.height };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<a-card title="هویت و سئوی پیشفرض">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="name" label="نام شرکت" required><a-input v-model:value="data.name" /></FormField>
|
||||
<FormField path="nameEn" label="نام انگلیسی" required><a-input v-model:value="data.nameEn" class="ltr-input" /></FormField>
|
||||
<FormField path="title" label="عنوان پیشفرض صفحات (title)" required class="md:col-span-2">
|
||||
<a-input v-model:value="data.title" :maxlength="120" show-count />
|
||||
</FormField>
|
||||
<FormField path="description" label="توضیحات متا (description)" required class="md:col-span-2">
|
||||
<a-textarea v-model:value="data.description" :maxlength="400" show-count :auto-size="{ minRows: 3 }" />
|
||||
</FormField>
|
||||
<FormField path="foundingDate" label="سال تأسیس (میلادی)" required>
|
||||
<a-input v-model:value="data.foundingDate" class="ltr-input" placeholder="2007" />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField path="ogImage.url" label="تصویر اشتراکگذاری (Open Graph)" hint="ترجیحاً ۱۲۰۰×۶۳۰ پیکسل" required>
|
||||
<ImageField :url="data.ogImage.url" @pick="pickOgImage" />
|
||||
</FormField>
|
||||
<FormField path="ogImage.alt" label="متن جایگزین تصویر">
|
||||
<a-input v-model:value="data.ogImage.alt" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
|
||||
<a-card title="اطلاعات تماس">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="phoneDisplay" label="تلفن (نمایشی)" required>
|
||||
<a-input v-model:value="data.phoneDisplay" />
|
||||
</FormField>
|
||||
<FormField path="phone" label="تلفن (برای لینک و گوگل)" hint="با کد کشور، مثلاً +98-21-91008800" required>
|
||||
<a-input v-model:value="data.phone" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField path="email" label="ایمیل" required class="md:col-span-2">
|
||||
<a-input v-model:value="data.email" class="ltr-input" />
|
||||
</FormField>
|
||||
<FormField path="address.locality" label="شهر" required>
|
||||
<a-input v-model:value="data.address.locality" />
|
||||
</FormField>
|
||||
<FormField path="address.country" label="کد کشور" required>
|
||||
<a-input v-model:value="data.address.country" class="ltr-input" :maxlength="2" />
|
||||
</FormField>
|
||||
<FormField path="address.street" label="نشانی" required class="md:col-span-2">
|
||||
<a-input v-model:value="data.address.street" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="ساعات کاری">
|
||||
<FormField path="workingHours" label="متن نمایشی" required>
|
||||
<a-input v-model:value="data.workingHours" />
|
||||
</FormField>
|
||||
<FormField path="openingHours.days" label="روزهای کاری (برای گوگل)">
|
||||
<a-checkbox-group
|
||||
v-model:value="data.openingHours.days"
|
||||
:options="DAY_OPTIONS"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="openingHours.opens" label="ساعت شروع" required>
|
||||
<a-input v-model:value="data.openingHours.opens" class="ltr-input" placeholder="08:00" />
|
||||
</FormField>
|
||||
<FormField path="openingHours.closes" label="ساعت پایان" required>
|
||||
<a-input v-model:value="data.openingHours.closes" class="ltr-input" placeholder="17:00" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="شبکههای اجتماعی و لینکها" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor
|
||||
v-model="data.socials"
|
||||
:create="newSocial"
|
||||
:max="12"
|
||||
add-label="افزودن لینک"
|
||||
:item-title="socialTitle"
|
||||
>
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-3">
|
||||
<FormField :path="`socials.${index}.type`" label="نوع">
|
||||
<a-select v-model:value="item.type" :options="SOCIAL_OPTIONS" />
|
||||
</FormField>
|
||||
<FormField :path="`socials.${index}.url`" label="آدرس" required class="md:col-span-2">
|
||||
<a-input v-model:value="item.url" class="ltr-input" :placeholder="URL_PLACEHOLDER" />
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"standards">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان و متن">
|
||||
<SectionHeadFields v-model="data.head" />
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField path="note" label="کادر توضیح (اختیاری)">
|
||||
<a-textarea v-model:value="data.note" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
<FormField path="ctaLabel" label="متن دکمه" required>
|
||||
<a-input v-model:value="data.ctaLabel" />
|
||||
</FormField>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card title="استانداردها" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.items" :create="() => ({ code: '', label: '' })" :max="12" add-label="افزودن استاندارد" :item-title="(s) => s.code">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`items.${index}.code`" label="کد استاندارد" required><a-input v-model:value="item.code" class="ltr-input" placeholder="EN 397" /></FormField>
|
||||
<FormField :path="`items.${index}.label`" label="عنوان" required><a-input v-model:value="item.label" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
|
||||
const data = defineModel<SectionData<"stats">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="شمارندهها" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.items" :create="() => ({ end: 0, suffix: '+', label: '', note: '' })" :max="8" add-label="افزودن شمارنده" :item-title="(s) => s.label">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-4">
|
||||
<FormField :path="`items.${index}.end`" label="عدد نهایی" required>
|
||||
<a-input-number v-model:value="item.end" :min="0" class="!w-full" />
|
||||
</FormField>
|
||||
<FormField :path="`items.${index}.suffix`" label="پسوند" hint="مثلاً + یا ٪">
|
||||
<a-input v-model:value="item.suffix" />
|
||||
</FormField>
|
||||
<FormField :path="`items.${index}.label`" label="عنوان" required>
|
||||
<a-input v-model:value="item.label" />
|
||||
</FormField>
|
||||
<FormField :path="`items.${index}.note`" label="یادداشت انگلیسی کوچک">
|
||||
<a-input v-model:value="item.note" class="ltr-input" />
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
|
||||
const data = defineModel<SectionData<"tape">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="عبارتهای نوار متحرک">
|
||||
<FormField path="items" label="عبارتها" hint="هر عبارت را بنویسید و Enter بزنید؛ برای حذف روی × کلیک کنید" required>
|
||||
<a-select v-model:value="data.items" mode="tags" :open="false" />
|
||||
</FormField>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectionData } from "@imen/shared";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import ListEditor from "@/components/ListEditor.vue";
|
||||
import SectionHeadFields from "@/components/SectionHeadFields.vue";
|
||||
|
||||
const data = defineModel<SectionData<"testimonials">>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<a-card title="عنوان بخش">
|
||||
<SectionHeadFields v-model="data.head" :with-highlight="false" :with-desc="false" />
|
||||
</a-card>
|
||||
<a-card title="نظرات" :body-style="{ background: '#fafafa' }">
|
||||
<ListEditor v-model="data.items" :create="() => ({ text: '', name: '', role: '' })" :max="12" add-label="افزودن نظر" :item-title="(q) => q.name">
|
||||
<template #default="{ item, index }">
|
||||
<div class="grid gap-x-4 md:grid-cols-2">
|
||||
<FormField :path="`items.${index}.text`" label="متن نظر" required class="md:col-span-2">
|
||||
<a-textarea v-model:value="item.text" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</FormField>
|
||||
<FormField :path="`items.${index}.name`" label="نام" required><a-input v-model:value="item.name" /></FormField>
|
||||
<FormField :path="`items.${index}.role`" label="سمت و سازمان"><a-input v-model:value="item.role" /></FormField>
|
||||
</div>
|
||||
</template>
|
||||
</ListEditor>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { AdminUserDto, AuthResponse, LoginInput } from "@imen/shared";
|
||||
import { defineStore } from "pinia";
|
||||
import { rawApi, setAuthHooks } from "@/api/http";
|
||||
import { router } from "@/router";
|
||||
|
||||
let refreshing: Promise<boolean> | null = null;
|
||||
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
state: () => ({
|
||||
/** short-lived access token, kept in memory only */
|
||||
token: "",
|
||||
user: null as AdminUserDto | null,
|
||||
/** true once the initial session check has run */
|
||||
ready: false,
|
||||
}),
|
||||
getters: {
|
||||
isAdmin: (s) => s.user?.role === "ADMIN",
|
||||
},
|
||||
actions: {
|
||||
setSession(r: AuthResponse) {
|
||||
this.token = r.accessToken;
|
||||
this.user = r.user;
|
||||
},
|
||||
clear() {
|
||||
this.token = "";
|
||||
this.user = null;
|
||||
},
|
||||
async login(input: LoginInput) {
|
||||
this.setSession(await rawApi<AuthResponse>("/admin/auth/login", { method: "POST", body: input }));
|
||||
},
|
||||
/** Exchanges the refresh cookie for a new access token; concurrent calls share one request */
|
||||
refresh(): Promise<boolean> {
|
||||
refreshing ??= rawApi<AuthResponse>("/admin/auth/refresh", { method: "POST" })
|
||||
.then((r) => {
|
||||
this.setSession(r);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
this.clear();
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshing = null;
|
||||
});
|
||||
return refreshing;
|
||||
},
|
||||
/** Restores the session after a page load (the refresh cookie survives reloads) */
|
||||
async init() {
|
||||
if (this.ready) return;
|
||||
setAuthHooks({
|
||||
token: () => this.token,
|
||||
refresh: () => this.refresh(),
|
||||
onExpired: () => {
|
||||
this.clear();
|
||||
void router.push({ name: "login", query: { redirect: router.currentRoute.value.fullPath } });
|
||||
},
|
||||
});
|
||||
await this.refresh();
|
||||
this.ready = true;
|
||||
},
|
||||
async logout() {
|
||||
await rawApi("/admin/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||
this.clear();
|
||||
await router.push({ name: "login" });
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("/fonts/IRANSansWebFaNum.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("/fonts/IRANSansWebFaNum_Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("/fonts/IRANSansWebFaNum_Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: "IRANSans", Tahoma, sans-serif;
|
||||
--color-brand: #ff5a00;
|
||||
--color-navy: #0b1224;
|
||||
--color-navy-2: #101a32;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #f5f6fa;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* inputs that hold Latin text (codes, slugs, URLs) read left-to-right */
|
||||
.ltr-input input,
|
||||
input.ltr-input,
|
||||
textarea.ltr-input {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const dateFmt = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
/** ISO date → Jalali date and time, e.g. «۱۴۰۴/۰۷/۲۹، ۱۶:۳۰» */
|
||||
export const jalali = (iso: string | null | undefined) => (iso ? dateFmt.format(new Date(iso)) : "—");
|
||||
|
||||
export const fileSize = (bytes: number) =>
|
||||
bytes < 1024 * 1024
|
||||
? `${(bytes / 1024).toLocaleString("fa-IR", { maximumFractionDigits: 0 })} کیلوبایت`
|
||||
: `${(bytes / 1024 / 1024).toLocaleString("fa-IR", { maximumFractionDigits: 1 })} مگابایت`;
|
||||
|
||||
export const faNumber = (n: number) => n.toLocaleString("fa-IR");
|
||||
|
||||
/** Website base URL for "view on site" links */
|
||||
export const SITE_URL = (import.meta.env.VITE_SITE_URL as string | undefined) ?? "http://localhost:3000";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: "ایجاد",
|
||||
update: "ویرایش",
|
||||
delete: "حذف",
|
||||
upload: "آپلود",
|
||||
login: "ورود",
|
||||
publish: "انتشار",
|
||||
unpublish: "عدم انتشار",
|
||||
duplicate: "کپی",
|
||||
reorder: "مرتبسازی",
|
||||
"change-password": "تغییر رمز",
|
||||
};
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
product: "محصول",
|
||||
category: "دسته",
|
||||
section: "بخش سایت",
|
||||
media: "رسانه",
|
||||
user: "کاربر",
|
||||
message: "پیام",
|
||||
};
|
||||
|
||||
export const activityLabel = (action: string, entity: string) =>
|
||||
`${ACTION_LABELS[action] ?? action} ${ENTITY_LABELS[entity] ?? entity}`;
|
||||
@@ -0,0 +1,7 @@
|
||||
/** "Safety Shoes 2" → "safety-shoes-2" (Latin letters and digits only) */
|
||||
export const toSlug = (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client", "ant-design-vue/typings/global"],
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig, type ProxyOptions } from "vite";
|
||||
|
||||
// same paths nginx serves in production: /api → NestJS, /media → object storage bucket
|
||||
const proxy: Record<string, ProxyOptions> = {
|
||||
"/api": { target: "http://localhost:4000", changeOrigin: true },
|
||||
// trailing slash: the media library page itself lives at /media
|
||||
"/media/": {
|
||||
target: "http://localhost:9000/imen-media",
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/media/, ""),
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||
},
|
||||
server: { port: 5173, proxy },
|
||||
preview: { port: 5173, proxy },
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
# --- server ---
|
||||
PORT=4000
|
||||
NODE_ENV=development
|
||||
# comma-separated origins allowed to call the API from a browser (admin dev server, site)
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# --- database (docker-compose.dev.yml) ---
|
||||
DATABASE_URL=postgresql://imen:imen@localhost:5433/imen
|
||||
|
||||
# --- auth ---
|
||||
# generate with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
JWT_SECRET=change-me-dev-secret-change-me-dev-secret
|
||||
JWT_ACCESS_TTL=15m
|
||||
REFRESH_TTL_DAYS=30
|
||||
# set to false only when serving the admin over plain http in production
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# first admin user, created by `pnpm db:seed`
|
||||
ADMIN_PHONE=09120000000
|
||||
ADMIN_PASSWORD=ChangeMe123!
|
||||
ADMIN_NAME=مدیر سایت
|
||||
|
||||
# --- object storage (MinIO in dev, any S3-compatible service in production) ---
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=imen-media
|
||||
S3_ACCESS_KEY=imen
|
||||
S3_SECRET_KEY=imen-secret-key
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
# create the bucket and make it publicly readable on startup (MinIO)
|
||||
S3_ENSURE_BUCKET=true
|
||||
# public base URL of uploaded files (no trailing slash). "/media" is proxied to the bucket
|
||||
# by the site/admin dev servers and by nginx in production, so URLs are the same everywhere
|
||||
MEDIA_PUBLIC_URL=/media
|
||||
|
||||
# --- website cache revalidation ---
|
||||
WEB_REVALIDATE_URL=http://localhost:3000/_internal/revalidate
|
||||
REVALIDATE_SECRET=change-me-revalidate-secret
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@imen/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "prisma generate && nest build",
|
||||
"dev": "nest start --watch --preserveWatchOutput",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "prisma generate && tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run --config ./vitest.config.e2e.ts",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"prisma:studio": "prisma studio",
|
||||
"db:seed": "prisma db seed",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1154.0",
|
||||
"@imen/shared": "workspace:*",
|
||||
"@nestjs/common": "^12.2.0",
|
||||
"@nestjs/core": "^12.2.0",
|
||||
"@nestjs/jwt": "^12.0.3",
|
||||
"@nestjs/platform-express": "^12.2.0",
|
||||
"@nestjs/throttler": "^6.7.1",
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"@prisma/adapter-pg": "7.10.1",
|
||||
"@prisma/client": "7.10.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.3.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sharp": "^0.35.5",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^12.0.10",
|
||||
"@nestjs/schematics": "^12.0.10",
|
||||
"@nestjs/testing": "^12.2.0",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/multer": "^2.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"prisma": "7.10.1",
|
||||
"supertest": "^7.1.4",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
// Prisma 7 no longer loads .env on its own
|
||||
if (existsSync(".env")) process.loadEnvFile(".env");
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
seed: "tsx src/seed/seed.ts",
|
||||
},
|
||||
datasource: {
|
||||
// `prisma generate` (e.g. in the Docker build) does not need a real database
|
||||
url: process.env.DATABASE_URL ?? "postgresql://placeholder:placeholder@localhost:5432/placeholder",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MessageStatus" AS ENUM ('NEW', 'READ', 'ARCHIVED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AdminUser" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL DEFAULT 'EDITOR',
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"userAgent" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Media" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"mime" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"alt" TEXT NOT NULL DEFAULT '',
|
||||
"originalName" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Media_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameEn" TEXT NOT NULL DEFAULT '',
|
||||
"shortDesc" TEXT NOT NULL DEFAULT '',
|
||||
"fullDesc" TEXT NOT NULL DEFAULT '',
|
||||
"icon" TEXT NOT NULL DEFAULT '',
|
||||
"coverImageId" INTEGER,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isPublished" BOOLEAN NOT NULL DEFAULT true,
|
||||
"seoTitle" TEXT NOT NULL DEFAULT '',
|
||||
"seoDescription" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductGroup" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"categoryId" INTEGER NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"desc" TEXT NOT NULL DEFAULT '',
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "ProductGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"categoryId" INTEGER NOT NULL,
|
||||
"groupId" INTEGER,
|
||||
"code" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameEn" TEXT NOT NULL DEFAULT '',
|
||||
"desc" TEXT NOT NULL DEFAULT '',
|
||||
"specs" TEXT[],
|
||||
"variants" TEXT[],
|
||||
"imageId" INTEGER,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isPublished" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductImage" (
|
||||
"productId" INTEGER NOT NULL,
|
||||
"mediaId" INTEGER NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("productId","mediaId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SiteSection" (
|
||||
"key" TEXT NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"updatedById" INTEGER,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SiteSection_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContactMessage" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"org" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL DEFAULT '',
|
||||
"status" "MessageStatus" NOT NULL DEFAULT 'NEW',
|
||||
"ip" TEXT NOT NULL DEFAULT '',
|
||||
"userAgent" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ContactMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER,
|
||||
"action" TEXT NOT NULL,
|
||||
"entity" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL DEFAULT '',
|
||||
"summary" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Media_key_key" ON "Media"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Category_sortOrder_idx" ON "Category"("sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProductGroup_categoryId_slug_key" ON "ProductGroup"("categoryId", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_categoryId_sortOrder_idx" ON "Product"("categoryId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContactMessage_status_createdAt_idx" ON "ContactMessage"("status", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Category" ADD CONSTRAINT "Category_coverImageId_fkey" FOREIGN KEY ("coverImageId") REFERENCES "Media"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductGroup" ADD CONSTRAINT "ProductGroup_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "ProductGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Media"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "Media"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "applications" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "attributes" JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN "brand" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "details" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "features" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "minOrder" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "origin" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "packaging" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "seoDescription" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "seoTitle" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "sizes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "standards" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Rename login identifier from email to phone
|
||||
ALTER TABLE "AdminUser" RENAME COLUMN "email" TO "phone";
|
||||
ALTER INDEX "AdminUser_email_key" RENAME TO "AdminUser_phone_key";
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,188 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
moduleFormat = "esm"
|
||||
importFileExtension = "js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
EDITOR
|
||||
}
|
||||
|
||||
enum MessageStatus {
|
||||
NEW
|
||||
READ
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
model AdminUser {
|
||||
id Int @id @default(autoincrement())
|
||||
phone String @unique
|
||||
name String
|
||||
passwordHash String
|
||||
role Role @default(EDITOR)
|
||||
isActive Boolean @default(true)
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user AdminUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// sha256 of the opaque token stored in the httpOnly cookie
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
userAgent String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Media {
|
||||
id Int @id @default(autoincrement())
|
||||
/// object key in the S3 bucket; the public URL is MEDIA_PUBLIC_URL + "/" + key
|
||||
key String @unique
|
||||
mime String
|
||||
size Int
|
||||
width Int?
|
||||
height Int?
|
||||
alt String @default("")
|
||||
originalName String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
categoryCovers Category[] @relation("CategoryCover")
|
||||
productImages Product[] @relation("ProductImage")
|
||||
productGallery ProductImage[]
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique
|
||||
code String
|
||||
name String
|
||||
nameEn String @default("")
|
||||
shortDesc String @default("")
|
||||
fullDesc String @default("")
|
||||
/// Lucide icon name
|
||||
icon String @default("")
|
||||
coverImageId Int?
|
||||
coverImage Media? @relation("CategoryCover", fields: [coverImageId], references: [id], onDelete: SetNull)
|
||||
sortOrder Int @default(0)
|
||||
isPublished Boolean @default(true)
|
||||
seoTitle String @default("")
|
||||
seoDescription String @default("")
|
||||
groups ProductGroup[]
|
||||
products Product[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sortOrder])
|
||||
}
|
||||
|
||||
model ProductGroup {
|
||||
id Int @id @default(autoincrement())
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
slug String
|
||||
name String
|
||||
desc String @default("")
|
||||
sortOrder Int @default(0)
|
||||
products Product[]
|
||||
|
||||
@@unique([categoryId, slug])
|
||||
}
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||
groupId Int?
|
||||
group ProductGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
code String @unique
|
||||
slug String @unique
|
||||
name String
|
||||
nameEn String @default("")
|
||||
desc String @default("")
|
||||
specs String[]
|
||||
variants String[]
|
||||
/// long description for the product page; a blank line starts a new paragraph
|
||||
details String @default("")
|
||||
brand String @default("")
|
||||
/// country of manufacture
|
||||
origin String @default("")
|
||||
sizes String[] @default([])
|
||||
standards String[] @default([])
|
||||
features String[] @default([])
|
||||
applications String[] @default([])
|
||||
/// technical specification table: [{ "name": "...", "value": "..." }]
|
||||
attributes Json @default("[]")
|
||||
packaging String @default("")
|
||||
minOrder String @default("")
|
||||
seoTitle String @default("")
|
||||
seoDescription String @default("")
|
||||
imageId Int?
|
||||
image Media? @relation("ProductImage", fields: [imageId], references: [id], onDelete: SetNull)
|
||||
gallery ProductImage[]
|
||||
sortOrder Int @default(0)
|
||||
isPublished Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([categoryId, sortOrder])
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
productId Int
|
||||
mediaId Int
|
||||
sortOrder Int @default(0)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([productId, mediaId])
|
||||
}
|
||||
|
||||
/// One row per editable website section; `data` is validated by the zod schema for `key` (@imen/shared)
|
||||
model SiteSection {
|
||||
key String @id
|
||||
data Json
|
||||
updatedById Int?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ContactMessage {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
org String
|
||||
phone String
|
||||
message String @default("")
|
||||
status MessageStatus @default(NEW)
|
||||
ip String @default("")
|
||||
userAgent String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int?
|
||||
user AdminUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
/// create | update | delete | login ...
|
||||
action String
|
||||
/// product | category | section | media | user | message
|
||||
entity String
|
||||
entityId String @default("")
|
||||
summary String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 25 KiB |