Add admin login page with session auth, route guard, and RTL form layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -172,6 +172,23 @@ defineProps<{
|
||||
<circle cx="9" cy="12" r="2" />
|
||||
<path d="M14 10h5M14 14h5" />
|
||||
</template>
|
||||
<!-- lock -->
|
||||
<template v-else-if="name === 'lock'">
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<path d="M8 11V8a4 4 0 1 1 8 0v3" />
|
||||
</template>
|
||||
<!-- eye -->
|
||||
<template v-else-if="name === 'eye'">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</template>
|
||||
<!-- eye-off -->
|
||||
<template v-else-if="name === 'eye-off'">
|
||||
<path d="M10.6 10.6a2.5 2.5 0 0 0 3.5 3.5" />
|
||||
<path d="M6.7 6.7C4.6 8.2 3 10.2 2 12s3.5 7 10 7c1.8 0 3.4-.4 4.8-1.1" />
|
||||
<path d="M17.3 17.3C19.4 15.8 21 13.8 22 12s-3.5-7-10-7c-1.8 0-3.4.4-4.8 1.1" />
|
||||
<path d="M2 2l20 20" />
|
||||
</template>
|
||||
<!-- user -->
|
||||
<template v-else-if="name === 'user'">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { adminNotifications, internalMessages } from '@/data/mockData'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminNotification, InternalMessage } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
@@ -17,6 +18,7 @@ defineEmits<{
|
||||
type OpenPanel = 'messages' | 'notifications' | null
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const openPanel = ref<OpenPanel>(null)
|
||||
const actionsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -225,10 +227,10 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="admin-header__user">
|
||||
<div class="admin-header__avatar">ع</div>
|
||||
<div class="admin-header__avatar">{{ auth.fullName.charAt(0) || '؟' }}</div>
|
||||
<div class="admin-header__user-info">
|
||||
<strong>علی محمدی</strong>
|
||||
<span>مدیر سیستم</span>
|
||||
<strong>{{ auth.fullName || 'مدیر' }}</strong>
|
||||
<span>{{ auth.roleLabel || 'پنل مدیریت' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ADMIN_NAV_ITEMS, isNavActive } from '@/utils/navigation'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
@@ -13,11 +14,18 @@ const emit = defineEmits<{
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -55,7 +63,7 @@ function navigate(path: string) {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button type="button" class="admin-drawer__logout" @click="navigate('/login')">
|
||||
<button type="button" class="admin-drawer__logout" @click="logout">
|
||||
<AdminIcon name="logout" :size="20" />
|
||||
<span>خروج</span>
|
||||
</button>
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ADMIN_NAV_ITEMS, isNavActive } from '@/utils/navigation'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,7 +39,7 @@ function navigate(path: string) {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button type="button" class="admin-sidebar__logout" @click="navigate('/login')">
|
||||
<button type="button" class="admin-sidebar__logout" @click="logout">
|
||||
<AdminIcon name="logout" :size="20" />
|
||||
<span>خروج</span>
|
||||
</button>
|
||||
|
||||
+5
-1
@@ -3,14 +3,18 @@ import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
import '@/assets/styles/main.css'
|
||||
import '@/assets/styles/admin.css'
|
||||
import '@/assets/styles/responsive.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
useAuthStore(pinia).hydrate()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AdminShell from '@/layouts/AdminShell.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: { public: true, title: 'ورود به پنل مدیریت' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AdminShell,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -102,4 +110,25 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore()
|
||||
auth.hydrate()
|
||||
|
||||
if (to.meta.public) {
|
||||
if (auth.isAuthenticated && to.name === 'login') {
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (to.matched.some((record) => record.meta.requiresAuth) && !auth.isAuthenticated) {
|
||||
return {
|
||||
name: 'login',
|
||||
query: { redirect: to.fullPath },
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AdminLoginPayload, AdminLoginResult, AdminSession } from '@/types/auth'
|
||||
import { adminUsers } from '@/data/mockData'
|
||||
|
||||
const STORAGE_KEY = 'fabric-admin-session'
|
||||
const MOCK_PASSWORD = 'admin123'
|
||||
|
||||
function readSession(): AdminSession | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY) ?? sessionStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as AdminSession
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(session: AdminSession, remember: boolean) {
|
||||
const payload = JSON.stringify(session)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
sessionStorage.removeItem(STORAGE_KEY)
|
||||
if (remember) {
|
||||
localStorage.setItem(STORAGE_KEY, payload)
|
||||
} else {
|
||||
sessionStorage.setItem(STORAGE_KEY, payload)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
sessionStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function getStoredSession(): AdminSession | null {
|
||||
return readSession()
|
||||
}
|
||||
|
||||
export async function loginAdmin(payload: AdminLoginPayload): Promise<AdminLoginResult> {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 450))
|
||||
|
||||
const phone = payload.phone.trim()
|
||||
const password = payload.password
|
||||
|
||||
if (!phone || !password) {
|
||||
return { success: false, message: 'شماره موبایل و رمز عبور الزامی است.' }
|
||||
}
|
||||
|
||||
const user = adminUsers.find((item) => item.phone === phone && item.isActive)
|
||||
|
||||
if (!user) {
|
||||
return { success: false, message: 'کاربر یافت نشد یا حساب غیرفعال است.' }
|
||||
}
|
||||
|
||||
if (password !== MOCK_PASSWORD) {
|
||||
return { success: false, message: 'رمز عبور نادرست است.' }
|
||||
}
|
||||
|
||||
const session: AdminSession = {
|
||||
id: user.id,
|
||||
fullName: user.fullName,
|
||||
phone: user.phone,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
}
|
||||
|
||||
writeSession(session, payload.remember)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export function logoutAdmin() {
|
||||
clearSession()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { AdminSession } from '@/types/auth'
|
||||
import {
|
||||
getStoredSession,
|
||||
loginAdmin,
|
||||
logoutAdmin,
|
||||
} from '@/services/authService'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const session = ref<AdminSession | null>(getStoredSession())
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!session.value)
|
||||
const fullName = computed(() => session.value?.fullName ?? '')
|
||||
const roleLabel = computed(() => {
|
||||
const map = {
|
||||
admin: 'مدیر سیستم',
|
||||
sales: 'واحد فروش',
|
||||
finance: 'واحد مالی',
|
||||
warehouse: 'انبار',
|
||||
} as const
|
||||
return session.value ? map[session.value.role] : ''
|
||||
})
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function hydrate() {
|
||||
session.value = getStoredSession()
|
||||
}
|
||||
|
||||
async function login(phone: string, password: string, remember: boolean) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const result = await loginAdmin({ phone, password, remember })
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
session.value = getStoredSession()
|
||||
return true
|
||||
}
|
||||
|
||||
error.value = result.message ?? 'ورود انجام نشد.'
|
||||
return false
|
||||
}
|
||||
|
||||
function logout() {
|
||||
logoutAdmin()
|
||||
session.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
fullName,
|
||||
roleLabel,
|
||||
clearError,
|
||||
hydrate,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface AdminSession {
|
||||
id: string
|
||||
fullName: string
|
||||
phone: string
|
||||
email: string
|
||||
role: 'admin' | 'sales' | 'finance' | 'warehouse'
|
||||
}
|
||||
|
||||
export interface AdminLoginPayload {
|
||||
phone: string
|
||||
password: string
|
||||
remember: boolean
|
||||
}
|
||||
|
||||
export interface AdminLoginResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const phone = ref('۰۹۱۲۱۱۱۲۲۲۲')
|
||||
const password = ref('')
|
||||
const remember = ref(true)
|
||||
const showPassword = ref(false)
|
||||
|
||||
const isFormValid = computed(() => phone.value.trim().length > 0 && password.value.length > 0)
|
||||
|
||||
async function submit() {
|
||||
if (!isFormValid.value) {
|
||||
auth.error = 'لطفاً شماره موبایل و رمز عبور را وارد کنید.'
|
||||
return
|
||||
}
|
||||
|
||||
const ok = await auth.login(phone.value.trim(), password.value, remember.value)
|
||||
if (!ok) return
|
||||
|
||||
toast.success(`خوش آمدید، ${auth.fullName}`)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||
router.replace(redirect)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-page__form-wrap">
|
||||
<div class="login-page__card admin-card">
|
||||
<div class="login-page__header">
|
||||
<h2 class="login-page__title">ورود به پنل مدیریت</h2>
|
||||
<p class="login-page__subtitle">برای ادامه، اطلاعات حساب کاربری خود را وارد کنید.</p>
|
||||
</div>
|
||||
|
||||
<form class="admin-form login-page__form" @submit.prevent="submit">
|
||||
<p v-if="auth.error" class="admin-form__error" role="alert">{{ auth.error }}</p>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="login-phone">شماره موبایل</label>
|
||||
<div class="login-page__input-wrap">
|
||||
<AdminIcon name="phone" :size="18" />
|
||||
<input
|
||||
id="login-phone"
|
||||
v-model="phone"
|
||||
type="tel"
|
||||
class="admin-input login-page__input"
|
||||
placeholder="۰۹۱۲۱۱۱۱۲۲۲۲"
|
||||
autocomplete="username"
|
||||
@input="auth.clearError()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="login-password">رمز عبور</label>
|
||||
<div class="login-page__input-wrap">
|
||||
<AdminIcon name="lock" :size="18" />
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="admin-input login-page__input"
|
||||
placeholder="رمز عبور"
|
||||
autocomplete="current-password"
|
||||
@input="auth.clearError()"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="login-page__toggle-password"
|
||||
:aria-label="showPassword ? 'پنهان کردن رمز' : 'نمایش رمز'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<AdminIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="login-page__remember">
|
||||
<input v-model="remember" type="checkbox" />
|
||||
<span>مرا به خاطر بسپار</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="admin-btn admin-btn--primary login-page__submit" :disabled="!isFormValid || auth.loading">
|
||||
<span v-if="auth.loading" class="admin-btn__spinner" aria-hidden="true" />
|
||||
ورود به پنل
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login-page__hint">
|
||||
حساب آزمایشی: <span dir="ltr">09121112222</span> / رمز: <span dir="ltr">admin123</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-page__brand">
|
||||
<div class="login-page__brand-inner">
|
||||
<p class="login-page__brand-badge">پَر | فام بافت</p>
|
||||
<h1 class="login-page__brand-title">پنل مدیریت سفارشهای عمده</h1>
|
||||
<p class="login-page__brand-text">
|
||||
مدیریت سفارشها، مشتریان، قیمتگذاری، چکها و گزارشهای فروش در یک محیط یکپارچه.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.login-page__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 40px;
|
||||
background: linear-gradient(160deg, #1a2542 0%, #243456 55%, #17379f 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-page__brand-inner {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.login-page__brand-badge {
|
||||
display: inline-flex;
|
||||
margin: 0 0 16px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-page__brand-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: clamp(1.5rem, 2.4vw, 2rem);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.login-page__brand-text {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
line-height: 1.8;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.login-page__form-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.login-page__card {
|
||||
width: min(100%, 420px);
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.login-page__header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-page__title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-page__subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-page__input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.login-page__input {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.login-page__toggle-password {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.login-page__toggle-password:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.login-page__remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.login-page__remember input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.login-page__submit {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-page__hint {
|
||||
margin: 16px 0 0;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.login-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-page__form-wrap {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.login-page__brand {
|
||||
order: 1;
|
||||
padding: 28px 20px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.login-page__brand-text {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user