730 lines
19 KiB
Vue
730 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { onClickOutside, useEventListener } from '@vueuse/core'
|
||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||
import { dashboardApi, notificationsApi } from '@/services/api'
|
||
import { formatDateTime } from '@/utils/format'
|
||
import { resolveAdminInboxLink } from '@/utils/inboxLinks'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useThemeStore, type ThemePreference } from '@/stores/theme'
|
||
import type { AdminNotification, InternalMessage } from '@/types'
|
||
|
||
defineEmits<{
|
||
menuClick: []
|
||
}>()
|
||
|
||
type OpenPanel = 'messages' | 'notifications' | 'theme' | null
|
||
|
||
const router = useRouter()
|
||
const auth = useAuthStore()
|
||
const theme = useThemeStore()
|
||
const openPanel = ref<OpenPanel>(null)
|
||
const actionsRef = ref<HTMLElement | null>(null)
|
||
|
||
const messageList = ref<InternalMessage[]>([])
|
||
const notificationList = ref<AdminNotification[]>([])
|
||
|
||
const unreadMessages = computed(() => messageList.value.filter((m) => !m.isRead).length)
|
||
const unreadNotifications = computed(() => notificationList.value.filter((n) => !n.isRead).length)
|
||
|
||
const themeOptions: { value: ThemePreference; label: string; icon: string }[] = [
|
||
{ value: 'light', label: 'روشن', icon: 'vuesax-linear:sun-1' },
|
||
{ value: 'dark', label: 'تاریک', icon: 'vuesax-linear:moon' },
|
||
{ value: 'system', label: 'سیستم', icon: 'vuesax-linear:mobile' },
|
||
]
|
||
|
||
const themeLabel = computed(
|
||
() => themeOptions.find((o) => o.value === theme.preference)?.label ?? 'سیستم',
|
||
)
|
||
|
||
const themeTriggerIcon = computed(
|
||
() => themeOptions.find((o) => o.value === theme.preference)?.icon ?? 'vuesax-linear:sun-1',
|
||
)
|
||
|
||
void dashboardApi
|
||
.get()
|
||
.then((data) => {
|
||
messageList.value = data.messages ?? []
|
||
notificationList.value = data.notifications ?? []
|
||
})
|
||
.catch(() => {
|
||
messageList.value = []
|
||
notificationList.value = []
|
||
})
|
||
|
||
onClickOutside(actionsRef, () => {
|
||
openPanel.value = null
|
||
})
|
||
|
||
useEventListener(document, 'keydown', (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') openPanel.value = null
|
||
})
|
||
|
||
function togglePanel(panel: Exclude<OpenPanel, null>) {
|
||
openPanel.value = openPanel.value === panel ? null : panel
|
||
}
|
||
|
||
function closePanel() {
|
||
openPanel.value = null
|
||
}
|
||
|
||
function markMessageRead(message: InternalMessage) {
|
||
message.isRead = true
|
||
}
|
||
|
||
function markNotificationRead(notification: AdminNotification) {
|
||
if (notification.isRead) return
|
||
notification.isRead = true
|
||
void notificationsApi.markRead(notification.id).catch(() => {
|
||
notification.isRead = false
|
||
})
|
||
}
|
||
|
||
function markAllMessagesRead() {
|
||
messageList.value.forEach((m) => {
|
||
m.isRead = true
|
||
})
|
||
}
|
||
|
||
function markAllNotificationsRead() {
|
||
const previouslyUnread = notificationList.value.filter((n) => !n.isRead)
|
||
if (!previouslyUnread.length) return
|
||
previouslyUnread.forEach((n) => {
|
||
n.isRead = true
|
||
})
|
||
void notificationsApi.markAllRead().catch(() => {
|
||
previouslyUnread.forEach((n) => {
|
||
n.isRead = false
|
||
})
|
||
})
|
||
}
|
||
|
||
function navigateInboxTarget(target: string | null) {
|
||
closePanel()
|
||
if (!target) return
|
||
void router.push(target)
|
||
}
|
||
|
||
function openMessage(message: InternalMessage) {
|
||
markMessageRead(message)
|
||
navigateInboxTarget(
|
||
resolveAdminInboxLink({
|
||
link: message.link,
|
||
orderId: message.orderId,
|
||
title: message.subject,
|
||
body: message.preview,
|
||
}),
|
||
)
|
||
}
|
||
|
||
function openNotification(notification: AdminNotification) {
|
||
markNotificationRead(notification)
|
||
navigateInboxTarget(
|
||
resolveAdminInboxLink({
|
||
link: notification.link,
|
||
orderId: notification.orderId,
|
||
title: notification.title,
|
||
body: notification.body,
|
||
}),
|
||
)
|
||
}
|
||
|
||
function selectTheme(value: ThemePreference) {
|
||
theme.setPreference(value)
|
||
closePanel()
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<header class="admin-header">
|
||
<div class="admin-header__right">
|
||
<button
|
||
type="button"
|
||
class="admin-header__menu-btn compact-only"
|
||
aria-label="باز کردن منو"
|
||
@click="$emit('menuClick')"
|
||
>
|
||
<AdminIcon name="menu" :size="22" />
|
||
</button>
|
||
</div>
|
||
|
||
<div ref="actionsRef" class="admin-header__left">
|
||
<div class="admin-header__dropdown">
|
||
<button
|
||
type="button"
|
||
class="admin-header__icon-btn"
|
||
:class="{ 'admin-header__icon-btn--active': openPanel === 'messages' }"
|
||
title="پیامهای داخلی"
|
||
:aria-label="`پیامهای داخلی — ${unreadMessages} پیام خواندهنشده`"
|
||
:aria-expanded="openPanel === 'messages'"
|
||
aria-haspopup="true"
|
||
@click.stop="togglePanel('messages')"
|
||
>
|
||
<AdminIcon name="message" :size="22" />
|
||
<span v-if="unreadMessages" class="admin-header__badge" aria-hidden="true">
|
||
{{ unreadMessages }}
|
||
</span>
|
||
<span class="admin-header__icon-label">پیام</span>
|
||
</button>
|
||
|
||
<div
|
||
v-if="openPanel === 'messages'"
|
||
class="admin-header__panel"
|
||
role="dialog"
|
||
aria-label="پیامهای داخلی"
|
||
@click.stop
|
||
>
|
||
<div class="admin-header__panel-header">
|
||
<h2 class="admin-header__panel-title">پیامهای داخلی</h2>
|
||
<button
|
||
v-if="unreadMessages"
|
||
type="button"
|
||
class="admin-header__panel-action"
|
||
@click="markAllMessagesRead"
|
||
>
|
||
علامتگذاری همه بهعنوان خواندهشده
|
||
</button>
|
||
</div>
|
||
|
||
<ul v-if="messageList.length" class="admin-header__panel-list">
|
||
<li
|
||
v-for="message in messageList"
|
||
:key="message.id"
|
||
class="admin-header__panel-item"
|
||
:class="{ unread: !message.isRead }"
|
||
>
|
||
<button type="button" class="admin-header__panel-item-btn" @click="openMessage(message)">
|
||
<span class="admin-header__panel-item-top">
|
||
<strong>{{ message.subject }}</strong>
|
||
<time :datetime="message.sentAt">{{ formatDateTime(message.sentAt) }}</time>
|
||
</span>
|
||
<span class="admin-header__panel-item-sender">از {{ message.senderName }}</span>
|
||
<span class="admin-header__panel-item-body">{{ message.preview }}</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
|
||
<p v-else class="admin-header__panel-empty">پیامی وجود ندارد.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-header__dropdown">
|
||
<button
|
||
type="button"
|
||
class="admin-header__icon-btn"
|
||
:class="{ 'admin-header__icon-btn--active': openPanel === 'notifications' }"
|
||
title="اعلانها"
|
||
:aria-label="`اعلانها — ${unreadNotifications} اعلان جدید`"
|
||
:aria-expanded="openPanel === 'notifications'"
|
||
aria-haspopup="true"
|
||
@click.stop="togglePanel('notifications')"
|
||
>
|
||
<AdminIcon name="bell" :size="22" />
|
||
<span v-if="unreadNotifications" class="admin-header__badge" aria-hidden="true">
|
||
{{ unreadNotifications }}
|
||
</span>
|
||
<span class="admin-header__icon-label">اعلان</span>
|
||
</button>
|
||
|
||
<div
|
||
v-if="openPanel === 'notifications'"
|
||
class="admin-header__panel"
|
||
role="dialog"
|
||
aria-label="اعلانها"
|
||
@click.stop
|
||
>
|
||
<div class="admin-header__panel-header">
|
||
<h2 class="admin-header__panel-title">اعلانها</h2>
|
||
<button
|
||
v-if="unreadNotifications"
|
||
type="button"
|
||
class="admin-header__panel-action"
|
||
@click="markAllNotificationsRead"
|
||
>
|
||
علامتگذاری همه بهعنوان خواندهشده
|
||
</button>
|
||
</div>
|
||
|
||
<ul v-if="notificationList.length" class="admin-header__panel-list">
|
||
<li
|
||
v-for="notification in notificationList"
|
||
:key="notification.id"
|
||
class="admin-header__panel-item"
|
||
:class="[
|
||
{ unread: !notification.isRead },
|
||
notification.tone ? `admin-header__panel-item--${notification.tone}` : '',
|
||
]"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="admin-header__panel-item-btn"
|
||
@click="openNotification(notification)"
|
||
>
|
||
<span class="admin-header__panel-item-top">
|
||
<strong>{{ notification.title }}</strong>
|
||
<time :datetime="notification.sentAt">{{ formatDateTime(notification.sentAt) }}</time>
|
||
</span>
|
||
<span class="admin-header__panel-item-body">{{ notification.body }}</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
|
||
<p v-else class="admin-header__panel-empty">اعلانی وجود ندارد.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-header__dropdown">
|
||
<button
|
||
type="button"
|
||
class="admin-header__icon-btn"
|
||
:class="{ 'admin-header__icon-btn--active': openPanel === 'theme' }"
|
||
title="حالت نمایش"
|
||
:aria-label="`حالت نمایش — ${themeLabel}`"
|
||
:aria-expanded="openPanel === 'theme'"
|
||
aria-haspopup="true"
|
||
@click.stop="togglePanel('theme')"
|
||
>
|
||
<span class="admin-header__theme-glyph" aria-hidden="true">
|
||
<BasicIcon :icon="themeTriggerIcon" :size="20" :stroke-width="1.5" />
|
||
</span>
|
||
<span class="admin-header__icon-label">تم</span>
|
||
</button>
|
||
|
||
<div
|
||
v-if="openPanel === 'theme'"
|
||
class="admin-header__panel admin-header__panel--theme"
|
||
role="dialog"
|
||
aria-label="انتخاب حالت نمایش"
|
||
@click.stop
|
||
>
|
||
<div class="admin-header__panel-header">
|
||
<h2 class="admin-header__panel-title">حالت نمایش</h2>
|
||
</div>
|
||
<ul class="admin-header__theme-list">
|
||
<li v-for="option in themeOptions" :key="option.value">
|
||
<button
|
||
type="button"
|
||
class="admin-header__theme-option"
|
||
:class="{ 'admin-header__theme-option--active': theme.preference === option.value }"
|
||
@click="selectTheme(option.value)"
|
||
>
|
||
<span class="admin-header__theme-option-icon" aria-hidden="true">
|
||
<BasicIcon :icon="option.icon" :size="18" :stroke-width="1.5" />
|
||
</span>
|
||
<span class="admin-header__theme-option-label">{{ option.label }}</span>
|
||
<BasicIcon
|
||
v-if="theme.preference === option.value"
|
||
class="admin-header__theme-option-check"
|
||
icon="vuesax-linear:tick-circle"
|
||
:size="16"
|
||
:stroke-width="1.75"
|
||
/>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-header__user">
|
||
<div class="admin-header__avatar">{{ auth.fullName.charAt(0) || '؟' }}</div>
|
||
<div class="admin-header__user-info">
|
||
<strong>{{ auth.fullName || 'مدیر' }}</strong>
|
||
<span>{{ auth.roleLabel || 'پنل مدیریت' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.admin-header {
|
||
height: var(--header-height);
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 24px;
|
||
padding: 0 28px;
|
||
background: var(--color-surface);
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.admin-header__right {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.admin-header__menu-btn {
|
||
width: 40px;
|
||
height: 40px;
|
||
flex-shrink: 0;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-surface);
|
||
color: var(--color-text);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.admin-header__menu-btn:hover {
|
||
background: var(--color-border-light);
|
||
}
|
||
|
||
.admin-header__left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.admin-header__dropdown {
|
||
position: relative;
|
||
}
|
||
|
||
.admin-header__icon-btn {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 2px;
|
||
min-width: 52px;
|
||
height: 52px;
|
||
padding: 6px 8px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-surface);
|
||
color: var(--color-text);
|
||
transition: background 0.15s, border-color 0.15s;
|
||
}
|
||
|
||
.admin-header__icon-btn:hover,
|
||
.admin-header__icon-btn--active {
|
||
background: var(--color-border-light);
|
||
border-color: #cbd5e1;
|
||
}
|
||
|
||
.admin-header__icon-label {
|
||
font-size: 0.625rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-muted);
|
||
line-height: 1;
|
||
}
|
||
|
||
.admin-header__badge {
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
min-width: 18px;
|
||
height: 18px;
|
||
padding: 0 5px;
|
||
border-radius: 999px;
|
||
background: var(--color-danger);
|
||
color: #fff;
|
||
font-size: 0.625rem;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 2px solid var(--color-surface);
|
||
line-height: 1;
|
||
}
|
||
|
||
.admin-header__panel {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
left: 0;
|
||
z-index: 950;
|
||
width: min(360px, calc(100vw - 32px));
|
||
max-height: min(420px, calc(100dvh - var(--header-height) - 24px));
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: var(--shadow-card);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.admin-header__panel-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
background: var(--color-surface);
|
||
}
|
||
|
||
.admin-header__panel-title {
|
||
margin: 0;
|
||
font-size: 0.9375rem;
|
||
font-weight: 700;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.admin-header__panel-action {
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--color-primary);
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
padding: 0;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.admin-header__panel-action:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.admin-header__panel-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.admin-header__panel-item {
|
||
border-bottom: 1px solid var(--color-border-light);
|
||
}
|
||
|
||
.admin-header__panel-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.admin-header__panel-item.unread {
|
||
background: var(--color-info-light);
|
||
}
|
||
|
||
.admin-header__panel-item-btn {
|
||
width: 100%;
|
||
border: none;
|
||
background: transparent;
|
||
text-align: right;
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.admin-header__panel-item-btn:hover {
|
||
background: var(--color-border-light);
|
||
}
|
||
|
||
.admin-header__panel-item-top {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
|
||
.admin-header__panel-item-top strong {
|
||
font-size: 0.8125rem;
|
||
font-weight: 700;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.admin-header__panel-item-top time {
|
||
flex-shrink: 0;
|
||
font-size: 0.6875rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.admin-header__panel-item-sender {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.admin-header__panel-item-body {
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-muted);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.admin-header__panel-item--warning.unread {
|
||
background: var(--color-warning-light);
|
||
}
|
||
|
||
.admin-header__panel-item--danger.unread {
|
||
background: var(--color-danger-light);
|
||
}
|
||
|
||
.admin-header__panel-item--success.unread {
|
||
background: var(--color-success-light);
|
||
}
|
||
|
||
.admin-header__panel-empty {
|
||
margin: 0;
|
||
padding: 24px 16px;
|
||
text-align: center;
|
||
font-size: 0.875rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.admin-header__user {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding-right: 12px;
|
||
margin-right: 4px;
|
||
border-right: 1px solid var(--color-border);
|
||
}
|
||
|
||
.admin-header__avatar {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
background: var(--color-primary);
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 700;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.admin-header__user-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
|
||
.admin-header__user-info strong {
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.admin-header__user-info span {
|
||
font-size: 0.6875rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.admin-header__theme-glyph {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
line-height: 0;
|
||
color: inherit;
|
||
}
|
||
|
||
.admin-header__panel--theme {
|
||
min-width: 200px;
|
||
}
|
||
|
||
.admin-header__theme-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.admin-header__theme-option {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
text-align: right;
|
||
border: 1px solid transparent;
|
||
border-radius: 12px;
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
padding: 10px 12px;
|
||
font-family: inherit;
|
||
font-size: 0.875rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.admin-header__theme-option-icon {
|
||
width: 36px;
|
||
height: 36px;
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 10px;
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-border);
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.admin-header__theme-option-label {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.admin-header__theme-option-check {
|
||
flex-shrink: 0;
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.admin-header__theme-option:hover {
|
||
background: var(--color-border-light);
|
||
}
|
||
|
||
.admin-header__theme-option--active {
|
||
border-color: var(--color-primary);
|
||
background: var(--color-primary-soft);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.admin-header__theme-option--active .admin-header__theme-option-icon {
|
||
background: var(--color-primary);
|
||
border-color: var(--color-primary);
|
||
color: #fff;
|
||
}
|
||
|
||
@media (max-width: 767px) {
|
||
.admin-header {
|
||
padding: 0 16px;
|
||
gap: 12px;
|
||
}
|
||
|
||
.admin-header__icon-label {
|
||
display: none;
|
||
}
|
||
|
||
.admin-header__icon-btn {
|
||
min-width: 40px;
|
||
height: 40px;
|
||
padding: 0;
|
||
}
|
||
|
||
.admin-header__panel {
|
||
position: fixed;
|
||
top: var(--header-height);
|
||
left: 16px;
|
||
right: 16px;
|
||
width: auto;
|
||
max-height: calc(100dvh - var(--header-height) - 16px);
|
||
}
|
||
|
||
.admin-header__panel-header {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.admin-header__panel-action {
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.admin-header__user-info {
|
||
display: none;
|
||
}
|
||
|
||
.admin-header__user {
|
||
border-right: none;
|
||
padding-right: 0;
|
||
margin-right: 0;
|
||
}
|
||
}
|
||
</style>
|