بناء تطبيق ويب متكامل باستخدام Nuxt 4 و Vue 3

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

يرشدك هذا الدليل خطوة بخطوة لبناء تطبيق ويب متكامل باستخدام Nuxt 4 و Vue 3. ستقوم ببناء تطبيق لإدارة المهام (TaskFlow) مع المصادقة ومسارات API وقاعدة بيانات PostgreSQL عبر Prisma. في النهاية سيكون لديك تطبيق كامل جاهز للإنتاج.

أهداف التعلم

في نهاية هذا الدليل ستكون قادراً على:

  • إنشاء وتكوين مشروع Nuxt 4 مع TypeScript
  • إتقان نظام التوجيه القائم على الملفات في Nuxt
  • بناء مكونات Vue 3 تفاعلية باستخدام Composition API
  • إنشاء مسارات API على الخادم مع Nitro
  • دمج Prisma ORM لإدارة قاعدة البيانات
  • تنفيذ مصادقة بسيطة قائمة على الجلسات
  • نشر تطبيقك في بيئة الإنتاج

المتطلبات الأساسية

قبل البدء تأكد من توفر:

  • Node.js 20+ مثبت على جهازك
  • pnpm (مدير الحزم الموصى به لـ Nuxt)
  • PostgreSQL مثبت محلياً أو خدمة سحابية (Neon أو Supabase)
  • معرفة أساسية بـ JavaScript/TypeScript
  • إلمام بأساسيات Vue.js (المكونات والتفاعلية)
  • محرر أكواد (يُوصى بـ VS Code مع إضافة Volar)

ما ستبنيه

تطبيق TaskFlow — مدير مهام متكامل يتضمن:

  • تسجيل ودخول المستخدمين
  • إنشاء وتعديل وحذف المهام
  • التصفية حسب الحالة (للتنفيذ، قيد التنفيذ، مكتمل)
  • واجهة متجاوبة مع نظام تصميم Nuxt UI
  • واجهة برمجة REST آمنة على جانب الخادم

الخطوة 1: تهيئة مشروع Nuxt 4

ابدأ بإنشاء مشروع Nuxt 4 جديد:

pnpm dlx nuxi@latest init taskflow-app
cd taskflow-app

عندما يطلب منك CLI الخيارات اختر:

  • مدير الحزم: pnpm
  • تهيئة git: نعم

ثم ثبّت التبعيات وشغّل خادم التطوير:

pnpm install
pnpm dev

تطبيقك متاح على http://localhost:3000.

هيكل المشروع

إليك الهيكل الأساسي لمشروع Nuxt 4:

taskflow-app/
├── app/
│   ├── components/     # مكونات Vue قابلة لإعادة الاستخدام
│   ├── composables/    # منطق قابل لإعادة الاستخدام (hooks)
│   ├── layouts/        # تخطيطات الصفحات
│   ├── pages/          # الصفحات (توجيه تلقائي)
│   └── app.vue         # المكون الجذر
├── server/
│   ├── api/            # مسارات API
│   ├── middleware/      # وسيط الخادم
│   └── utils/          # أدوات الخادم
├── prisma/
│   └── schema.prisma   # مخطط قاعدة البيانات
├── nuxt.config.ts      # تكوين Nuxt
├── package.json
└── tsconfig.json

يتبنى Nuxt 4 هيكل مجلدات جديد مع مجلد app/ الذي يحتوي على كل الكود العميل. هذا الفصل الواضح بين العميل (app/) والخادم (server/) يحسّن تنظيم الكود.

الخطوة 2: تكوين Nuxt 4

حدّث ملف nuxt.config.ts مع الوحدات المطلوبة:

// nuxt.config.ts
export default defineNuxtConfig({
  future: {
    compatibilityVersion: 4,
  },
 
  devtools: { enabled: true },
 
  modules: [
    '@nuxt/ui',
    '@nuxt/fonts',
  ],
 
  runtimeConfig: {
    sessionSecret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
    databaseUrl: process.env.DATABASE_URL,
    public: {
      appName: 'TaskFlow',
    },
  },
 
  compatibilityDate: '2026-03-01',
})

ثبّت وحدات Nuxt UI و Fonts:

pnpm add @nuxt/ui @nuxt/fonts

