feat: complete UI/UX improvements, vuesax icons, media server, and section management
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user