feat: complete UI/UX improvements, vuesax icons, media server, and section management

This commit is contained in:
s.esmaeili
2026-10-23 21:06:15 +03:30
parent 49f30a96b1
commit fa61a76444
64 changed files with 4527 additions and 819 deletions
+7
View File
@@ -75,3 +75,10 @@ export interface SectionResponse<K extends SectionKey = SectionKey> {
data: SiteSections[K];
updatedAt: string;
}
export interface AdminSectionSummary {
key: SectionKey;
updatedAt: string | null;
enabled: boolean;
}
+27
View File
@@ -46,4 +46,31 @@ defineProps<{ lockBody?: boolean }>();
flex-direction: column;
overflow: hidden;
}
@media (max-width: 1023px) {
.ap {
height: auto;
min-height: 100%;
overflow: visible;
}
.ap__head {
position: sticky;
top: 52px;
z-index: 20;
padding: 10px 12px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.03);
}
.ap__body {
padding: 12px;
overflow: visible;
}
.ap__body--locked {
overflow: visible;
height: auto;
}
}
</style>
+9 -11
View File
@@ -1,20 +1,18 @@
<script setup lang="ts">
import { ICON_NAMES, type IconName } from "@imen/shared";
import { computed } from "vue";
import { ICONS } from "./icons";
import { RECOMMENDED_ICONS } from "@imen/shared";
import VuesaxIcon from "./VuesaxIcon.vue";
/** 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]);
const options = RECOMMENDED_ICONS.map((name) => ({ value: name, label: name }));
</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 class="flex size-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-[#ff5a00]">
<VuesaxIcon v-if="value" :name="value" :size="20" />
<span v-else class="text-xs text-gray-400">---</span>
</span>
<a-select
v-model:value="value"
@@ -25,9 +23,9 @@ const current = computed(() => ICONS[value.value as IconName]);
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 class="flex items-center gap-2.5 py-0.5" dir="ltr">
<VuesaxIcon :name="name" :size="18" class="text-orange-600" />
<span class="text-xs font-mono">{{ name }}</span>
</span>
</template>
</a-select>
+31 -8
View File
@@ -1,11 +1,15 @@
<script setup lang="ts">
import VuesaxIcon from "@/components/VuesaxIcon.vue";
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>
<RouterLink v-if="back" :to="back" class="page-header__back" title="بازگشت">
<VuesaxIcon name="arrow-right" :size="18" />
</RouterLink>
<div class="min-w-0">
<h1 class="page-header__title">{{ title }}</h1>
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
@@ -19,42 +23,61 @@ defineProps<{ title: string; subtitle?: string; back?: string }>();
<style scoped>
.page-header {
flex: 0 0 auto !important;
flex-shrink: 0 !important;
flex-grow: 0 !important;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 10px;
}
.page-header__main {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
min-width: 0;
}
.page-header__back {
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: #f1f5f9;
color: #475569;
text-decoration: none;
transition: color 0.15s ease;
transition: all 0.15s ease;
flex-shrink: 0;
}
.page-header__back:hover {
color: #334155;
background: #e2e8f0;
color: #0f172a;
}
.page-header__title {
margin: 0;
font-size: 18px;
font-size: 16px;
font-weight: 700;
line-height: 1.3;
color: #0f172a;
}
@media (min-width: 640px) {
.page-header__title {
font-size: 18px;
}
}
.page-header__subtitle {
margin: 2px 0 0;
font-size: 13px;
font-size: 12px;
color: #94a3b8;
line-height: 1.4;
}
.page-header__actions {
+7 -6
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed } from "vue";
import { resolveVuesaxIcon } from "@imen/shared";
import icons from "@/icons/vuesax-linear.json";
const props = withDefaults(
defineProps<{
/** Short name (`add`) or full key (`vuesax-linear:add`) */
/** Short name (`add`), alias (`ShieldCheck`, `Phone`), or full key (`vuesax-linear:add`) */
name: string;
size?: number | string;
}>(),
@@ -14,10 +15,9 @@ const props = withDefaults(
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];
const resolved = resolveVuesaxIcon(props.name || "");
const key = resolved.includes(":") ? resolved : `vuesax-linear:${resolved}`;
const raw = pack[key] || pack[`vuesax-linear:${resolved.toLowerCase()}`] || pack["vuesax-linear:shield-tick"];
if (!raw) return "";
return raw
.replace(/\swidth="24"/, "")
@@ -33,7 +33,7 @@ const box = computed(() =>
<template>
<span
class="vx-icon"
class="anticon vx-icon"
:style="{ width: box, height: box }"
:aria-hidden="true"
v-html="svgHtml"
@@ -48,6 +48,7 @@ const box = computed(() =>
justify-content: center;
line-height: 0;
color: inherit;
vertical-align: -0.15em;
}
.vx-icon :deep(svg) {
+2 -104
View File
@@ -1,104 +1,2 @@
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,
};
export { ICON_ALIASES, ICON_NAMES, RECOMMENDED_ICONS, resolveVuesaxIcon } from "@imen/shared";
export type { IconName } from "@imen/shared";
+509 -68
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ROLE_LABELS } from "@imen/shared";
import { computed, h, ref, watch } from "vue";
import { computed, h, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { api } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import { useAuthStore } from "@/stores/auth";
import { SITE_URL } from "@/utils/format";
@@ -10,13 +11,28 @@ const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const drawer = ref(false);
const unreadCount = ref(0);
async function fetchUnread() {
try {
const res = await api<{ total: number }>("/admin/messages", {
query: { status: "NEW", pageSize: 1 },
});
unreadCount.value = res.total;
} catch {
// ignore
}
}
onMounted(fetchUnread);
watch(() => route.fullPath, fetchUnread);
/** 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 })]);
h("span", { class: "admin-menu__icon" }, [h(VuesaxIcon, { name, size: 20 })]);
const items = computed(() => [
{ key: "dashboard", icon: menuIcon("element-3"), label: "داشبورد" },
{ key: "dashboard", icon: menuIcon("chart-square"), label: "داشبورد" },
{ key: "products", icon: menuIcon("bag-2"), label: "محصولات" },
{ key: "categories", icon: menuIcon("category"), label: "دسته‌بندی‌ها" },
{ key: "sections", icon: menuIcon("row-vertical"), label: "بخش‌های سایت" },
@@ -58,16 +74,35 @@ const selected = computed(() => {
const pageTitle = computed(() => PAGE_TITLES[String(route.name ?? "")] ?? "پنل مدیریت");
const userInitial = computed(() => (auth.user?.name?.trim().charAt(0) || "ک").slice(0, 1));
const isChildRoute = computed(() => {
const name = String(route.name ?? "");
return ["product-new", "product-edit", "category-new", "category-edit", "section-edit"].includes(name);
});
const parentBack = computed(() => {
const name = String(route.name ?? "");
if (name.startsWith("product")) return { name: "products", label: "محصولات" };
if (name.startsWith("category")) return { name: "categories", label: "دسته‌بندی‌ها" };
if (name.startsWith("section")) return { name: "sections", label: "بخش‌های سایت" };
return { name: "dashboard", label: "داشبورد" };
});
const go = ({ key }: { key: string | number }) => {
drawer.value = false;
void router.push({ name: String(key) });
};
const navigateTo = (name: string) => {
drawer.value = false;
void router.push({ name });
};
watch(() => route.fullPath, () => (drawer.value = false));
</script>
<template>
<a-layout class="admin-shell">
<!-- Desktop Sider (lg and up) -->
<a-layout-sider
:width="248"
breakpoint="lg"
@@ -97,42 +132,250 @@ watch(() => route.fullPath, () => (drawer.value = false));
</div>
</a-layout-sider>
<!-- Mobile App Hub Drawer (Rich App Grid) -->
<a-drawer
v-model:open="drawer"
placement="right"
:width="280"
:width="320"
:closable="false"
class="admin-drawer"
:body-style="{ padding: 0, background: '#ffffff' }"
class="mobile-hub-drawer"
:body-style="{ padding: 0, background: '#f8fafc' }"
>
<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 class="flex flex-col h-full">
<!-- Hub User Profile Header -->
<div class="p-4 bg-white border-b border-slate-200/80">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2.5">
<span class="size-8 rounded-lg bg-orange-50 border border-orange-100 flex items-center justify-center">
<img src="/logo.png" alt="" class="size-5 object-contain" />
</span>
<div>
<div class="text-sm font-bold text-slate-800">ایمن پیشگامان</div>
<div class="text-[10px] text-slate-400">اپلیکیشن مدیریت</div>
</div>
</div>
<button
type="button"
class="size-8 rounded-full flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition"
@click="drawer = false"
>
<VuesaxIcon name="close-circle" :size="20" />
</button>
</div>
<div
class="flex items-center gap-3 p-2.5 rounded-xl bg-slate-50 border border-slate-100 cursor-pointer active:scale-[0.99] transition"
@click="navigateTo('profile')"
>
<span class="size-10 rounded-full bg-linear-to-br from-[#ff5a00] to-[#ff8a3d] text-white flex items-center justify-center font-bold text-sm shadow-xs">
{{ userInitial }}
</span>
<div class="min-w-0 flex-1">
<div class="text-xs font-bold text-slate-800 truncate">{{ auth.user?.name }}</div>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[10px] text-orange-600 font-medium">
{{ auth.user && ROLE_LABELS[auth.user.role] }}
</span>
<span class="text-[10px] text-slate-400 font-mono" dir="ltr">{{ auth.user?.phone }}</span>
</div>
</div>
<VuesaxIcon name="arrow-left" :size="14" class="text-slate-400" />
</div>
</div>
<!-- Hub Apps Grid Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<div>
<div class="text-[11px] font-bold text-slate-400 mb-2.5 px-1">بخش‌ها و کاتالوگ</div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-orange-200 active:bg-orange-50/40 transition"
:class="{ '!border-orange-500 !bg-orange-50/30 font-bold text-orange-600': route.name === 'dashboard' }"
@click="navigateTo('dashboard')"
>
<div class="size-9 rounded-lg bg-orange-50 text-orange-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="chart-square" :size="18" />
</div>
<span class="text-xs text-slate-700">داشبورد</span>
<span class="text-[9px] text-slate-400 mt-0.5">نمای کلی آمار</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-orange-200 active:bg-orange-50/40 transition"
:class="{ '!border-orange-500 !bg-orange-50/30 font-bold text-orange-600': selected.includes('products') }"
@click="navigateTo('products')"
>
<div class="size-9 rounded-lg bg-orange-50 text-orange-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="bag-2" :size="18" />
</div>
<span class="text-xs text-slate-700">محصولات</span>
<span class="text-[9px] text-slate-400 mt-0.5">کاتالوگ و موجودی</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-blue-200 active:bg-blue-50/40 transition"
:class="{ '!border-blue-500 !bg-blue-50/30 font-bold text-blue-600': selected.includes('categories') }"
@click="navigateTo('categories')"
>
<div class="size-9 rounded-lg bg-blue-50 text-blue-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="category" :size="18" />
</div>
<span class="text-xs text-slate-700">دسته‌بندی‌ها</span>
<span class="text-[9px] text-slate-400 mt-0.5">گروه‌های کالا</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-amber-200 active:bg-amber-50/40 transition relative"
:class="{ '!border-amber-500 !bg-amber-50/30 font-bold text-amber-600': route.name === 'messages' }"
@click="navigateTo('messages')"
>
<span v-if="unreadCount > 0" class="absolute top-2 end-2 size-2.5 rounded-full bg-orange-600 ring-2 ring-white animate-pulse" />
<div class="size-9 rounded-lg bg-amber-50 text-amber-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="sms" :size="18" />
</div>
<span class="text-xs text-slate-700">پیام‌های تماس</span>
<span class="text-[9px] text-slate-400 mt-0.5">درخواست‌های مشاوره</span>
</button>
</div>
</div>
<div>
<div class="text-[11px] font-bold text-slate-400 mb-2.5 px-1">محتوا و وب‌سایت</div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-purple-200 active:bg-purple-50/40 transition"
:class="{ '!border-purple-500 !bg-purple-50/30 font-bold text-purple-600': route.name === 'media' }"
@click="navigateTo('media')"
>
<div class="size-9 rounded-lg bg-purple-50 text-purple-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="gallery" :size="18" />
</div>
<span class="text-xs text-slate-700">کتابخانه رسانه</span>
<span class="text-[9px] text-slate-400 mt-0.5">تصاویر WebP</span>
</button>
<button
type="button"
class="flex flex-col items-center justify-center p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-emerald-200 active:bg-emerald-50/40 transition"
:class="{ '!border-emerald-500 !bg-emerald-50/30 font-bold text-emerald-600': selected.includes('sections') }"
@click="navigateTo('sections')"
>
<div class="size-9 rounded-lg bg-emerald-50 text-emerald-600 flex items-center justify-center mb-1.5">
<VuesaxIcon name="row-vertical" :size="18" />
</div>
<span class="text-xs text-slate-700">بخش‌های سایت</span>
<span class="text-[9px] text-slate-400 mt-0.5">تنظیمات صفحه اصلی</span>
</button>
</div>
</div>
<div v-if="auth.isAdmin">
<div class="text-[11px] font-bold text-slate-400 mb-2.5 px-1">مدیریت سیستم</div>
<div class="grid grid-cols-1 gap-2">
<button
type="button"
class="flex items-center gap-3 p-3 rounded-xl bg-white border border-slate-200/70 shadow-2xs hover:border-slate-300 active:bg-slate-50 transition text-right"
:class="{ '!border-orange-500 font-bold': route.name === 'users' }"
@click="navigateTo('users')"
>
<div class="size-9 rounded-lg bg-slate-100 text-slate-600 flex items-center justify-center shrink-0">
<VuesaxIcon name="profile-2user" :size="18" />
</div>
<div class="flex-1 min-w-0">
<div class="text-xs font-bold text-slate-800">مدیریت کاربران پنل</div>
<div class="text-[10px] text-slate-400">حساب‌ها و سطح دسترسی‌ها</div>
</div>
<VuesaxIcon name="arrow-left" :size="14" class="text-slate-400" />
</button>
</div>
</div>
</div>
<!-- Hub Footer System Actions -->
<div class="p-3 bg-white border-t border-slate-200/80 space-y-2">
<a
:href="SITE_URL"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-2.5 rounded-lg text-slate-600 hover:bg-slate-50 text-xs font-semibold transition"
>
<span class="flex items-center gap-2">
<VuesaxIcon name="export" :size="16" class="text-slate-400" />
مشاهده وب‌سایت اصلی
</span>
<VuesaxIcon name="arrow-left" :size="12" class="text-slate-300" />
</a>
<button
type="button"
class="w-full flex items-center justify-center gap-2 p-2.5 rounded-lg text-rose-600 hover:bg-rose-50 text-xs font-semibold transition"
@click="auth.logout()"
>
<VuesaxIcon name="logout" :size="16" />
خروج از حساب کاربری
</button>
</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>
<!-- Dedicated Mobile App Bar (Mobile Only) -->
<header class="mobile-app-bar lg:!hidden">
<div v-if="isChildRoute" class="flex items-center justify-between w-full">
<button
type="button"
class="mobile-app-bar__back-btn"
@click="router.push(parentBack)"
>
<VuesaxIcon name="arrow-right" :size="20" />
<span class="text-xs font-bold text-slate-700">بازگشت به {{ parentBack.label }}</span>
</button>
<div class="text-xs font-bold text-slate-800 truncate max-w-[140px]">
{{ pageTitle }}
</div>
</div>
<div v-else class="flex items-center justify-between w-full">
<div class="flex items-center gap-2.5">
<span class="size-8 rounded-lg bg-orange-50 border border-orange-100 flex items-center justify-center">
<img src="/logo.png" alt="" class="size-5 object-contain" />
</span>
<div class="leading-none">
<div class="text-xs font-extrabold text-slate-800">ایمن پیشگامان</div>
<div class="text-[10px] font-medium text-orange-600 mt-0.5">{{ pageTitle }}</div>
</div>
</div>
<div class="flex items-center gap-1.5">
<a
:href="SITE_URL"
target="_blank"
rel="noopener"
class="size-8 rounded-lg flex items-center justify-center text-slate-500 hover:text-orange-600 hover:bg-orange-50 transition"
title="مشاهده سایت"
>
<VuesaxIcon name="export" :size="18" />
</a>
<button
type="button"
class="size-8 rounded-lg bg-linear-to-br from-[#ff5a00] to-[#ff8a3d] text-white flex items-center justify-center font-bold text-xs shadow-xs"
@click="router.push({ name: 'profile' })"
>
{{ userInitial }}
</button>
</div>
</div>
</header>
<!-- Desktop Header (Desktop Only) -->
<a-layout-header class="admin-header !hidden lg:!flex">
<div class="admin-header__start">
<div class="admin-header__crumb">
<span class="admin-header__crumb-kicker">پنل مدیریت</span>
<span class="admin-header__crumb-sep" aria-hidden="true">/</span>
@@ -179,13 +422,69 @@ watch(() => route.fullPath, () => (drawer.value = false));
</div>
</a-layout-header>
<a-layout-content class="admin-content">
<!-- Content Viewport -->
<a-layout-content class="admin-content" :class="{ 'admin-content--has-bottom-nav': !isChildRoute }">
<div class="admin-page">
<RouterView v-slot="{ Component, route: r }">
<component :is="Component" :key="r.path" />
</RouterView>
</div>
</a-layout-content>
<!-- Purpose-Built Mobile Bottom Navigation Bar -->
<nav v-if="!isChildRoute" class="mobile-nav-bar lg:!hidden">
<button
type="button"
class="mobile-nav-item"
:class="{ 'mobile-nav-item--active': route.name === 'dashboard' }"
@click="navigateTo('dashboard')"
>
<div class="mobile-nav-item__icon">
<VuesaxIcon name="chart-square" :size="20" />
</div>
<span class="mobile-nav-item__label">داشبورد</span>
</button>
<button
type="button"
class="mobile-nav-item"
:class="{ 'mobile-nav-item--active': selected.includes('products') }"
@click="navigateTo('products')"
>
<div class="mobile-nav-item__icon">
<VuesaxIcon name="bag-2" :size="20" />
</div>
<span class="mobile-nav-item__label">محصولات</span>
</button>
<button
type="button"
class="mobile-nav-item"
:class="{ 'mobile-nav-item--active': route.name === 'messages' }"
@click="navigateTo('messages')"
>
<div class="mobile-nav-item__icon relative">
<VuesaxIcon name="sms" :size="20" />
<span
v-if="unreadCount > 0"
class="absolute -top-1 -end-1 size-2 rounded-full bg-orange-600 ring-2 ring-white"
/>
</div>
<span class="mobile-nav-item__label">پیام‌ها</span>
</button>
<button
type="button"
class="mobile-nav-item"
:class="{ 'mobile-nav-item--active': drawer || ['categories', 'sections', 'media', 'users', 'profile'].includes(String(route.name ?? '')) }"
@click="drawer = true"
>
<div class="mobile-nav-item__icon">
<VuesaxIcon name="category" :size="20" />
</div>
<span class="mobile-nav-item__label">ابزارها</span>
</button>
</nav>
</a-layout>
</a-layout>
</template>
@@ -247,14 +546,22 @@ watch(() => route.fullPath, () => (drawer.value = false));
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.admin-page > :deep(*) {
.admin-page > :deep(.page-header) {
flex: 0 0 auto !important;
overflow: visible !important;
padding: 16px 20px 14px !important;
border-bottom: 1px solid #eef1f6;
background: #ffffff;
}
.admin-page > :deep(*:not(.page-header)) {
flex: 1;
min-width: 0;
min-height: 0;
}
/* Pages that don't use AdminPage still scroll inside the panel */
.admin-page > :deep(*:not(.ap)) {
.admin-page > :deep(*:not(.ap):not(.page-header)) {
overflow: auto;
padding: 16px;
box-sizing: border-box;
@@ -276,8 +583,9 @@ watch(() => route.fullPath, () => (drawer.value = false));
.admin-header__menu-btn {
border-radius: 10px !important;
border-color: #e2e8f0 !important;
border: none !important;
color: #475569 !important;
background: transparent !important;
}
.admin-header__crumb {
@@ -325,26 +633,21 @@ watch(() => route.fullPath, () => (drawer.value = false));
align-items: center;
gap: 8px;
height: 36px;
padding-inline: 14px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #ffffff;
padding-inline: 8px;
border: none !important;
background: transparent !important;
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;
transition: color 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);
color: #ff5a00;
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.admin-header__divider {
@@ -362,7 +665,7 @@ watch(() => route.fullPath, () => (drawer.value = false));
padding-block: 4px;
padding-inline: 4px 10px;
border: 1px solid #e8ecf2;
border-radius: 999px;
border-radius: var(--ant-border-radius, 8px);
background: #f8fafc;
color: inherit;
cursor: pointer;
@@ -385,7 +688,7 @@ watch(() => route.fullPath, () => (drawer.value = false));
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 999px;
border-radius: calc(var(--ant-border-radius, 8px) - 2px);
background: linear-gradient(135deg, #ff5a00, #ff8a3d);
color: #ffffff;
font-size: 14px;
@@ -498,14 +801,14 @@ watch(() => route.fullPath, () => (drawer.value = false));
.admin-menu :deep(.ant-menu-item) {
display: flex;
align-items: center;
height: 44px !important;
height: 48px !important;
margin: 0 0 6px !important;
padding-inline: 12px !important;
padding-inline: 10px 14px !important;
border: 1px solid transparent !important;
border-radius: 12px !important;
color: #475569 !important;
font-weight: 500;
line-height: 44px !important;
line-height: 48px !important;
transition:
background 0.2s ease,
color 0.2s ease,
@@ -516,11 +819,13 @@ watch(() => route.fullPath, () => (drawer.value = false));
.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;
align-items: center !important;
justify-content: center !important;
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
min-height: 36px !important;
margin-inline-end: 12px !important;
font-size: 0 !important;
background: transparent !important;
box-shadow: none !important;
@@ -528,12 +833,14 @@ watch(() => route.fullPath, () => (drawer.value = false));
.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;
align-items: center !important;
justify-content: center !important;
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
min-height: 36px !important;
flex-shrink: 0 !important;
border-radius: 10px !important;
background: #f1f5f9;
color: #64748b;
transition:
@@ -543,11 +850,22 @@ watch(() => route.fullPath, () => (drawer.value = false));
}
.admin-menu :deep(.admin-menu__icon .vx-icon) {
width: 16px !important;
height: 16px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
min-width: 20px !important;
min-height: 20px !important;
background: transparent !important;
box-shadow: none !important;
color: inherit;
color: inherit !important;
}
.admin-menu :deep(.admin-menu__icon svg) {
width: 20px !important;
height: 20px !important;
display: block !important;
}
.admin-menu :deep(.ant-menu-item:hover) {
@@ -556,9 +874,9 @@ watch(() => route.fullPath, () => (drawer.value = false));
border-color: #eef1f6 !important;
}
.admin-menu :deep(.ant-menu-item:hover .admin-menu__icon) {
background: #e8eef7;
color: #475569;
.admin-menu :deep(.ant-menu-item:hover:not(.ant-menu-item-selected) .admin-menu__icon) {
background: #e8eef7 !important;
color: #475569 !important;
}
.admin-menu :deep(.ant-menu-item-selected) {
@@ -572,10 +890,133 @@ watch(() => route.fullPath, () => (drawer.value = false));
.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);
box-shadow: 0 4px 12px rgba(255, 90, 0, 0.35) !important;
}
.admin-menu :deep(.ant-menu-item-selected::after) {
display: none !important;
}
/* -------------------------------------------------------------
* Mobile App Experience Styles (Viewport < 1024px)
* ------------------------------------------------------------- */
.mobile-app-bar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
height: 52px;
padding-inline: 14px;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
border-bottom: 1px solid #eef1f6;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.02);
}
.mobile-app-bar__back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
margin-inline-start: -6px;
border: none;
background: transparent;
color: #334155;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.mobile-app-bar__back-btn:active {
background: #f1f5f9;
}
.mobile-nav-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-around;
height: 60px;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(16px);
border-top: 1px solid #e2e8f0;
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04);
}
.mobile-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding-block: 4px;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
transition: color 0.15s ease;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.mobile-nav-item__icon {
display: flex;
align-items: center;
justify-content: center;
height: 24px;
transition: transform 0.15s ease;
}
.mobile-nav-item__label {
margin-top: 2px;
font-size: 10px;
font-weight: 600;
line-height: 1;
}
.mobile-nav-item--active {
color: #ff5a00;
}
.mobile-nav-item--active .mobile-nav-item__icon {
transform: translateY(-1px);
}
@media (max-width: 1023px) {
.admin-content {
padding: 0 !important;
background: #f8fafc !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
.admin-content--has-bottom-nav {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px)) !important;
}
.admin-page {
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
min-height: 100%;
}
.admin-page > :deep(.page-header) {
padding: 12px 14px !important;
border-bottom: 1px solid #eef1f6 !important;
background: #ffffff !important;
}
.admin-page > :deep(*:not(.ap):not(.page-header)) {
padding: 12px !important;
}
}
</style>
+132 -1
View File
@@ -5,8 +5,10 @@ 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 { useAuthStore } from "@/stores/auth";
import { faNumber, SITE_URL } from "@/utils/format";
const auth = useAuthStore();
const rows = ref<AdminCategory[]>([]);
const loading = ref(false);
@@ -67,7 +69,7 @@ onMounted(load);
</RouterLink>
</PageHeader>
<a-card :body-style="{ padding: 0 }">
<a-card class="!hidden lg:!block" :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'">
@@ -108,6 +110,7 @@ onMounted(load);
<a-button size="small" title="مشاهده در سایت"><template #icon><VuesaxIcon name="export" :size="16" /></template></a-button>
</a>
<a-popconfirm
v-if="auth.isAdmin"
title="این دسته حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@@ -122,4 +125,132 @@ onMounted(load);
</template>
</a-table>
</a-card>
<!-- Mobile Category Cards View (Mobile Only) -->
<div class="mobile-categories-view block lg:!hidden pb-24">
<a-spin :spinning="loading">
<a-empty v-if="!rows.length && !loading" description="دسته‌بندی ثبت نشده است" class="py-12" />
<div v-else class="space-y-3">
<div
v-for="(record, index) in rows"
:key="record.id"
class="bg-white rounded-xl border border-slate-200/80 p-3.5 shadow-2xs transition"
>
<!-- Top Row: Cover + Info -->
<div class="flex items-start gap-3">
<img
v-if="record.coverImage"
:src="record.coverImage.url"
alt=""
class="size-14 rounded-lg object-cover shrink-0 border border-slate-100 bg-slate-50"
/>
<div v-else class="size-14 rounded-lg bg-slate-100 shrink-0 flex items-center justify-center text-slate-300">
<VuesaxIcon name="category" :size="22" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<RouterLink
:to="{ name: 'category-edit', params: { id: record.id } }"
class="font-bold text-[14px] text-slate-800 hover:text-orange-600 line-clamp-1"
>
{{ record.name }}
</RouterLink>
<span class="text-[10px] font-mono text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded shrink-0" dir="ltr">
{{ record.code }}
</span>
</div>
<div class="flex items-center gap-2 mt-1.5 text-xs">
<RouterLink
:to="{ name: 'products', query: { categoryId: record.id } }"
class="inline-flex items-center gap-1 text-orange-600 font-bold bg-orange-50 px-2 py-0.5 rounded-full"
>
{{ faNumber(record.productCount) }} محصول
</RouterLink>
<a-tag :color="record.isPublished ? 'green' : 'default'" class="!m-0 !text-[10px]">
{{ record.isPublished ? "منتشرشده" : "مخفی" }}
</a-tag>
</div>
</div>
</div>
<!-- Bottom Row: Reorder Controls + Actions -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-3">
<!-- Touch Reorder Buttons -->
<div class="flex items-center gap-1">
<span class="text-[11px] text-slate-400 me-1">ترتیب:</span>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === 0"
@click="move(index, -1)"
>
<VuesaxIcon name="arrow-up" :size="16" />
</button>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === rows.length - 1"
@click="move(index, 1)"
>
<VuesaxIcon name="arrow-down" :size="16" />
</button>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-1.5">
<RouterLink :to="{ name: 'category-edit', params: { id: record.id } }">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="ویرایش"
>
<VuesaxIcon name="edit-2" :size="16" />
</button>
</RouterLink>
<a :href="`${SITE_URL}/categories/${record.slug}`" target="_blank" rel="noopener">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="مشاهده در سایت"
>
<VuesaxIcon name="export" :size="16" />
</button>
</a>
<a-popconfirm
v-if="auth.isAdmin"
title="این دسته حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record as AdminCategory)"
>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-rose-500 bg-rose-50 hover:bg-rose-100 disabled:opacity-30 disabled:pointer-events-none active:bg-rose-200 transition"
:disabled="record.productCount > 0"
:title="record.productCount > 0 ? 'ابتدا محصولات این دسته را حذف کنید' : 'حذف'"
>
<VuesaxIcon name="trash" :size="16" />
</button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</a-spin>
<!-- Mobile Floating Action Button (FAB) for New Category -->
<RouterLink
:to="{ name: 'category-new' }"
class="fixed start-4 bottom-[72px] z-30 flex items-center gap-2 px-4 py-2.5 rounded-full bg-linear-to-r from-[#ff5a00] to-[#ff7a29] text-white font-bold text-xs shadow-lg shadow-orange-500/30 active:scale-95 transition"
>
<VuesaxIcon name="add" :size="18" />
<span>دسته جدید</span>
</RouterLink>
</div>
</template>
+72 -7
View File
@@ -102,6 +102,8 @@ const groupTitle = (g: Group) => {
return count ? `${g.name} (${count} محصول)` : g.name;
};
const mobileTab = ref<"main" | "groups" | "media" | "seo">("main");
onMounted(load);
</script>
@@ -113,11 +115,50 @@ onMounted(load);
</a-button>
</PageHeader>
<!-- Mobile Segmented Workflow Tabs -->
<div class="lg:hidden mb-4 overflow-x-auto pb-1 scrollbar-none">
<div class="flex items-center gap-1.5 min-w-max bg-gray-100 p-1 rounded-xl">
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all"
:class="mobileTab === 'main' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'main'"
>
اطلاعات اصلی
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all flex items-center gap-1"
:class="mobileTab === 'groups' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'groups'"
>
<span>زیرگروه‌ها</span>
<span class="rounded-full bg-gray-200 px-1.5 py-0.2 text-[10px]">{{ model.groups.length }}</span>
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all"
:class="mobileTab === 'media' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'media'"
>
کاور و آیکون
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-bold transition-all"
:class="mobileTab === 'seo' ? 'bg-white text-orange-600 shadow-sm' : 'text-gray-600'"
@click="mobileTab = 'seo'"
>
سئو و انتشار
</button>
</div>
</div>
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="save">
<a-form layout="vertical" class="pb-20 lg:pb-0" @submit.prevent="save">
<div class="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2">
<a-card title="اطلاعات اصلی">
<a-card title="اطلاعات اصلی" :class="mobileTab !== 'main' && 'max-lg:hidden'">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="name" label="نام دسته" required>
<a-input v-model:value="model.name" />
@@ -144,7 +185,7 @@ onMounted(load);
</div>
</a-card>
<a-card title="زیرگروه‌ها" :body-style="{ background: '#fafafa' }">
<a-card title="زیرگروه‌ها" :body-style="{ background: '#fafafa' }" :class="mobileTab !== 'groups' && 'max-lg:hidden'">
<p class="mb-4 text-sm text-gray-500">
زیرگروه‌ها در صفحه دسته به‌صورت فیلتر نمایش داده می‌شوند. با حذف یک زیرگروه، محصولات آن بدون زیرگروه می‌مانند.
</p>
@@ -172,23 +213,23 @@ onMounted(load);
</div>
<div class="space-y-6">
<a-card title="انتشار">
<a-card title="انتشار" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "مخفی" }}</span>
</FormField>
</a-card>
<a-card title="تصویر کاور">
<a-card title="تصویر کاور" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="coverImageId">
<ImageField :url="cover?.url" clearable @pick="pickCover" @clear="cover = null; model.coverImageId = null" />
</FormField>
</a-card>
<a-card title="آیکون">
<a-card title="آیکون" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="icon">
<IconSelect v-model="model.icon" allow-clear />
</FormField>
</a-card>
<a-card title="سئو">
<a-card title="سئو" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام دسته">
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
</FormField>
@@ -200,4 +241,28 @@ onMounted(load);
</div>
</a-form>
</a-spin>
<!-- Mobile Sticky Bottom Action Bar -->
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-2.5 backdrop-blur-md shadow-lg flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<a-switch v-model:checked="model.isPublished" size="small" />
<span class="text-xs font-medium text-gray-700">{{ model.isPublished ? "انتشار" : "پیش‌نویس" }}</span>
</div>
<div class="flex items-center gap-2">
<a
v-if="model.slug"
:href="`/categories/${model.slug}`"
target="_blank"
rel="noopener"
>
<a-button size="middle" class="!px-3" title="مشاهده در سایت">
<template #icon><VuesaxIcon name="eye" :size="16" /></template>
</a-button>
</a>
<a-button type="primary" size="middle" class="!font-bold !px-5" :loading="saving" @click="save">
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره تغییرات
</a-button>
</div>
</div>
</template>
+290 -36
View File
@@ -1,84 +1,338 @@
<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 { computed, onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { activityLabel, faNumber, jalali } from "@/utils/format";
const auth = useAuthStore();
const data = ref<DashboardDto | null>(null);
const loading = ref(true);
const maxCategoryCount = computed(() => {
if (!data.value?.categoryStats?.length) return 1;
return Math.max(...data.value.categoryStats.map((c) => c.count), 1);
});
const maxTrendCount = computed(() => {
if (!data.value?.trend?.length) return 1;
return Math.max(...data.value.trend.map((t) => t.messages), 1);
});
const sectionPercent = computed(() => {
if (!data.value?.sectionsSummary?.total) return 100;
return Math.round((data.value.sectionsSummary.visible / data.value.sectionsSummary.total) * 100);
});
onMounted(async () => {
loading.value = true;
try {
data.value = await api<DashboardDto>("/admin/dashboard");
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
});
</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 title="داشبورد مدیریت" subtitle="نمای کلی آمار، نمودارها، پیام‌ها و فعالیت‌های اخیر سامانه">
<div class="flex items-center gap-2">
<RouterLink :to="{ name: 'sections' }">
<a-button>
<template #icon><VuesaxIcon name="row-vertical" :size="16" /></template>
مدیریت بخش‌ها
</a-button>
</RouterLink>
<RouterLink :to="{ name: 'product-new' }">
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
</div>
</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-spin :spinning="loading">
<!-- 1. Top Statistics Row -->
<div class="grid grid-cols-2 gap-3 lg:gap-4 lg:grid-cols-5">
<RouterLink :to="{ name: 'products' }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="کل محصولات" :value="faNumber(data?.products ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-orange-50 text-orange-600">
<VuesaxIcon name="bag-2" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">
{{ faNumber((data?.products ?? 0) - (data?.draftProducts ?? 0)) }} محصول فعال روی سایت
</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'media' }">
<a-card hoverable><a-statistic title="تصاویر" :value="faNumber(data?.media ?? 0)" /></a-card>
<RouterLink :to="{ name: 'products', query: { status: 'draft' } }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="پیش‌نویس‌ها" :value="faNumber(data?.draftProducts ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-amber-50 text-amber-600">
<VuesaxIcon name="document-text" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">نیاز به بازبینی و انتشار</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'categories' }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="دسته‌بندی‌ها" :value="faNumber(data?.categories ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-blue-50 text-blue-600">
<VuesaxIcon name="category" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">شاخه‌های تجهیزات ایمنی</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'messages', query: { status: 'NEW' } }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic
title="پیام‌های جدید"
:value="faNumber(data?.newMessages ?? 0)"
:value-style="data?.newMessages ? { color: '#ff5a00', fontWeight: 'bold' } : undefined"
/>
<div
class="flex size-10 items-center justify-center rounded-xl"
:class="data?.newMessages ? 'bg-orange-100 text-orange-600 animate-pulse' : 'bg-gray-100 text-gray-500'"
>
<VuesaxIcon name="sms" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">
از مجموع {{ faNumber(data?.messageBreakdown?.total ?? 0) }} پیام
</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'media' }" class="max-lg:col-span-2 active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="رسانه‌ها و تصاویر" :value="faNumber(data?.media ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-purple-50 text-purple-600">
<VuesaxIcon name="gallery" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">تصاویر بهینه‌شده WebP</div>
</a-card>
</RouterLink>
</div>
<!-- 2. Quick Actions & Site Health Section -->
<div class="mt-6 grid gap-6 lg:grid-cols-12">
<!-- Quick Action Buttons -->
<a-card title="دسترسی‌های سریع" class="lg:col-span-8">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<RouterLink :to="{ name: 'product-new' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-orange-200 hover:bg-orange-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-orange-600 shadow-sm">
<VuesaxIcon name="add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">افزودن محصول</span>
<span class="mt-1 text-[11px] text-gray-400">کالای ایمنی جدید</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'category-new' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-blue-200 hover:bg-blue-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-blue-600 shadow-sm">
<VuesaxIcon name="folder-add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">دسته‌بندی جدید</span>
<span class="mt-1 text-[11px] text-gray-400">گروه محصولات</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'sections' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-emerald-200 hover:bg-emerald-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-emerald-600 shadow-sm">
<VuesaxIcon name="row-vertical" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">بخش‌های سایت</span>
<span class="mt-1 text-[11px] text-gray-400">نمایش یا مخفی‌سازی</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'media' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-purple-200 hover:bg-purple-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-purple-600 shadow-sm">
<VuesaxIcon name="gallery-add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">آپلود رسانه</span>
<span class="mt-1 text-[11px] text-gray-400">تصاویر و اسناد</span>
</div>
</RouterLink>
</div>
</a-card>
<!-- Section Visibility Health Card -->
<a-card title="وضعیت بخش‌های صفحه اصلی" class="lg:col-span-4">
<template #extra>
<RouterLink :to="{ name: 'sections' }" class="text-xs text-orange-600 hover:underline">
مدیریت
</RouterLink>
</template>
<div class="flex items-center gap-4">
<a-progress
type="circle"
:percent="sectionPercent"
:size="72"
:stroke-color="{ '0%': '#ff8a3d', '100%': '#ff5a00' }"
/>
<div class="space-y-1.5 text-xs">
<div class="font-bold text-gray-800">
{{ faNumber(data?.sectionsSummary?.visible ?? 0) }} بخش از {{ faNumber(data?.sectionsSummary?.total ?? 12) }} فعال
</div>
<div class="text-gray-500">
<span v-if="(data?.sectionsSummary?.hidden ?? 0) === 0" class="text-emerald-600 font-medium">
تمامی بخش‌های سایت در حال نمایش هستند
</span>
<span v-else class="text-amber-600 font-medium">
{{ faNumber(data?.sectionsSummary?.hidden ?? 0) }} بخش غیرفعال / مخفی
</span>
</div>
<div class="text-[11px] text-gray-400">
تغییر وضعیت آنی از منوی بخش‌های سایت
</div>
</div>
</div>
</a-card>
</div>
<!-- 3. Charts Row (نمودارهای تحلیلی) -->
<div class="mt-6 grid gap-6 lg:grid-cols-12">
<!-- Chart 1: Products distribution per category -->
<a-card title="نمودار توزیع محصولات بر اساس دسته‌بندی" class="lg:col-span-7">
<div v-if="!data?.categoryStats?.length" class="py-8">
<a-empty description="داده‌ای موجود نیست" />
</div>
<div v-else class="space-y-3.5 pt-1">
<div v-for="cat in data.categoryStats" :key="cat.id" class="space-y-1">
<div class="flex items-center justify-between text-xs font-medium">
<span class="text-gray-700">{{ cat.name }}</span>
<span class="font-mono text-gray-500">{{ faNumber(cat.count) }} محصول</span>
</div>
<a-progress
:percent="Math.round((cat.count / maxCategoryCount) * 100)"
:show-info="false"
:stroke-color="{ '0%': '#ffd0b5', '100%': '#ff5a00' }"
size="small"
/>
</div>
</div>
</a-card>
<!-- Chart 2: Message Status Breakdown & Trend -->
<a-card title="تحلیل پیام‌های تماس دریافتی" class="lg:col-span-5">
<!-- Message Status Chips -->
<div class="grid grid-cols-3 gap-2 text-center">
<div class="rounded-lg bg-orange-50 p-2.5">
<div class="text-lg font-black text-orange-600">{{ faNumber(data?.messageBreakdown?.new ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">جدید</div>
</div>
<div class="rounded-lg bg-blue-50 p-2.5">
<div class="text-lg font-black text-blue-600">{{ faNumber(data?.messageBreakdown?.read ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">خوانده‌شده</div>
</div>
<div class="rounded-lg bg-gray-50 p-2.5">
<div class="text-lg font-black text-gray-600">{{ faNumber(data?.messageBreakdown?.archived ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">بایگانی</div>
</div>
</div>
<!-- 7-Day Trend Bar Chart -->
<div class="mt-5 border-t border-gray-100 pt-4">
<div class="mb-3 flex items-center justify-between text-xs">
<span class="font-bold text-gray-700">روند پیام‌ها در ۷ روز اخیر</span>
<span class="text-gray-400">نمودار روزانه</span>
</div>
<div class="flex h-28 items-end justify-between gap-1.5 sm:gap-2 pt-4">
<div
v-for="day in data?.trend ?? []"
:key="day.date"
class="flex flex-1 flex-col items-center gap-1"
>
<span v-if="day.messages > 0" class="text-[9px] font-bold text-orange-600 mb-0.5">
{{ faNumber(day.messages) }}
</span>
<span v-else class="text-[9px] text-transparent mb-0.5 select-none">-</span>
<a-tooltip :title="`${day.dayName}: ${faNumber(day.messages)} پیام`">
<div class="relative flex w-full justify-center">
<div
class="w-full max-w-[24px] rounded-t-md transition-all duration-300"
:class="day.messages > 0 ? 'bg-orange-500 hover:bg-orange-600' : 'bg-gray-100'"
:style="{
height: `${Math.max(Math.round((day.messages / maxTrendCount) * 72), 6)}px`,
}"
/>
</div>
</a-tooltip>
<span class="text-[10px] text-gray-400 truncate max-w-[36px]">{{ day.dayName }}</span>
</div>
</div>
</div>
</a-card>
</div>
<!-- 4. Tables / Activity Lists Row -->
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<!-- Recent Messages -->
<a-card title="آخرین پیام‌های تماس">
<template #extra><RouterLink :to="{ name: 'messages' }">همه پیام‌ها</RouterLink></template>
<template #extra>
<RouterLink :to="{ name: 'messages' }" class="text-xs text-orange-600 hover:underline">
مشاهده همه ←
</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" />
<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>
<a-tag :color="item.status === 'NEW' ? 'orange' : item.status === 'READ' ? 'blue' : 'default'">
{{ MESSAGE_STATUS_LABELS[item.status as keyof typeof MESSAGE_STATUS_LABELS] }}
</a-tag>
<div class="mt-1 font-mono text-[11px]">{{ jalali(item.createdAt) }}</div>
</div>
</a-list-item>
</template>
</a-list>
</a-card>
<a-card title="آخرین فعالیت‌ها">
<!-- Recent Audit Log / Activity -->
<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">
<a-timeline v-else class="!mt-2 max-h-[360px] overflow-y-auto pe-2">
<a-timeline-item
v-for="a in data.recentActivity"
:key="a.id"
:color="a.action === 'delete' ? 'red' : a.action === 'create' ? 'green' : 'blue'"
>
<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>
<div class="mt-0.5 text-xs text-gray-400">
{{ a.user ?? "سیستم" }} · {{ jalali(a.createdAt) }}
</div>
</a-timeline-item>
</a-timeline>
</a-card>
+24 -1
View File
@@ -176,6 +176,10 @@ async function submit() {
}
}
.login-page {
color-scheme: dark;
}
.login-panel :deep(.ant-form-item-label > label) {
color: #edede6 !important;
}
@@ -187,6 +191,12 @@ async function submit() {
color: #edede6 !important;
}
.login-panel :deep(.ant-input-affix-wrapper input.ant-input),
.login-panel :deep(.ant-input-affix-wrapper input) {
background: transparent !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) {
@@ -198,10 +208,23 @@ async function submit() {
color: #8e9ab0 !important;
}
.login-panel :deep(.ant-input-password-icon) {
.login-panel :deep(.ant-input-password-icon),
.login-panel :deep(.ant-input-suffix) {
color: #8e9ab0 !important;
}
/* Fix browser autofill white background and black text */
.login-panel :deep(input:-webkit-autofill),
.login-panel :deep(input:-webkit-autofill:hover),
.login-panel :deep(input:-webkit-autofill:focus),
.login-panel :deep(input:-webkit-autofill:active) {
-webkit-box-shadow: 0 0 0 1000px #152038 inset !important;
box-shadow: 0 0 0 1000px #152038 inset !important;
-webkit-text-fill-color: #edede6 !important;
caret-color: #edede6 !important;
transition: background-color 99999s ease-in-out 0s;
}
.login-panel :deep(.ant-form-item-explain-error) {
color: #ff7a2a;
}
+110 -21
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, ref } from "vue";
import { onMounted, onUnmounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { faNumber, fileSize, jalali } from "@/utils/format";
const auth = useAuthStore();
const items = ref<MediaDto[]>([]);
const total = ref(0);
const page = ref(1);
@@ -16,6 +18,13 @@ const uploading = ref(0);
const selected = ref<MediaDto | null>(null);
const altDraft = ref("");
const PAGE_SIZE = 36;
const isMobile = ref(false);
function updateMobile() {
if (typeof window !== "undefined") {
isMobile.value = window.innerWidth < 1024;
}
}
async function load() {
loading.value = true;
@@ -82,7 +91,17 @@ async function copyUrl(url: string) {
void message.success("آدرس کپی شد");
}
onMounted(load);
onMounted(() => {
updateMobile();
window.addEventListener("resize", updateMobile);
void load();
});
onUnmounted(() => {
if (typeof window !== "undefined") {
window.removeEventListener("resize", updateMobile);
}
});
</script>
<template>
@@ -100,51 +119,121 @@ onMounted(load);
v-model:value="q"
allow-clear
placeholder="جستجو در نام فایل یا متن جایگزین"
class="mb-4 max-w-md"
class="mb-4 w-full md: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">
<div class="grid grid-cols-3 gap-2.5 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]"
class="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 transition active:scale-95 hover:border-[#ff5a00] focus:outline-none"
:title="m.originalName"
@click="open(m)"
>
<img :src="m.url" :alt="m.alt" class="aspect-square w-full object-contain" loading="lazy" />
<img :src="m.url" :alt="m.alt" class="aspect-square w-full object-contain p-1" loading="lazy" />
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-1 text-[9px] text-white opacity-0 transition group-hover:opacity-100 max-lg:hidden truncate">
{{ 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" />
<a-pagination
v-model:current="page"
:total="total"
:page-size="PAGE_SIZE"
:show-size-changer="false"
:simple="isMobile"
@change="load"
/>
</div>
</a-card>
<a-drawer :open="!!selected" title="جزئیات تصویر" :width="380" placement="left" @close="selected = null">
<!-- Mobile Floating Upload Button -->
<div class="lg:hidden fixed bottom-20 left-4 z-30">
<a-upload accept="image/jpeg,image/png,image/webp,image/avif,image/gif" multiple :show-upload-list="false" :before-upload="upload">
<button
type="button"
class="flex size-13 items-center justify-center rounded-full bg-[#ff5a00] text-white shadow-lg shadow-orange-500/30 transition-transform active:scale-95"
title="آپلود تصویر"
:disabled="uploading > 0"
>
<VuesaxIcon v-if="uploading === 0" name="document-upload" :size="24" />
<span v-else class="text-xs font-bold">{{ uploading }}</span>
</button>
</a-upload>
</div>
<!-- Image Detail Drawer (Side on Desktop, Bottom Sheet on Mobile) -->
<a-drawer
:open="!!selected"
title="جزئیات تصویر"
:width="isMobile ? '100%' : 380"
:height="isMobile ? '82vh' : undefined"
:placement="isMobile ? 'bottom' : 'left'"
:body-style="{ paddingBottom: isMobile ? '80px' : '24px' }"
@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>
<div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 flex items-center justify-center p-2">
<img :src="selected.url" :alt="selected.alt" class="max-h-60 w-full object-contain" />
</div>
<a-descriptions :column="isMobile ? 2 : 1" size="small" class="mt-4">
<a-descriptions-item label="نام فایل" :span="isMobile ? 2 : 1">
<span dir="ltr" class="truncate block max-w-[240px] text-xs font-mono text-gray-700">{{ selected.originalName }}</span>
</a-descriptions-item>
<a-descriptions-item label="ابعاد">
<span dir="ltr">{{ selected.width }}×{{ selected.height }}</span>
<span dir="ltr" class="text-xs font-mono">{{ 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-item label="تاریخ" :span="isMobile ? 2 : 1">{{ 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 label="متن جایگزین (alt)" class="!mb-3">
<a-input v-model:value="altDraft" placeholder="توضیح کوتاه برای سئو و دسترس‌پذیری" />
</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>
<!-- Actions: Desktop row vs Mobile full touch grid -->
<div class="max-lg:grid max-lg:grid-cols-2 max-lg:gap-2 max-lg:mt-4 max-lg:hidden">
<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
v-if="auth.isAdmin"
title="این تصویر حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove"
>
<a-button danger><template #icon><VuesaxIcon name="trash" :size="16" /></template>حذف</a-button>
</a-popconfirm>
</a-space>
</div>
<div class="lg:hidden grid grid-cols-2 gap-2 mt-4">
<a-button type="primary" class="h-10 !font-bold" @click="saveAlt">ذخیره متن</a-button>
<a-button class="h-10 !flex !items-center !justify-center !gap-1" @click="copyUrl(selected.url)">
<template #icon><VuesaxIcon name="copy" :size="16" /></template>
<span>کپی آدرس</span>
</a-button>
<a-popconfirm
v-if="auth.isAdmin"
title="این تصویر حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove"
>
<a-button danger class="col-span-2 h-10 !flex !items-center !justify-center !gap-1">
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
<span>حذف تصویر</span>
</a-button>
</a-popconfirm>
</a-space>
</div>
</a-form>
</template>
</a-drawer>
+172 -6
View File
@@ -7,13 +7,15 @@ import {
type Paginated,
} from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, ref, watch } from "vue";
import { computed, 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 { useAuthStore } from "@/stores/auth";
import { faNumber, jalali } from "@/utils/format";
const auth = useAuthStore();
const route = useRoute();
/** "ALL" or one status */
const status = ref<MessageStatus | "ALL">((route.query.status as MessageStatus) || "ALL");
@@ -23,8 +25,11 @@ const page = ref(1);
const rows = ref<ContactMessageDto[]>([]);
const total = ref(0);
const loading = ref(false);
const expandedIds = ref<number[]>([]);
const PAGE_SIZE = 20;
const totalPages = computed(() => Math.ceil(total.value / PAGE_SIZE) || 1);
async function load() {
loading.value = true;
try {
@@ -44,11 +49,24 @@ async function setStatus(row: ContactMessageDto, s: MessageStatus) {
try {
await api(`/admin/messages/${row.id}`, { method: "PATCH", body: { status: s } });
row.status = s;
void message.success(`وضعیت پیام به «${MESSAGE_STATUS_LABELS[s]}» تغییر کرد`);
} catch (e) {
void message.error(errorMessage(e));
}
}
function toggleExpand(row: ContactMessageDto) {
const idx = expandedIds.value.indexOf(row.id);
if (idx > -1) {
expandedIds.value.splice(idx, 1);
} else {
expandedIds.value.push(row.id);
if (row.status === "NEW") {
void setStatus(row, "READ");
}
}
}
/** opening a new message marks it as read */
function onExpand(expanded: boolean, row: ContactMessageDto) {
if (expanded && row.status === "NEW") void setStatus(row, "READ");
@@ -64,6 +82,12 @@ async function remove(row: ContactMessageDto) {
}
}
function onMobilePageChange(p: number) {
if (p < 1 || p > totalPages.value) return;
page.value = p;
void load();
}
const STATUS_COLORS: Record<MessageStatus, string> = { NEW: "orange", READ: "blue", ARCHIVED: "default" };
const columns = [
{ title: "نام", dataIndex: "name" },
@@ -82,11 +106,28 @@ onMounted(load);
</script>
<template>
<PageHeader title="پیام‌های تماس" :subtitle="`${faNumber(total)} پیام — درخواست‌های ثبت‌شده از فرم مشاوره سایت`">
<a-segmented v-model:value="status" :options="FILTER_OPTIONS" />
<PageHeader title="پیام‌های تماس" :subtitle="`${faNumber(total)} پیام — استعلام‌ها و درخواست‌های ثبت‌شده از سایت`">
<div class="!hidden lg:!block">
<a-segmented v-model:value="status" :options="FILTER_OPTIONS" />
</div>
</PageHeader>
<a-card :body-style="{ padding: 0 }">
<!-- Mobile Segmented Filter Row (Mobile Only) -->
<div class="mobile-filter-bar sticky top-[52px] z-20 bg-white/95 backdrop-blur-md -mx-3 px-3 py-2 border-b border-slate-100 flex items-center gap-1.5 overflow-x-auto no-scrollbar mb-3 lg:!hidden">
<button
v-for="opt in FILTER_OPTIONS"
:key="opt.value"
type="button"
class="px-3 py-1.5 rounded-full text-xs font-bold transition shrink-0 select-none cursor-pointer"
:class="status === opt.value ? 'bg-orange-600 text-white shadow-xs' : 'bg-slate-100 text-slate-600 active:bg-slate-200'"
@click="status = opt.value as any"
>
{{ opt.label }}
</button>
</div>
<!-- Desktop Table View (lg and up) -->
<a-card class="!hidden lg:!block" :body-style="{ padding: 0 }">
<a-table
:columns="columns"
:data-source="rows"
@@ -101,7 +142,7 @@ onMounted(load);
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'phone'">
<a :href="`tel:${record.phone}`" dir="ltr">{{ record.phone }}</a>
<a :href="`tel:${record.phone}`" dir="ltr" class="hover:text-orange-600 font-mono">{{ record.phone }}</a>
</template>
<template v-else-if="column.key === 'date'">{{ jalali(record.createdAt) }}</template>
<template v-else-if="column.key === 'status'">
@@ -116,11 +157,136 @@ onMounted(load);
</a-select>
</template>
<template v-else-if="column.key === 'actions'">
<a-popconfirm title="این پیام حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record as ContactMessageDto)">
<a-popconfirm
v-if="auth.isAdmin"
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>
<!-- Mobile Message Cards View (Mobile Only) -->
<div class="mobile-messages-view block lg:!hidden pb-20">
<a-spin :spinning="loading">
<a-empty v-if="!rows.length && !loading" description="پیامی با این وضعیت موجود نیست" class="py-12" />
<div v-else class="space-y-3">
<div
v-for="record in rows"
:key="record.id"
class="bg-white rounded-xl border border-slate-200/80 p-3.5 shadow-2xs transition"
:class="{ 'border-orange-300 ring-2 ring-orange-100': record.status === 'NEW' }"
>
<!-- Top Row: Sender Info + Status Pill -->
<div class="flex items-start justify-between gap-2">
<div>
<div class="flex items-center gap-1.5">
<span class="font-bold text-[14px] text-slate-800">{{ record.name }}</span>
<span v-if="record.status === 'NEW'" class="size-2 rounded-full bg-orange-500 animate-pulse" />
</div>
<div v-if="record.org" class="text-xs text-slate-500 font-medium mt-0.5">
{{ record.org }}
</div>
</div>
<a-tag :color="STATUS_COLORS[record.status as MessageStatus]" class="!m-0 !text-[11px] !font-bold">
{{ MESSAGE_STATUS_LABELS[record.status as keyof typeof MESSAGE_STATUS_LABELS] }}
</a-tag>
</div>
<!-- Middle Row: Date & Call Button -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-2.5">
<span class="text-[11px] text-slate-400 font-mono">
{{ jalali(record.createdAt) }}
</span>
<!-- Direct Call Button -->
<a
:href="'tel:' + record.phone"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200/70 font-bold text-xs active:bg-emerald-100 active:scale-95 transition"
title="تماس مستقیم با مشتری"
>
<Phone :size="13" class="text-emerald-600 shrink-0" />
<span dir="ltr" class="font-mono">{{ record.phone }}</span>
</a>
</div>
<!-- Message Body Preview & Full Content -->
<div class="mt-3 cursor-pointer" @click="toggleExpand(record)">
<div
v-if="!expandedIds.includes(record.id)"
class="text-xs text-slate-600 line-clamp-2 leading-relaxed bg-slate-50/70 p-2.5 rounded-lg border border-slate-100"
>
{{ record.message || "— بدون توضیح —" }}
<div class="text-[10px] text-orange-600 font-bold mt-1">مشاهده متن کامل پیام ←</div>
</div>
<div
v-else
class="text-xs text-slate-700 whitespace-pre-line leading-relaxed bg-orange-50/30 p-3 rounded-lg border border-orange-100"
>
{{ record.message || "— بدون توضیح —" }}
<div class="text-[10px] text-slate-400 font-medium mt-2">بستن متن پیام ↑</div>
</div>
</div>
<!-- Footer Actions: Status Changer + Delete -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-3">
<div class="flex items-center gap-2">
<span class="text-[11px] text-slate-400">وضعیت:</span>
<a-select
:value="record.status"
size="small"
class="w-28 !text-xs"
:options="STATUS_OPTIONS"
@change="(v: unknown) => setStatus(record, v as MessageStatus)"
/>
</div>
<a-popconfirm
v-if="auth.isAdmin"
title="این پیام حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record)"
>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-rose-500 bg-rose-50 hover:bg-rose-100 active:bg-rose-200 transition"
title="حذف پیام"
>
<VuesaxIcon name="trash" :size="16" />
</button>
</a-popconfirm>
</div>
</div>
</div>
<!-- Mobile Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between gap-3 pt-4 px-1">
<a-button
:disabled="page <= 1"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(page - 1)"
>
صفحه قبلی
</a-button>
<span class="text-xs text-slate-500 font-mono">
{{ page.toLocaleString("fa-IR") }} / {{ totalPages.toLocaleString("fa-IR") }}
</span>
<a-button
:disabled="page >= totalPages"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(page + 1)"
>
صفحه بعدی
</a-button>
</div>
</a-spin>
</div>
</template>
+145 -49
View File
@@ -59,6 +59,16 @@ const groups = ref<AdminCategoryDetail["groups"]>([]);
const loading = ref(false);
const saving = ref(false);
const mobileTab = ref<"general" | "specs" | "desc" | "media" | "seo">("general");
const MOBILE_TABS = [
{ key: "general", label: "اطلاعات اصلی" },
{ key: "specs", label: "مشخصات فنی" },
{ key: "desc", label: "توضیحات و کاربرد" },
{ key: "media", label: "تصاویر و سفارش" },
{ key: "seo", label: "سئو و انتشار" },
] as const;
const category = computed(() => categories.value.find((c) => c.id === model.value.categoryId));
/** a-select clears to undefined, the API expects null */
const groupId = computed({
@@ -161,25 +171,48 @@ onMounted(load);
<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>
<div class="!hidden lg:!flex items-center gap-2">
<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>
</div>
</PageHeader>
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="save">
<a-form layout="vertical" class="pb-24 lg:pb-6" @submit.prevent="save">
<!-- Mobile Segmented Navigation Tabs -->
<div
class="mobile-edit-tabs lg:!hidden flex items-center gap-1.5 overflow-x-auto pb-2.5 mb-3 sticky top-[52px] z-20 bg-white/95 backdrop-blur-md -mx-3 px-3 pt-1 border-b border-slate-100 no-scrollbar"
>
<button
v-for="t in MOBILE_TABS"
:key="t.key"
type="button"
class="px-3 py-1.5 rounded-full text-xs font-bold transition shrink-0 select-none cursor-pointer"
:class="
mobileTab === t.key
? 'bg-orange-600 text-white shadow-xs'
: 'bg-slate-100 text-slate-600 active:bg-slate-200'
"
@click="mobileTab = t.key"
>
{{ t.label }}
</button>
</div>
<div class="grid gap-6 xl:grid-cols-3">
<div class="space-y-6 xl:col-span-2">
<a-card title="اطلاعات محصول">
<!-- Card 1: Main Info -->
<a-card title="اطلاعات محصول" :class="mobileTab !== 'general' && 'max-lg:hidden'">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField path="name" label="نام محصول" required>
<a-input v-model:value="model.name" />
@@ -206,7 +239,8 @@ onMounted(load);
</div>
</a-card>
<a-card title="توضیحات کامل (صفحه محصول)">
<!-- Card 2: Details Description -->
<a-card title="توضیحات کامل (صفحه محصول)" :class="mobileTab !== 'desc' && 'max-lg:hidden'">
<FormField path="details" hint="برای شروع پاراگراف جدید یک خط خالی بگذارید. اگر خالی بماند، توضیح دسته نمایش داده می‌شود.">
<a-textarea
v-model:value="model.details"
@@ -216,7 +250,8 @@ onMounted(load);
</FormField>
</a-card>
<a-card title="ویژگی‌ها و کاربردها">
<!-- Card 3: Features & Applications -->
<a-card title="ویژگی‌ها و کاربردها" :class="mobileTab !== 'desc' && 'max-lg:hidden'">
<FormField path="features" label="ویژگی‌های کلیدی" hint="هر ویژگی در یک خط">
<LinesInput v-model="model.features" :rows="4" placeholder="مثلاً: زیره ضد لغزش و مقاوم در برابر روغن" />
</FormField>
@@ -225,7 +260,8 @@ onMounted(load);
</FormField>
</a-card>
<a-card title="مشخصات فنی" :body-style="{ background: '#fafafa' }">
<!-- Card 4: Technical Attributes & Specifications -->
<a-card title="مشخصات فنی" :body-style="{ background: '#fafafa' }" :class="mobileTab !== 'specs' && 'max-lg:hidden'">
<p class="mb-4 text-sm text-gray-500">
ردیف‌های جدول مشخصات صفحه محصول (مثلاً «جنس رویه» ← «چرم طبیعی»). کد، دسته، برند و موارد زیر به‌صورت خودکار به جدول اضافه می‌شوند.
</p>
@@ -260,40 +296,19 @@ onMounted(load);
</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="انتشار">
<!-- Card: Publishing (Desktop & Mobile SEO tab) -->
<a-card title="انتشار" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="isPublished">
<a-switch v-model:checked="model.isPublished" />
<span class="ms-2">{{ model.isPublished ? "در سایت نمایش داده می‌شود" : "پیش‌نویس (مخفی)" }}</span>
</FormField>
</a-card>
<a-card title="دسته‌بندی">
<!-- Card: Category & Group (General tab on mobile) -->
<a-card title="دسته‌بندی" :class="mobileTab !== 'general' && 'max-lg:hidden'">
<FormField path="categoryId" label="دسته" required>
<a-select
v-model:value="model.categoryId"
@@ -312,12 +327,56 @@ onMounted(load);
/>
</FormField>
</a-card>
<a-card title="تصویر اصلی">
<FormField path="imageId" hint="بدون تصویر، کاور دسته نمایش داده می‌شود">
<ImageField :url="image?.url" clearable @pick="pickImage" @clear="image = null; model.imageId = null" />
</FormField>
<!-- Card: Product Images (Cover + Gallery) -->
<a-card title="تصاویر محصول" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<!-- Cover / Main Image -->
<div>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-bold text-slate-700">تصویر اصلی (کاور)</span>
<span class="text-[11px] text-slate-400">نمایش در لیست‌ها و کارت‌ها</span>
</div>
<FormField path="imageId" hint="در صورت عدم انتخاب، کاور دسته‌بندی استفاده می‌شود">
<ImageField :url="image?.url" clearable @pick="pickImage" @clear="image = null; model.imageId = null" />
</FormField>
</div>
<div class="my-5 border-t border-slate-100" />
<!-- Gallery Images -->
<div>
<div class="mb-1.5 flex items-center justify-between">
<span class="text-xs font-bold text-slate-700">گالری تصاویر بیشتر</span>
<span v-if="gallery.length" class="text-[11px] text-orange-600 font-mono font-bold">{{ gallery.length }} تصویر</span>
</div>
<p class="mb-3 text-xs text-slate-400">تصاویری که در اسلایدر جزئیات محصول نمایش داده می‌شوند.</p>
<div class="flex flex-wrap gap-2.5">
<div v-for="m in gallery" :key="m.id" class="relative size-20 overflow-hidden rounded-lg border border-slate-200 bg-slate-50">
<img :src="m.url" alt="" class="size-full object-contain p-1" />
<button
type="button"
class="absolute end-1 top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-white shadow-xs transition hover:bg-red-600 cursor-pointer"
title="حذف از گالری"
@click="removeFromGallery(m.id)"
>
<VuesaxIcon name="close-circle" :size="12" />
</button>
</div>
<button
type="button"
class="flex size-20 flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-slate-300 text-slate-400 hover:border-[#ff5a00] hover:text-[#ff5a00] hover:bg-orange-50/30 transition cursor-pointer"
@click="galleryPicker = true"
>
<VuesaxIcon name="add" :size="18" />
<span class="text-[10px] font-medium">افزودن</span>
</button>
</div>
</div>
<MediaPicker v-model:open="galleryPicker" @select="addToGallery" />
</a-card>
<a-card title="برند و سفارش">
<!-- Card 9: Brand and Ordering (Media tab on mobile) -->
<a-card title="برند و سفارش" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="brand" label="برند">
<a-input v-model:value="model.brand" />
</FormField>
@@ -331,7 +390,9 @@ onMounted(load);
<a-input v-model:value="model.minOrder" placeholder="مثلاً: ۱ کارتن" />
</FormField>
</a-card>
<a-card title="سئو">
<!-- Card 10: SEO (SEO tab on mobile) -->
<a-card title="سئو" :class="mobileTab !== 'seo' && 'max-lg:hidden'">
<FormField path="seoTitle" label="عنوان صفحه" hint="خالی = نام محصول">
<a-input v-model:value="model.seoTitle" :maxlength="120" show-count />
</FormField>
@@ -341,6 +402,41 @@ onMounted(load);
</a-card>
</div>
</div>
<!-- Mobile Sticky Bottom Action Bar -->
<div
class="fixed bottom-0 start-0 end-0 z-30 bg-white/95 backdrop-blur-md border-t border-slate-200/80 p-3 shadow-lg flex items-center justify-between gap-3 lg:!hidden"
>
<div class="flex items-center gap-2">
<a-switch v-model:checked="model.isPublished" size="small" />
<span class="text-xs" :class="model.isPublished ? 'text-emerald-600 font-bold' : 'text-slate-400'">
{{ model.isPublished ? "منتشرشده" : "پیش‌نویس" }}
</span>
</div>
<div class="flex items-center gap-2">
<a
v-if="id && category?.isPublished && model.isPublished"
:href="`${SITE_URL}/products/${model.slug}`"
target="_blank"
rel="noopener"
class="size-10 rounded-xl bg-slate-100 flex items-center justify-center text-slate-600 hover:text-orange-600 active:bg-slate-200"
title="مشاهده در سایت"
>
<VuesaxIcon name="export" :size="18" />
</a>
<a-button
type="primary"
class="!h-10 !px-5 !rounded-xl !font-bold !flex !items-center !gap-1.5 !shadow-sm"
:loading="saving"
@click="save"
>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره محصول
</a-button>
</div>
</div>
</a-form>
</a-spin>
</template>
+409 -19
View File
@@ -7,9 +7,11 @@ 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";
import { useAuthStore } from "@/stores/auth";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const tableShell = ref<HTMLElement | null>(null);
const scrollY = ref(360);
let tableResizeObserver: ResizeObserver | null = null;
@@ -44,11 +46,62 @@ const loading = ref(false);
const categories = ref<AdminCategory[]>([]);
const groups = ref<AdminCategoryDetail["groups"]>([]);
const filtersOpen = ref(false);
const mobileFilterDrawer = ref(false);
const activeFilterCount = computed(
() => [filters.categoryId, filters.groupId, filters.status].filter(Boolean).length,
);
const activeCategoryName = computed(
() => categories.value.find((c) => c.id === filters.categoryId)?.name,
);
const activeGroupName = computed(
() => groups.value.find((g) => g.id === filters.groupId)?.name,
);
const totalPages = computed(() => Math.ceil(total.value / filters.pageSize) || 1);
function clearAllFilters() {
filters.categoryId = undefined;
filters.groupId = undefined;
filters.status = undefined;
filters.q = "";
filters.page = 1;
void load();
}
function clearCategoryFilter() {
filters.categoryId = undefined;
filters.groupId = undefined;
filters.page = 1;
void load();
}
function clearGroupFilter() {
filters.groupId = undefined;
filters.page = 1;
void load();
}
function clearStatusFilter() {
filters.status = undefined;
filters.page = 1;
void load();
}
function clearSearch() {
filters.q = "";
filters.page = 1;
void load();
}
function onMobilePageChange(p: number) {
if (p < 1 || p > totalPages.value) return;
filters.page = p;
void load();
}
/** products without their own photo show the category cover (same fallback as the website) */
const thumbOf = (row: AdminProduct) =>
row.image
@@ -219,8 +272,22 @@ onBeforeUnmount(() => {
<template>
<AdminPage lock-body>
<template #head>
<div class="toolbar">
<!-- Desktop Toolbar (lg and up) -->
<div class="toolbar !hidden lg:!flex">
<div class="toolbar__start">
<a-input-search
v-model:value="filters.q"
allow-clear
class="toolbar__search"
placeholder="جستجوی نام یا کد"
@search="
() => {
filters.page = 1;
load();
}
"
/>
<a-popover
v-model:open="filtersOpen"
trigger="click"
@@ -266,19 +333,6 @@ onBeforeUnmount(() => {
</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
@@ -288,15 +342,87 @@ onBeforeUnmount(() => {
query: filters.categoryId ? { categoryId: filters.categoryId } : {},
}"
>
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
</div>
<!-- Mobile Toolbar (Mobile Only) -->
<div class="mobile-products-toolbar lg:!hidden flex flex-col gap-2">
<div class="flex items-center gap-2 w-full">
<a-input-search
v-model:value="filters.q"
allow-clear
class="flex-1"
placeholder="جستجوی نام یا کد کالا..."
@search="
() => {
filters.page = 1;
load();
}
"
/>
<a-badge :count="activeFilterCount" :offset="[-2, 2]" color="#ff5a00">
<a-button
class="!h-9 !px-3 !rounded-lg !flex !items-center !gap-1.5"
:type="activeFilterCount > 0 ? 'primary' : 'default'"
:ghost="activeFilterCount > 0"
@click="mobileFilterDrawer = true"
>
<template #icon><VuesaxIcon name="filter" :size="16" /></template>
<span class="text-xs">فیلتر</span>
</a-button>
</a-badge>
</div>
<!-- Active Filter Chips on Mobile -->
<div
v-if="activeFilterCount > 0 || filters.q"
class="flex items-center gap-1.5 overflow-x-auto py-1 text-[11px] no-scrollbar"
>
<span
v-if="filters.q"
class="inline-flex items-center gap-1 bg-slate-100 text-slate-700 px-2 py-0.5 rounded-full shrink-0"
>
جستجو: «{{ filters.q }}»
<button type="button" class="text-slate-400 hover:text-slate-600" @click="clearSearch">✕</button>
</span>
<span
v-if="activeCategoryName"
class="inline-flex items-center gap-1 bg-orange-50 text-orange-700 border border-orange-200/60 px-2 py-0.5 rounded-full shrink-0"
>
{{ activeCategoryName }}
<button type="button" class="text-orange-400 hover:text-orange-700" @click="clearCategoryFilter">✕</button>
</span>
<span
v-if="activeGroupName"
class="inline-flex items-center gap-1 bg-blue-50 text-blue-700 border border-blue-200/60 px-2 py-0.5 rounded-full shrink-0"
>
{{ activeGroupName }}
<button type="button" class="text-blue-400 hover:text-blue-700" @click="clearGroupFilter">✕</button>
</span>
<span
v-if="filters.status"
class="inline-flex items-center gap-1 bg-slate-100 text-slate-700 px-2 py-0.5 rounded-full shrink-0"
>
{{ filters.status === 'published' ? 'منتشرشده' : 'پیش‌نویس / مخفی' }}
<button type="button" class="text-slate-400 hover:text-slate-700" @click="clearStatusFilter">✕</button>
</span>
<button
type="button"
class="text-[10px] text-rose-500 font-medium hover:underline shrink-0 px-1"
@click="clearAllFilters"
>
حذف همه
</button>
</div>
</div>
</template>
<div ref="tableShell" class="table-shell">
<!-- Desktop Table View (lg and up) -->
<div ref="tableShell" class="table-shell !hidden lg:!flex">
<a-table
class="table-shell__table"
:columns="columns"
@@ -366,6 +492,7 @@ onBeforeUnmount(() => {
<template #icon><VuesaxIcon name="copy" :size="14" /></template>
</a-button>
<a-popconfirm
v-if="auth.isAdmin"
title="این محصول حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@@ -380,6 +507,269 @@ onBeforeUnmount(() => {
</template>
</a-table>
</div>
<!-- Mobile Product Cards View (Mobile Only) -->
<div class="mobile-products-view block lg:!hidden pb-24">
<!-- Reorder Info Banner -->
<div
v-if="reorderable"
class="mb-3 p-2.5 rounded-xl bg-orange-50 border border-orange-200/70 text-xs text-orange-800 flex items-center gap-2"
>
<VuesaxIcon name="info-circle" :size="18" class="text-orange-600 shrink-0" />
<span>حالت تغییر ترتیب فعال است. می‌توانید با فلش‌ها ترتیب کالاها را عوض کنید.</span>
</div>
<!-- Count Header -->
<div class="flex items-center justify-between text-xs text-slate-500 px-1 mb-2.5">
<span>{{ total.toLocaleString("fa-IR") }} محصول</span>
<span v-if="!reorderable && totalPages > 1" class="font-mono">
صفحه {{ filters.page.toLocaleString("fa-IR") }} از {{ totalPages.toLocaleString("fa-IR") }}
</span>
</div>
<a-spin :spinning="loading">
<a-empty v-if="!rows.length && !loading" description="محصولی با این مشخصات یافت نشد" class="py-12" />
<div v-else class="space-y-2.5">
<div
v-for="(record, index) in rows"
:key="record.id"
class="bg-white rounded-xl border border-slate-200/80 p-3 shadow-2xs transition active:bg-slate-50/50"
>
<!-- Top: Thumbnail + Info -->
<div class="flex items-start gap-3">
<template v-if="thumbOf(record).url">
<img
:src="thumbOf(record).url!"
alt=""
:class="[
'size-14 rounded-lg bg-slate-50 object-contain shrink-0 border border-slate-100',
thumbOf(record).fallback && 'opacity-40 grayscale',
]"
/>
</template>
<div v-else class="size-14 rounded-lg bg-slate-100 shrink-0 flex items-center justify-center text-slate-300">
<VuesaxIcon name="gallery" :size="20" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<RouterLink
:to="{ name: 'product-edit', params: { id: record.id } }"
class="font-bold text-[13px] text-slate-800 hover:text-orange-600 line-clamp-2 leading-snug"
>
{{ record.name }}
</RouterLink>
<span class="text-[10px] font-mono text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded shrink-0" dir="ltr">
{{ record.code }}
</span>
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-1.5 text-[11px] text-slate-500">
<span class="text-slate-600 font-medium">{{ record.category.name }}</span>
<span v-if="record.group" class="text-slate-400">/</span>
<span v-if="record.group" class="text-slate-500">{{ record.group.name }}</span>
</div>
</div>
</div>
<!-- Bottom: Publish Status Switch + Touch Actions -->
<div class="flex items-center justify-between border-t border-slate-100 pt-2.5 mt-2.5">
<div class="flex items-center gap-2">
<a-switch
:checked="record.isPublished"
size="small"
@change="(v: unknown) => togglePublish(record, !!v)"
/>
<span class="text-xs" :class="record.isPublished ? 'text-emerald-600 font-medium' : 'text-slate-400'">
{{ record.isPublished ? "منتشرشده" : "پیش‌نویس" }}
</span>
</div>
<div class="flex items-center gap-1.5">
<!-- Reorder buttons when reorderable -->
<template v-if="reorderable">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === 0"
@click="move(index, -1)"
>
<VuesaxIcon name="arrow-up" :size="16" />
</button>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 disabled:opacity-30 disabled:pointer-events-none active:bg-slate-300 transition"
:disabled="index === rows.length - 1"
@click="move(index, 1)"
>
<VuesaxIcon name="arrow-down" :size="16" />
</button>
</template>
<!-- Edit Action -->
<RouterLink :to="{ name: 'product-edit', params: { id: record.id } }">
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="ویرایش"
>
<VuesaxIcon name="edit-2" :size="16" />
</button>
</RouterLink>
<!-- Duplicate Action -->
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-slate-600 bg-slate-100 hover:bg-slate-200 active:bg-slate-300 transition"
title="تکثیر / کپی"
@click="duplicate(record)"
>
<VuesaxIcon name="copy" :size="16" />
</button>
<!-- Delete Action -->
<a-popconfirm
v-if="auth.isAdmin"
title="این محصول حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(record)"
>
<button
type="button"
class="size-8 rounded-lg flex items-center justify-center text-rose-500 bg-rose-50 hover:bg-rose-100 active:bg-rose-200 transition"
title="حذف"
>
<VuesaxIcon name="trash" :size="16" />
</button>
</a-popconfirm>
</div>
</div>
</div>
</div>
<!-- Mobile Pagination -->
<div v-if="!reorderable && totalPages > 1" class="flex items-center justify-between gap-3 pt-4 px-1">
<a-button
:disabled="filters.page <= 1"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(filters.page - 1)"
>
صفحه قبلی
</a-button>
<span class="text-xs text-slate-500 font-mono">
{{ filters.page.toLocaleString("fa-IR") }} / {{ totalPages.toLocaleString("fa-IR") }}
</span>
<a-button
:disabled="filters.page >= totalPages"
class="!flex-1 !h-10 !rounded-xl !font-medium"
@click="onMobilePageChange(filters.page + 1)"
>
صفحه بعدی
</a-button>
</div>
</a-spin>
<!-- Mobile Floating Action Button (FAB) for New Product -->
<RouterLink
:to="{
name: 'product-new',
query: filters.categoryId ? { categoryId: filters.categoryId } : {},
}"
class="fixed start-4 bottom-[72px] z-30 flex items-center gap-2 px-4 py-2.5 rounded-full bg-linear-to-r from-[#ff5a00] to-[#ff7a29] text-white font-bold text-xs shadow-lg shadow-orange-500/30 active:scale-95 transition"
>
<VuesaxIcon name="add" :size="18" />
<span>محصول جدید</span>
</RouterLink>
</div>
<!-- Mobile Filter Bottom Sheet Drawer -->
<a-drawer
v-model:open="mobileFilterDrawer"
placement="bottom"
height="auto"
:closable="false"
class="mobile-filter-drawer"
:body-style="{ padding: '16px 20px 24px', background: '#ffffff', borderTopLeftRadius: '20px', borderTopRightRadius: '20px' }"
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between border-b border-slate-100 pb-3">
<div class="flex items-center gap-2">
<VuesaxIcon name="filter" :size="18" class="text-orange-600" />
<span class="text-sm font-bold text-slate-800">فیلترهای کاتالوگ</span>
</div>
<button
type="button"
class="text-xs text-slate-400 hover:text-slate-600"
@click="mobileFilterDrawer = false"
>
بستن
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5">دسته‌بندی</label>
<a-select
v-model:value="filters.categoryId"
allow-clear
size="large"
style="width: 100%"
placeholder="همه دسته‌ها"
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5">زیرگروه کالا</label>
<a-select
v-model:value="filters.groupId"
allow-clear
size="large"
style="width: 100%"
placeholder="همه زیرگروه‌ها"
:disabled="!groups.length"
:options="groups.map((g) => ({ value: g.id, label: g.name }))"
/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1.5">وضعیت انتشار</label>
<a-select
v-model:value="filters.status"
allow-clear
size="large"
style="width: 100%"
placeholder="همه وضعیت‌ها"
:options="[
{ value: 'published', label: 'منتشرشده در سایت' },
{ value: 'draft', label: 'پیش‌نویس / مخفی' },
]"
/>
</div>
</div>
<div class="flex items-center gap-2.5 pt-2 border-t border-slate-100">
<a-button
v-if="activeFilterCount > 0"
danger
class="!h-11 !rounded-xl"
@click="clearAllFilters(); mobileFilterDrawer = false;"
>
حذف فیلترها
</a-button>
<a-button
type="primary"
block
class="!h-11 !rounded-xl !font-bold"
@click="mobileFilterDrawer = false; load();"
>
اعمال فیلترها
</a-button>
</div>
</div>
</a-drawer>
</AdminPage>
</template>
+28 -9
View File
@@ -32,24 +32,43 @@ async function save() {
</script>
<template>
<PageHeader title="حساب کاربری" />
<div class="grid max-w-4xl gap-6 md:grid-cols-2">
<a-card title="مشخصات">
<a-descriptions :column="1">
<PageHeader title="حساب کاربری" subtitle="مشخصات فردی، نقش دسترسی و تغییر رمز عبور ورود به سامانه" />
<div class="grid max-w-4xl gap-4 md:gap-6 md:grid-cols-2">
<!-- User Identity Card -->
<a-card title="مشخصات کاربری">
<div class="flex items-center gap-3.5 mb-4 pb-4 border-b border-gray-100">
<div class="flex size-14 items-center justify-center rounded-2xl bg-gradient-to-tr from-[#ff5a00] to-amber-500 text-xl font-black text-white shadow-md shadow-orange-500/20">
{{ auth.user?.name?.slice(0, 1) || 'U' }}
</div>
<div>
<div class="text-base font-bold text-gray-900">{{ auth.user?.name }}</div>
<div class="mt-0.5 text-xs font-mono text-gray-500" dir="ltr">{{ auth.user?.phone }}</div>
</div>
</div>
<a-descriptions :column="1" size="small">
<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-item label="موبایل"><span dir="ltr" class="font-mono">{{ auth.user?.phone }}</span></a-descriptions-item>
<a-descriptions-item label="نقش دسترسی">
<a-tag :color="auth.user?.role === 'ADMIN' ? 'orange' : 'blue'">
{{ auth.user && ROLE_LABELS[auth.user.role] }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-card>
<!-- Change Password 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" />
<a-input-password v-model:value="model.currentPassword" class="ltr-input h-10" autocomplete="current-password" />
</FormField>
<FormField path="newPassword" label="رمز جدید" hint="حداقل ۸ کاراکتر" required>
<a-input-password v-model:value="model.newPassword" class="ltr-input" autocomplete="new-password" />
<a-input-password v-model:value="model.newPassword" class="ltr-input h-10" autocomplete="new-password" />
</FormField>
<a-button type="primary" html-type="submit" :loading="saving">تغییر رمز</a-button>
<a-button type="primary" html-type="submit" :loading="saving" class="w-full md:w-auto h-10 !font-bold">
تغییر رمز ورود
</a-button>
</a-form>
</a-card>
</div>
+102 -4
View File
@@ -8,6 +8,7 @@ import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { SectionResponse } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
import { useAuthStore } from "@/stores/auth";
import { jalali, SITE_URL } from "@/utils/format";
import AboutForm from "@/sections/AboutForm.vue";
import ContactForm from "@/sections/ContactForm.vue";
@@ -37,6 +38,7 @@ const FORMS: Record<SectionKey, Component> = {
footer: FooterForm,
};
const auth = useAuthStore();
const route = useRoute();
const key = String(route.params.key);
const valid = isSectionKey(key);
@@ -55,7 +57,11 @@ 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);
const data = res.data as any;
if (sectionKey !== "settings" && data.enabled === undefined) {
data.enabled = true;
}
reset(data as never);
updatedAt.value = res.updatedAt;
loaded.value = true;
} catch (e) {
@@ -79,6 +85,33 @@ async function save() {
}
}
const togglingVisibility = ref(false);
async function onVisibilityChange(val: any) {
const nextVal = Boolean(val);
(model.value as any).enabled = nextVal;
togglingVisibility.value = true;
try {
const res = await api<{ key: string; enabled: boolean; updatedAt: string }>(
`/admin/sections/${sectionKey}/visibility`,
{
method: "PATCH",
body: { enabled: nextVal },
},
);
(model.value as any).enabled = res.enabled !== false;
updatedAt.value = res.updatedAt;
void message.success(
`وضعیت نمایش به ${nextVal ? "نمایش" : "عدم نمایش"} تغییر یافت`,
);
} catch (e) {
(model.value as any).enabled = !nextVal;
void message.error(errorMessage(e));
} finally {
togglingVisibility.value = false;
}
}
if (valid) void load();
</script>
@@ -90,16 +123,81 @@ if (valid) void load();
: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">
<div v-if="sectionKey !== 'settings'" class="max-lg:hidden flex items-center gap-2 me-2">
<span class="text-sm font-medium text-gray-700">نمایش در سایت:</span>
<a-switch
:checked="(model as any)?.enabled !== false"
:loading="togglingVisibility"
checked-children="نمایش"
un-checked-children="مخفی"
@change="onVisibilityChange"
/>
</div>
<a :href="SITE_URL" target="_blank" rel="noopener" class="max-lg:hidden"><a-button>مشاهده سایت</a-button></a>
<a-button
type="primary"
class="max-lg:hidden"
:loading="saving"
:disabled="!loaded || (sectionKey === 'settings' && !auth.isAdmin)"
@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">
<a-alert
v-if="loaded && sectionKey === 'settings' && !auth.isAdmin"
type="error"
show-icon
message="فقط مدیر کل می‌تواند تنظیمات سایت را تغییر دهد."
class="mb-4"
/>
<a-alert
v-else-if="loaded && sectionKey !== 'settings' && (model as any).enabled === false"
type="warning"
show-icon
message="این بخش در حال حاضر در سایت نمایش داده نمی‌شود (مخفی است)."
class="mb-4"
/>
<a-form v-if="loaded" layout="vertical" class="pb-20 lg:pb-0" @submit.prevent="save">
<component :is="FORMS[sectionKey]" v-model="model" />
</a-form>
</a-spin>
<!-- Mobile Sticky Bottom Action Bar -->
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-2.5 backdrop-blur-md shadow-lg flex items-center justify-between gap-3">
<div v-if="sectionKey !== 'settings'" class="flex items-center gap-2">
<a-switch
:checked="(model as any)?.enabled !== false"
:loading="togglingVisibility"
size="small"
@change="onVisibilityChange"
/>
<span class="text-xs font-medium text-gray-700">
{{ (model as any)?.enabled !== false ? 'نمایش در سایت' : 'مخفی' }}
</span>
</div>
<div v-else class="text-xs text-gray-500 font-medium">تنظیمات پایه</div>
<div class="flex items-center gap-2">
<a :href="SITE_URL" target="_blank" rel="noopener">
<a-button size="middle" class="!px-3" title="مشاهده سایت">
<template #icon><VuesaxIcon name="eye" :size="16" /></template>
</a-button>
</a>
<a-button
type="primary"
size="middle"
class="!font-bold !px-5"
:loading="saving"
:disabled="!loaded || (sectionKey === 'settings' && !auth.isAdmin)"
@click="save"
>
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره تغییرات
</a-button>
</div>
</div>
</template>
</template>
+144 -10
View File
@@ -1,24 +1,158 @@
<script setup lang="ts">
import { SECTION_KEYS, SECTION_LABELS } from "@imen/shared";
import { SECTION_KEYS, SECTION_LABELS, type SectionKey } from "@imen/shared";
import { message } from "ant-design-vue";
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { api, errorMessage } from "@/api/http";
import type { AdminSectionSummary } from "@/api/types";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { jalali } from "@/utils/format";
const router = useRouter();
const auth = useAuthStore();
// site-wide settings are editable by admins only
const keys = SECTION_KEYS.filter((k) => k !== "settings" || auth.isAdmin);
const loading = ref(true);
const sectionEnabled = reactive<Record<string, boolean>>({
hero: true,
tape: true,
stats: true,
productsIntro: true,
about: true,
services: true,
standards: true,
industries: true,
testimonials: true,
contact: true,
footer: true,
});
const sectionUpdatedAt = reactive<Record<string, string | null>>({});
const toggling = reactive<Record<string, boolean>>({});
function isEnabled(key: SectionKey): boolean {
return sectionEnabled[key] !== false;
}
function getUpdatedAt(key: SectionKey): string | null {
return sectionUpdatedAt[key] ?? null;
}
async function fetchSections() {
loading.value = true;
try {
const list = await api<AdminSectionSummary[]>("/admin/sections");
for (const item of list) {
sectionEnabled[item.key] = item.enabled !== false;
sectionUpdatedAt[item.key] = item.updatedAt;
}
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
async function toggle(key: SectionKey, nextVal: boolean) {
if (key === "settings") return;
const prevVal = sectionEnabled[key] !== false;
sectionEnabled[key] = nextVal;
toggling[key] = true;
try {
const res = await api<{ key: string; enabled: boolean; updatedAt: string }>(
`/admin/sections/${key}/visibility`,
{
method: "PATCH",
body: { enabled: nextVal },
},
);
sectionEnabled[key] = res.enabled !== false;
sectionUpdatedAt[key] = res.updatedAt;
void message.success(
`وضعیت «${SECTION_LABELS[key].title}» به ${nextVal ? "نمایش" : "عدم نمایش"} تغییر یافت`,
);
} catch (e) {
sectionEnabled[key] = prevVal;
void message.error(errorMessage(e));
} finally {
toggling[key] = false;
}
}
function goToEdit(key: SectionKey) {
void router.push({ name: "section-edit", params: { key } });
}
onMounted(() => {
void fetchSections();
});
</script>
<template>
<PageHeader
title="بخش‌های سایت"
subtitle="متن‌ها، تصاویر و فهرست‌های صفحه اصلی؛ تغییرات پس از ذخیره حداکثر ظرف یک دقیقه روی سایت دیده می‌شوند"
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>
<a-spin :spinning="loading">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div
v-for="key in keys"
:key="key"
class="block group"
>
<a-card
hoverable
class="h-full flex flex-col justify-between transition-all"
:class="{ 'opacity-75 bg-gray-50/60': key !== 'settings' && !isEnabled(key) }"
>
<div>
<div class="flex items-center justify-between gap-2">
<div
class="flex items-center gap-2 cursor-pointer flex-1"
@click="goToEdit(key)"
>
<span class="font-bold text-gray-800 group-hover:text-orange-600 transition-colors">
{{ SECTION_LABELS[key].title }}
</span>
<a-tag v-if="key === 'settings'" color="blue">تنظیمات پایه</a-tag>
<a-tag v-else-if="!isEnabled(key)" color="error">مخفی</a-tag>
</div>
<!-- Switch toggle: standalone button without any outer anchor tag -->
<div
v-if="key !== 'settings'"
class="flex items-center flex-shrink-0"
>
<a-switch
:checked="isEnabled(key)"
:loading="toggling[key]"
checked-children="نمایش"
un-checked-children="مخفی"
@change="(val: any) => toggle(key, Boolean(val))"
/>
</div>
</div>
<p
class="mt-2 text-sm leading-6 text-gray-500 cursor-pointer"
@click="goToEdit(key)"
>
{{ SECTION_LABELS[key].desc }}
</p>
</div>
<div
class="mt-4 flex items-center justify-between border-t border-gray-100 pt-3 text-xs text-gray-400 cursor-pointer"
@click="goToEdit(key)"
>
<span class="text-primary font-medium group-hover:underline">ویرایش بخش ←</span>
<span v-if="getUpdatedAt(key)">آخرین تغییر: {{ jalali(getUpdatedAt(key)) }}</span>
</div>
</a-card>
</div>
</div>
</a-spin>
</template>
+154 -1
View File
@@ -110,7 +110,8 @@ onMounted(load);
</a-button>
</PageHeader>
<a-card :body-style="{ padding: 0 }">
<!-- Desktop Table (Preserved for lg+) -->
<a-card :body-style="{ padding: 0 }" class="max-lg:hidden">
<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>
@@ -139,6 +140,155 @@ onMounted(load);
</a-table>
</a-card>
<!-- Mobile User Cards (App Experience for <lg) -->
<div class="lg:hidden space-y-3">
<a-spin :spinning="loading">
<div v-if="!rows.length && !loading" class="rounded-xl border border-dashed border-gray-200 bg-white p-8 text-center">
<div class="text-sm text-gray-500">کاربری یافت نشد</div>
</div>
<div v-else class="space-y-3">
<div
v-for="user in rows"
:key="user.id"
class="rounded-xl border border-gray-100 bg-white p-4 shadow-sm transition-all"
>
<!-- Header: User Avatar & Role Tag -->
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3">
<div
class="flex size-11 flex-shrink-0 items-center justify-center rounded-full font-bold text-white shadow-sm"
:class="user.role === 'ADMIN' ? 'bg-gradient-to-tr from-orange-600 to-amber-500' : 'bg-gradient-to-tr from-blue-600 to-sky-500'"
>
{{ user.name.slice(0, 1) }}
</div>
<div>
<div class="font-bold text-gray-900">{{ user.name }}</div>
<a
:href="`tel:${user.phone}`"
class="mt-0.5 inline-block text-xs font-mono text-gray-500 hover:text-orange-600"
dir="ltr"
>
{{ user.phone }}
</a>
</div>
</div>
<div class="flex flex-col items-end gap-1">
<a-tag :color="user.role === 'ADMIN' ? 'orange' : 'blue'" class="!m-0 text-[11px]">
{{ ROLE_LABELS[user.role as keyof typeof ROLE_LABELS] }}
</a-tag>
<a-tag :color="user.isActive ? 'green' : 'default'" class="!m-0 text-[10px]">
{{ user.isActive ? "فعال" : "غیرفعال" }}
</a-tag>
</div>
</div>
<!-- Bottom Meta & Actions -->
<div class="mt-3.5 flex items-center justify-between border-t border-gray-100 pt-3 text-xs text-gray-400">
<div class="flex items-center gap-1.5">
<span>آخرین ورود:</span>
<span class="font-medium text-gray-600">{{ jalali(user.lastLoginAt) }}</span>
</div>
<div class="flex items-center gap-1.5">
<a-button size="small" class="!flex !items-center !gap-1" @click="openEdit(user)">
<template #icon><VuesaxIcon name="edit-2" :size="14" /></template>
<span>ویرایش</span>
</a-button>
<a-popconfirm
v-if="user.id !== auth.user?.id"
title="این کاربر حذف شود؟"
ok-text="حذف"
cancel-text="انصراف"
@confirm="remove(user)"
>
<a-button size="small" danger class="!px-2">
<template #icon><VuesaxIcon name="trash" :size="14" /></template>
</a-button>
</a-popconfirm>
</div>
</div>
</div>
</div>
</a-spin>
</div>
<!-- Mobile Floating Action Button (FAB) for New User -->
<button
type="button"
class="lg:hidden fixed bottom-20 left-4 z-30 flex size-13 items-center justify-center rounded-full bg-[#ff5a00] text-white shadow-lg shadow-orange-500/30 transition-transform active:scale-95"
title="کاربر جدید"
@click="openNew"
>
<VuesaxIcon name="add" :size="24" />
</button>
<!-- Mobile Collapsible Role Permissions Guide -->
<details class="lg:hidden mt-4 rounded-xl border border-gray-200 bg-white p-3 text-xs shadow-sm">
<summary class="cursor-pointer font-bold text-gray-700 flex items-center justify-between select-none">
<span class="flex items-center gap-1.5">
<VuesaxIcon name="info-circle" :size="16" class="text-orange-500" />
راهنمای نقش‌ها و مجوزهای دسترسی
</span>
<span class="text-gray-400 text-[10px]">لمس برای مشاهده</span>
</summary>
<div class="mt-3 space-y-3 pt-3 border-t border-gray-100">
<div class="rounded-lg border border-orange-200 bg-orange-50/40 p-2.5">
<div class="flex items-center gap-2 font-bold text-orange-700">
<a-tag color="orange">مدیر کل (ADMIN)</a-tag>
<span class="text-[11px]">دسترسی ارشد سیستمی</span>
</div>
<ul class="mt-2 space-y-1 text-[11px] text-gray-600 list-disc list-inside">
<li>مدیریت کاربران، نقش‌ها و تنظیمات امنیتی</li>
<li>تنظیمات پایه سایت، سئو، شبکه‌ها و ساعات کاری</li>
<li>حذف داده‌ها (محصولات، دسته‌ها، پیام‌ها، رسانه‌ها)</li>
</ul>
</div>
<div class="rounded-lg border border-blue-200 bg-blue-50/40 p-2.5">
<div class="flex items-center gap-2 font-bold text-blue-700">
<a-tag color="blue">ویرایشگر (EDITOR)</a-tag>
<span class="text-[11px]">دسترسی محتوایی</span>
</div>
<ul class="mt-2 space-y-1 text-[11px] text-gray-600 list-disc list-inside">
<li>ایجاد، ویرایش و انتشار محصولات و دسته‌بندی‌ها</li>
<li>ویرایش محتوای بخش‌های صفحه اصلی</li>
<li>آپلود تصاویر در رسانه و مشاهده پیام‌های تماس</li>
</ul>
</div>
</div>
</details>
<!-- Desktop Role Permissions Reference Cards -->
<div class="mt-6 max-lg:hidden grid gap-4 md:grid-cols-2">
<a-card size="small" class="border-orange-200 bg-orange-50/30">
<div class="flex items-center gap-2 font-bold text-orange-700">
<a-tag color="orange">مدیر کل (ADMIN)</a-tag>
<span class="text-xs">دسترسی ارشد سیستمی</span>
</div>
<ul class="mt-2.5 space-y-1.5 text-xs text-gray-600 list-disc list-inside">
<li>مدیریت کاربران، افزودن کاربر و تغییر نقش‌ها و وضعیت حساب</li>
<li>دسترسی کامل به تنظیمات پایه سایت، ساعات کاری، شبکه‌ها و سئو</li>
<li>مجوز حذف محصولات، دسته‌بندی‌ها، رسانه‌ها و پیام‌های تماس</li>
<li>مشاهده لاگ‌های امنیتی و رویدادهای کامل سامانه</li>
</ul>
</a-card>
<a-card size="small" class="border-blue-200 bg-blue-50/30">
<div class="flex items-center gap-2 font-bold text-blue-700">
<a-tag color="blue">ویرایشگر (EDITOR)</a-tag>
<span class="text-xs">دسترسی محتوایی</span>
</div>
<ul class="mt-2.5 space-y-1.5 text-xs text-gray-600 list-disc list-inside">
<li>ایجاد، ویرایش، کپی و انتشار محصولات و گروه‌ها</li>
<li>ویرایش محتوای بخش‌های صفحه اصلی و فعال/مخفی‌سازی نمایش آنها</li>
<li>آپلود و ویرایش اطلاعات رسانه‌ها در کتابخانه رسانه</li>
<li>مشاهده پیام‌های تماس و به‌روزرسانی وضعیت (بدون دسترسی حذف یا مدیریت کاربران)</li>
</ul>
</a-card>
</div>
<a-modal
:open="editing !== null"
:title="isNew ? 'کاربر جدید' : 'ویرایش کاربر'"
@@ -168,6 +318,9 @@ onMounted(load);
:disabled="editing === auth.user?.id"
:options="ROLE_OPTIONS"
/>
<div class="mt-1.5 text-[11px] text-gray-500">
{{ model.role === 'ADMIN' ? 'مدیر کل: دسترسی کامل سیستمی، حذف داده‌ها، تنظیمات و مدیریت کاربران' : 'ویرایشگر: ویرایش محتوا، محصولات، بخش‌ها و پیام‌ها (بدون حذف)' }}
</div>
</FormField>
<FormField v-if="!isNew" path="isActive">
<a-checkbox v-model:checked="model.isActive" :disabled="editing === auth.user?.id">حساب فعال است</a-checkbox>
+69
View File
@@ -39,3 +39,72 @@ textarea.ltr-input {
direction: ltr;
text-align: left;
}
/* Remove borders from all icon buttons and action buttons across the admin panel */
.ant-btn-icon-only,
.ant-btn.ant-btn-icon-only,
.ant-table .ant-btn:not(.ant-btn-primary),
.ant-space .ant-btn:not(.ant-btn-primary),
.ant-btn:has(> .vx-icon:only-child):not(.ant-btn-primary),
.ant-btn:has(> span > .vx-icon:only-child):not(.ant-btn-primary) {
border: none !important;
box-shadow: none !important;
}
.ant-btn-icon-only:not(.ant-btn-primary):not(:hover),
.ant-table .ant-btn:not(.ant-btn-primary):not(:hover),
.ant-space .ant-btn:not(.ant-btn-primary):not(:hover) {
background: transparent !important;
}
.ant-btn-icon-only:not(.ant-btn-primary):hover,
.ant-table .ant-btn:not(.ant-btn-primary):hover,
.ant-space .ant-btn:not(.ant-btn-primary):hover {
border: none !important;
background: #f1f5f9 !important;
}
.ant-btn-icon-only.ant-btn-dangerous,
.ant-table .ant-btn.ant-btn-dangerous,
.ant-space .ant-btn.ant-btn-dangerous {
border: none !important;
color: #ef4444 !important;
}
.ant-btn-icon-only.ant-btn-dangerous:hover,
.ant-table .ant-btn.ant-btn-dangerous:hover,
.ant-space .ant-btn.ant-btn-dangerous:hover {
border: none !important;
background: #fee2e2 !important;
color: #dc2626 !important;
}
/* Ensure proper spacing and optical vertical alignment for icons in buttons across the admin */
.ant-btn {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
}
.ant-btn > .anticon,
.ant-btn > .vx-icon,
.ant-btn > .ant-btn-icon {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
line-height: 0 !important;
vertical-align: middle !important;
}
.ant-btn-icon-only {
gap: 0 !important;
}
.ant-btn-icon-only > .vx-icon,
.ant-btn-icon-only > .anticon,
.ant-btn-icon-only > .ant-btn-icon {
margin: 0 !important;
}