أنشئ ملف .env في جذر المشروع:

DATABASE_URL="postgresql://user:password@localhost:5432/taskflow"
SESSION_SECRET="سر-آمن-جداً-غيّره-في-الإنتاج"

الخطوة 3: إعداد Prisma وقاعدة البيانات

ثبّت Prisma وقم بتهيئته:

pnpm add -D prisma
pnpm add @prisma/client
pnpm dlx prisma init

حدّد مخطط قاعدة البيانات في prisma/schema.prisma:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  password  String
  tasks     Task[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
 
model Task {
  id          String     @id @default(cuid())
  title       String
  description String?
  status      TaskStatus @default(TODO)
  priority    Priority   @default(MEDIUM)
  userId      String
  user        User       @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
}
 
enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}
 
enum Priority {
  LOW
  MEDIUM
  HIGH
}

طبّق عمليات الترحيل:

pnpm dlx prisma migrate dev --name init

أنشئ أداة مساعدة على الخادم للوصول إلى عميل Prisma:

// server/utils/prisma.ts
import { PrismaClient } from '@prisma/client'
 
const prisma = new PrismaClient()
 
export default prisma

الخطوة 4: إنشاء مسارات API

يستخدم Nuxt محرك Nitro لمسارات الخادم. أنشئ نقاط نهاية API الخاصة بك.

مسار التسجيل

// server/api/auth/register.post.ts
import bcrypt from 'bcryptjs'
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
 
  if (!body.email || !body.password || !body.name) {
    throw createError({
      statusCode: 400,
      statusMessage: 'البريد الإلكتروني والاسم وكلمة المرور مطلوبة',
    })
  }
 
  const existingUser = await prisma.user.findUnique({
    where: { email: body.email },
  })
 
  if (existingUser) {
    throw createError({
      statusCode: 409,
      statusMessage: 'يوجد حساب بهذا البريد الإلكتروني بالفعل',
    })
  }
 
  const hashedPassword = await bcrypt.hash(body.password, 12)
 
  const user = await prisma.user.create({
    data: {
      email: body.email,
      name: body.name,
      password: hashedPassword,
    },
    select: {
      id: true,
      email: true,
      name: true,
    },
  })
 
  const session = await useSession(event, {
    password: useRuntimeConfig().sessionSecret,
  })
  await session.update({ userId: user.id })
 
  return user
})

مسار تسجيل الدخول

// server/api/auth/login.post.ts
import bcrypt from 'bcryptjs'
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
 
  if (!body.email || !body.password) {
    throw createError({
      statusCode: 400,
      statusMessage: 'البريد الإلكتروني وكلمة المرور مطلوبان',
    })
  }
 
  const user = await prisma.user.findUnique({
    where: { email: body.email },
  })
 
  if (!user || !(await bcrypt.compare(body.password, user.password))) {
    throw createError({
      statusCode: 401,
      statusMessage: 'بيانات الاعتماد غير صالحة',
    })
  }
 
  const session = await useSession(event, {
    password: useRuntimeConfig().sessionSecret,
  })
  await session.update({ userId: user.id })
 
  return {
    id: user.id,
    email: user.email,
    name: user.name,
  }
})

وسيط المصادقة على الخادم

// server/middleware/auth.ts
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const protectedRoutes = ['/api/tasks']
 
  const isProtected = protectedRoutes.some((route) =>
    event.path?.startsWith(route)
  )
 
  if (!isProtected) return
 
  const session = await useSession(event, {
    password: useRuntimeConfig().sessionSecret,
  })
 
  if (!session.data?.userId) {
    throw createError({
      statusCode: 401,
      statusMessage: 'غير مصادق',
    })
  }
 
  const user = await prisma.user.findUnique({
    where: { id: session.data.userId as string },
    select: { id: true, email: true, name: true },
  })
 
  if (!user) {
    throw createError({
      statusCode: 401,
      statusMessage: 'المستخدم غير موجود',
    })
  }
 
  event.context.user = user
})

عمليات CRUD للمهام

// server/api/tasks/index.get.ts
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const user = event.context.user
  const query = getQuery(event)
 
  const where: any = { userId: user.id }
 
  if (query.status) {
    where.status = query.status
  }
 
  const tasks = await prisma.task.findMany({
    where,
    orderBy: { createdAt: 'desc' },
  })
 
  return tasks
})
// server/api/tasks/index.post.ts
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const user = event.context.user
  const body = await readBody(event)
 
  if (!body.title) {
    throw createError({
      statusCode: 400,
      statusMessage: 'العنوان مطلوب',
    })
  }
 
  const task = await prisma.task.create({
    data: {
      title: body.title,
      description: body.description || null,
      priority: body.priority || 'MEDIUM',
      userId: user.id,
    },
  })
 
  return task
})
// server/api/tasks/[id].patch.ts
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const user = event.context.user
  const id = getRouterParam(event, 'id')
  const body = await readBody(event)
 
  const task = await prisma.task.findFirst({
    where: { id, userId: user.id },
  })
 
  if (!task) {
    throw createError({
      statusCode: 404,
      statusMessage: 'المهمة غير موجودة',
    })
  }
 
  const updated = await prisma.task.update({
    where: { id },
    data: {
      title: body.title ?? task.title,
      description: body.description ?? task.description,
      status: body.status ?? task.status,
      priority: body.priority ?? task.priority,
    },
  })
 
  return updated
})
// server/api/tasks/[id].delete.ts
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const user = event.context.user
  const id = getRouterParam(event, 'id')
 
  const task = await prisma.task.findFirst({
    where: { id, userId: user.id },
  })
 
  if (!task) {
    throw createError({
      statusCode: 404,
      statusMessage: 'المهمة غير موجودة',
    })
  }
 
  await prisma.task.delete({ where: { id } })
 
  return { success: true }
})

ثبّت bcryptjs لتشفير كلمات المرور:

pnpm add bcryptjs
pnpm add -D @types/bcryptjs

الخطوة 5: إنشاء composable المصادقة

أنشئ composable لإدارة حالة المصادقة على جانب العميل:

// app/composables/useAuth.ts
interface User {
  id: string
  email: string
  name: string
}
 
export function useAuth() {
  const user = useState<User | null>('auth-user', () => null)
  const isAuthenticated = computed(() => !!user.value)
 
  async function login(email: string, password: string) {
    const data = await $fetch<User>('/api/auth/login', {
      method: 'POST',
      body: { email, password },
    })
    user.value = data
    return data
  }
 
  async function register(name: string, email: string, password: string) {
    const data = await $fetch<User>('/api/auth/register', {
      method: 'POST',
      body: { name, email, password },
    })
    user.value = data
    return data
  }
 
  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    user.value = null
    navigateTo('/login')
  }
 
  async function fetchUser() {
    try {
      const data = await $fetch<User>('/api/auth/me')
      user.value = data
    } catch {
      user.value = null
    }
  }
 
  return {
    user,
    isAuthenticated,
    login,
    register,
    logout,
    fetchUser,
  }
}

أضف مسارات الجلسة المتبقية:

// server/api/auth/me.get.ts
export default defineEventHandler(async (event) => {
  const user = event.context.user
  if (!user) {
    throw createError({ statusCode: 401, statusMessage: 'غير مصادق' })
  }
  return user
})
// server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
  const session = await useSession(event, {
    password: useRuntimeConfig().sessionSecret,
  })
  await session.clear()
  return { success: true }
})

الخطوة 6: إنشاء التخطيط الرئيسي

حدّد تخطيطاً مع شريط تنقل:

<!-- app/layouts/default.vue -->
<script setup lang="ts">
const { user, isAuthenticated, logout } = useAuth()
const config = useRuntimeConfig()
</script>
 
<template>
  <div class="min-h-screen bg-gray-50 dark:bg-gray-900">
    <header class="bg-white dark:bg-gray-800 shadow-sm">
      <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="flex justify-between items-center h-16">
          <NuxtLink to="/" class="text-xl font-bold text-primary">
            {{ config.public.appName }}
          </NuxtLink>
 
          <nav class="flex items-center gap-4">
            <template v-if="isAuthenticated">
              <span class="text-sm text-gray-600 dark:text-gray-300">
                {{ user?.name }}
              </span>
              <UButton
                variant="ghost"
                color="red"
                @click="logout"
              >
                تسجيل الخروج
              </UButton>
            </template>
            <template v-else>
              <UButton to="/login" variant="ghost">تسجيل الدخول</UButton>
              <UButton to="/register" color="primary">إنشاء حساب</UButton>
            </template>
          </nav>
        </div>
      </div>
    </header>
 
    <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <slot />
    </main>
  </div>
</template>

الخطوة 7: بناء الصفحات

الصفحة الرئيسية

<!-- app/pages/index.vue -->
<script setup lang="ts">
const { isAuthenticated } = useAuth()
</script>
 
<template>
  <div class="text-center py-20">
    <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
      أدر مهامك بكفاءة
    </h1>
    <p class="text-lg text-gray-600 dark:text-gray-300 mb-8 max-w-2xl mx-auto">
      TaskFlow هو مدير مهام بسيط وقوي.
      نظّم وحدد الأولويات وتابع تقدم مشاريعك.
    </p>
    <div class="flex gap-4 justify-center">
      <UButton
        v-if="!isAuthenticated"
        to="/register"
        size="lg"
        color="primary"
      >
        ابدأ مجاناً
      </UButton>
      <UButton
        v-if="isAuthenticated"
        to="/dashboard"
        size="lg"
        color="primary"
      >
        الذهاب إلى لوحة التحكم
      </UButton>
    </div>
  </div>
</template>

صفحة تسجيل الدخول

<!-- app/pages/login.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'default' })
 
const { login } = useAuth()
const error = ref('')
const loading = ref(false)
 
const form = reactive({
  email: '',
  password: '',
})
 
async function handleSubmit() {
  error.value = ''
  loading.value = true
  try {
    await login(form.email, form.password)
    navigateTo('/dashboard')
  } catch (e: any) {
    error.value = e.data?.statusMessage || 'خطأ في تسجيل الدخول'
  } finally {
    loading.value = false
  }
}
</script>
 
<template>
  <div class="max-w-md mx-auto mt-16">
    <UCard>
      <template #header>
        <h2 class="text-2xl font-bold text-center">تسجيل الدخول</h2>
      </template>
 
      <form @submit.prevent="handleSubmit" class="space-y-4">
        <UAlert
          v-if="error"
          color="red"
          :title="error"
          variant="subtle"
        />
 
        <UFormGroup label="البريد الإلكتروني">
          <UInput
            v-model="form.email"
            type="email"
            placeholder="you@email.com"
            required
          />
        </UFormGroup>
 
        <UFormGroup label="كلمة المرور">
          <UInput
            v-model="form.password"
            type="password"
            placeholder="كلمة المرور"
            required
          />
        </UFormGroup>
 
        <UButton
          type="submit"
          block
          :loading="loading"
        >
          تسجيل الدخول
        </UButton>
      </form>
 
      <template #footer>
        <p class="text-center text-sm text-gray-500">
          ليس لديك حساب؟
          <NuxtLink to="/register" class="text-primary font-medium">
            سجّل الآن
          </NuxtLink>
        </p>
      </template>
    </UCard>
  </div>
</template>

صفحة التسجيل

<!-- app/pages/register.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'default' })
 
const { register } = useAuth()
const error = ref('')
const loading = ref(false)
 
const form = reactive({
  name: '',
  email: '',
  password: '',
  confirmPassword: '',
})
 
async function handleSubmit() {
  if (form.password !== form.confirmPassword) {
    error.value = 'كلمتا المرور غير متطابقتين'
    return
  }
 
  error.value = ''
  loading.value = true
  try {
    await register(form.name, form.email, form.password)
    navigateTo('/dashboard')
  } catch (e: any) {
    error.value = e.data?.statusMessage || 'خطأ في التسجيل'
  } finally {
    loading.value = false
  }
}
</script>
 
<template>
  <div class="max-w-md mx-auto mt-16">
    <UCard>
      <template #header>
        <h2 class="text-2xl font-bold text-center">إنشاء حساب</h2>
      </template>
 
      <form @submit.prevent="handleSubmit" class="space-y-4">
        <UAlert
          v-if="error"
          color="red"
          :title="error"
          variant="subtle"
        />
 
        <UFormGroup label="الاسم">
          <UInput
            v-model="form.name"
            placeholder="اسمك"
            required
          />
        </UFormGroup>
 
        <UFormGroup label="البريد الإلكتروني">
          <UInput
            v-model="form.email"
            type="email"
            placeholder="you@email.com"
            required
          />
        </UFormGroup>
 
        <UFormGroup label="كلمة المرور">
          <UInput
            v-model="form.password"
            type="password"
            placeholder="8 أحرف على الأقل"
            required
            minlength="8"
          />
        </UFormGroup>
 
        <UFormGroup label="تأكيد كلمة المرور">
          <UInput
            v-model="form.confirmPassword"
            type="password"
            placeholder="أعد كتابة كلمة المرور"
            required
          />
        </UFormGroup>
 
        <UButton
          type="submit"
          block
          :loading="loading"
        >
          إنشاء الحساب
        </UButton>
      </form>
 
      <template #footer>
        <p class="text-center text-sm text-gray-500">
          لديك حساب بالفعل؟
          <NuxtLink to="/login" class="text-primary font-medium">
            سجّل دخولك
          </NuxtLink>
        </p>
      </template>
    </UCard>
  </div>
</template>

صفحة لوحة التحكم

<!-- app/pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
  middleware: 'auth',
})
 
interface Task {
  id: string
  title: string
  description: string | null
  status: 'TODO' | 'IN_PROGRESS' | 'DONE'
  priority: 'LOW' | 'MEDIUM' | 'HIGH'
  createdAt: string
}
 
const activeFilter = ref<string | null>(null)
 
const { data: tasks, refresh } = await useFetch<Task[]>('/api/tasks', {
  query: computed(() => ({
    status: activeFilter.value || undefined,
  })),
})
 
const showCreateModal = ref(false)
 
const newTask = reactive({
  title: '',
  description: '',
  priority: 'MEDIUM' as 'LOW' | 'MEDIUM' | 'HIGH',
})
 
async function createTask() {
  await $fetch('/api/tasks', {
    method: 'POST',
    body: {
      title: newTask.title,
      description: newTask.description || null,
      priority: newTask.priority,
    },
  })
  newTask.title = ''
  newTask.description = ''
  newTask.priority = 'MEDIUM'
  showCreateModal.value = false
  refresh()
}
 
async function updateTaskStatus(taskId: string, status: string) {
  await $fetch(`/api/tasks/${taskId}`, {
    method: 'PATCH',
    body: { status },
  })
  refresh()
}
 
async function deleteTask(taskId: string) {
  await $fetch(`/api/tasks/${taskId}`, {
    method: 'DELETE',
  })
  refresh()
}
 
const filters = [
  { label: 'الكل', value: null },
  { label: 'للتنفيذ', value: 'TODO' },
  { label: 'قيد التنفيذ', value: 'IN_PROGRESS' },
  { label: 'مكتمل', value: 'DONE' },
]
 
const priorityColors = {
  LOW: 'green',
  MEDIUM: 'yellow',
  HIGH: 'red',
} as const
 
const statusLabels = {
  TODO: 'للتنفيذ',
  IN_PROGRESS: 'قيد التنفيذ',
  DONE: 'مكتمل',
} as const
</script>
 
<template>
  <div>
    <div class="flex justify-between items-center mb-8">
      <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
        مهامي
      </h1>
      <UButton
        color="primary"
        icon="i-heroicons-plus"
        @click="showCreateModal = true"
      >
        مهمة جديدة
      </UButton>
    </div>
 
    <!-- الفلاتر -->
    <div class="flex gap-2 mb-6">
      <UButton
        v-for="filter in filters"
        :key="filter.label"
        :variant="activeFilter === filter.value ? 'solid' : 'ghost'"
        size="sm"
        @click="activeFilter = filter.value"
      >
        {{ filter.label }}
      </UButton>
    </div>
 
    <!-- قائمة المهام -->
    <div class="space-y-3">
      <UCard
        v-for="task in tasks"
        :key="task.id"
      >
        <div class="flex items-start justify-between">
          <div class="flex-1">
            <div class="flex items-center gap-2 mb-1">
              <h3
                class="font-medium"
                :class="task.status === 'DONE' ? 'line-through text-gray-400' : ''"
              >
                {{ task.title }}
              </h3>
              <UBadge :color="priorityColors[task.priority]" size="xs">
                {{ task.priority }}
              </UBadge>
            </div>
            <p v-if="task.description" class="text-sm text-gray-500">
              {{ task.description }}
            </p>
          </div>
 
          <div class="flex items-center gap-2">
            <USelect
              :model-value="task.status"
              :options="[
                { label: 'للتنفيذ', value: 'TODO' },
                { label: 'قيد التنفيذ', value: 'IN_PROGRESS' },
                { label: 'مكتمل', value: 'DONE' },
              ]"
              size="sm"
              @update:model-value="updateTaskStatus(task.id, $event)"
            />
            <UButton
              icon="i-heroicons-trash"
              color="red"
              variant="ghost"
              size="sm"
              @click="deleteTask(task.id)"
            />
          </div>
        </div>
      </UCard>
 
      <div
        v-if="!tasks?.length"
        class="text-center py-12 text-gray-500"
      >
        <p class="text-lg mb-2">لا توجد مهام</p>
        <p class="text-sm">انقر على "مهمة جديدة" للبدء</p>
      </div>
    </div>
 
    <!-- نافذة الإنشاء -->
    <UModal v-model="showCreateModal">
      <UCard>
        <template #header>
          <h3 class="text-lg font-medium">مهمة جديدة</h3>
        </template>
 
        <form @submit.prevent="createTask" class="space-y-4">
          <UFormGroup label="العنوان" required>
            <UInput
              v-model="newTask.title"
              placeholder="عنوان المهمة"
              required
            />
          </UFormGroup>
 
          <UFormGroup label="الوصف">
            <UTextarea
              v-model="newTask.description"
              placeholder="وصف اختياري"
            />
          </UFormGroup>
 
          <UFormGroup label="الأولوية">
            <USelect
              v-model="newTask.priority"
              :options="[
                { label: 'منخفضة', value: 'LOW' },
                { label: 'متوسطة', value: 'MEDIUM' },
                { label: 'عالية', value: 'HIGH' },
              ]"
            />
          </UFormGroup>
 
          <div class="flex justify-end gap-2">
            <UButton
              variant="ghost"
              @click="showCreateModal = false"
            >
              إلغاء
            </UButton>
            <UButton type="submit" color="primary">
              إنشاء
            </UButton>
          </div>
        </form>
      </UCard>
    </UModal>
  </div>
</template>

الخطوة 8: وسيط المصادقة على العميل

أنشئ وسيط تنقل لحماية الصفحات:

// app/middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to) => {
  const { isAuthenticated, fetchUser } = useAuth()
 
  await fetchUser()
 
  if (!isAuthenticated.value) {
    return navigateTo('/login')
  }
})

الخطوة 9: إضافة التحقق مع Zod

ثبّت Zod للتحقق من البيانات على جانب الخادم:

pnpm add zod

أنشئ أداة تحقق مساعدة:

// server/utils/validate.ts
import { z } from 'zod'
 
export const createTaskSchema = z.object({
  title: z.string().min(1, 'العنوان مطلوب').max(200),
  description: z.string().max(1000).optional(),
  priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'),
})
 
export const updateTaskSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  description: z.string().max(1000).nullable().optional(),
  status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).optional(),
  priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
})
 
export const loginSchema = z.object({
  email: z.string().email('بريد إلكتروني غير صالح'),
  password: z.string().min(1, 'كلمة المرور مطلوبة'),
})
 
export const registerSchema = z.object({
  name: z.string().min(2, 'يجب أن يكون الاسم حرفين على الأقل'),
  email: z.string().email('بريد إلكتروني غير صالح'),
  password: z.string().min(8, 'يجب أن تكون كلمة المرور 8 أحرف على الأقل'),
})

ثم حدّث مسار إنشاء المهام لاستخدام التحقق:

// server/api/tasks/index.post.ts (محدّث)
import prisma from '~/server/utils/prisma'
import { createTaskSchema } from '~/server/utils/validate'
 
export default defineEventHandler(async (event) => {
  const user = event.context.user
  const body = await readBody(event)
 
  const result = createTaskSchema.safeParse(body)
  if (!result.success) {
    throw createError({
      statusCode: 400,
      statusMessage: result.error.issues[0].message,
    })
  }
 
  const task = await prisma.task.create({
    data: {
      ...result.data,
      description: result.data.description || null,
      userId: user.id,
    },
  })
 
  return task
})

الخطوة 10: اختبار تطبيقك

شغّل خادم التطوير:

pnpm dev

اختبر التدفق الكامل:

  1. انتقل إلى http://localhost:3000 — تظهر الصفحة الرئيسية
  2. سجّل بالنقر على "إنشاء حساب" وملء النموذج
  3. أنشئ مهمة من لوحة التحكم
  4. غيّر حالة مهمة باستخدام القائمة المنسدلة
  5. صفّي المهام حسب الحالة
  6. احذف مهمة بزر سلة المهملات

تأكد من أن PostgreSQL يعمل وأن DATABASE_URL صحيح قبل تشغيل التطبيق. إذا كنت تستخدم خدمة سحابية مثل Neon فتحقق من أن عنوان IP لجهازك مسموح به.

الخطوة 11: التحضير للإنتاج

تكوين البناء

حدّث nuxt.config.ts للإنتاج:

// nuxt.config.ts (إضافات الإنتاج)
export default defineNuxtConfig({
  // ... التكوين الحالي
 
  nitro: {
    preset: 'node-server',
    compressPublicAssets: true,
  },
 
  app: {
    head: {
      title: 'TaskFlow - مدير المهام',
      meta: [
        { name: 'description', content: 'تطبيق إدارة مهام حديث وفعال' },
      ],
    },
  },
})

البناء والتشغيل

# بناء الإنتاج
pnpm build
 
# التشغيل في الإنتاج
node .output/server/index.mjs

النشر مع Docker

أنشئ ملف Dockerfile:

FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
 
FROM base AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm dlx prisma generate
RUN pnpm build
 
FROM base AS production
WORKDIR /app
COPY --from=build /app/.output .output
COPY --from=build /app/node_modules/.prisma node_modules/.prisma
COPY --from=build /app/prisma prisma
 
ENV NODE_ENV=production
EXPOSE 3000
 
CMD ["node", ".output/server/index.mjs"]
# البناء والتشغيل
docker build -t taskflow .
docker run -p 3000:3000 --env-file .env taskflow

استكشاف الأخطاء وإصلاحها

خطأ "Cannot find module @prisma/client"

تأكد من تنفيذ pnpm dlx prisma generate بعد تثبيت التبعيات.

الجلسات لا تستمر

تحقق من أن SESSION_SECRET معرّف في ملف .env. في الإنتاج استخدم سراً طويلاً وعشوائياً.

خطأ في الاتصال بقاعدة البيانات

تحقق من DATABASE_URL وتأكد من إمكانية الوصول إلى PostgreSQL. للخدمات السحابية تحقق من قواعد جدار الحماية.

مكونات Nuxt UI لا تظهر

تأكد من أن @nuxt/ui موجود في قائمة modules في nuxt.config.ts وأن التبعيات مثبتة.

الخطوات التالية

الآن بعد أن أصبح تطبيقك يعمل يمكنك:

  • إضافة فئات لتنظيم المهام حسب المشروع
  • تنفيذ السحب والإفلات مع لوحة كانبان (vue-draggable)
  • إضافة إشعارات بالبريد الإلكتروني للمهام المتأخرة
  • دمج OAuth مع Google أو GitHub عبر nuxt-auth-utils
  • إضافة اختبارات مع Vitest و Testing Library
  • إعداد SSR انتقائي لتحسين الأداء

الخلاصة

لقد بنيت تطبيق ويب متكامل وشامل باستخدام Nuxt 4 و Vue 3. غطى هذا الدليل:

  • هيكل مشروع Nuxt 4 الجديد مع مجلد app/
  • نظام التوجيه التلقائي القائم على الملفات
  • مسارات API مع محرك Nitro
  • إدارة قاعدة البيانات مع Prisma ORM
  • واجهة Composition API في Vue 3 للمكونات التفاعلية
  • التحقق من البيانات مع Zod
  • النشر مع Docker

يوفر Nuxt 4 تجربة تطوير متكاملة رائعة بفضل تكامله السلس مع جانب الخادم ونظامه الغني بالوحدات. سواء كنت تبني منتجاً أولياً أو تطبيقاً مؤسسياً فإن Nuxt 4 خيار قوي لمشاريع Vue.js الخاصة بك.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على دمج API TTN في نظامك: دليل المطوّر التقني.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة