Files
imen_pishghaman/apps/admin/src/pages/CategoryEditPage.vue
T

269 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { categoryInputSchema, type MediaDto } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { z } from "zod";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { AdminCategoryDetail } from "@/api/types";
import FormField from "@/components/FormField.vue";
import IconSelect from "@/components/IconSelect.vue";
import ImageField from "@/components/ImageField.vue";
import ListEditor from "@/components/ListEditor.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useZodForm } from "@/composables/useZodForm";
import { toSlug } from "@/utils/slug";
type Form = z.output<typeof categoryInputSchema>;
type Group = Form["groups"][number];
const route = useRoute();
const router = useRouter();
const id = computed(() => (route.params.id ? Number(route.params.id) : null));
const empty: Form = {
slug: "",
code: "",
name: "",
nameEn: "",
shortDesc: "",
fullDesc: "",
icon: "",
coverImageId: null,
isPublished: true,
seoTitle: "",
seoDescription: "",
groups: [],
};
const { model, validate, handleError, reset } = useZodForm(categoryInputSchema, empty);
const cover = ref<MediaDto | null>(null);
/** product count per existing group id, to warn before deleting a group */
const groupProducts = ref<Record<number, number>>({});
const loading = ref(false);
const saving = ref(false);
async function load() {
if (!id.value) return;
loading.value = true;
try {
const c = await api<AdminCategoryDetail>(`/admin/categories/${id.value}`);
reset({
slug: c.slug,
code: c.code,
name: c.name,
nameEn: c.nameEn,
shortDesc: c.shortDesc,
fullDesc: c.fullDesc,
icon: c.icon,
coverImageId: c.coverImageId,
isPublished: c.isPublished,
seoTitle: c.seoTitle,
seoDescription: c.seoDescription,
groups: c.groups.map((g) => ({ id: g.id, slug: g.slug, name: g.name, desc: g.desc })),
});
cover.value = c.coverImage;
groupProducts.value = Object.fromEntries(c.groups.map((g) => [g.id, g.productCount]));
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
}
function pickCover(m: MediaDto) {
cover.value = m;
model.value.coverImageId = m.id;
}
async function save() {
const data = validate();
if (!data) return;
saving.value = true;
try {
const saved = await api<AdminCategoryDetail>(
id.value ? `/admin/categories/${id.value}` : "/admin/categories",
{ method: id.value ? "PUT" : "POST", body: data },
);
void message.success("دسته ذخیره شد");
if (!id.value) await router.replace({ name: "category-edit", params: { id: saved.id } });
await load();
} catch (e) {
handleError(e);
} finally {
saving.value = false;
}
}
const newGroup = (): Group => ({ slug: "", name: "", desc: "" });
const groupTitle = (g: Group) => {
const count = g.id ? groupProducts.value[g.id] : 0;
return count ? `${g.name} (${count} محصول)` : g.name;
};
const mobileTab = ref<"main" | "groups" | "media" | "seo">("main");
onMounted(load);
</script>
<template>
<PageHeader :title="id ? `ویرایش دسته: ${model.name}` : 'دسته جدید'" back="/categories">
<a-button type="primary" :loading="saving" @click="save">
<template #icon><VuesaxIcon name="save-2" :size="16" /></template>
ذخیره
</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" 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="اطلاعات اصلی" :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" />
</FormField>
<FormField path="nameEn" label="نام انگلیسی" hint="زیر نام دسته نمایش داده می‌شود">
<a-input v-model:value="model.nameEn" class="ltr-input" />
</FormField>
<FormField path="slug" label="اسلاگ (آدرس صفحه)" :hint="`آدرس: /categories/${model.slug || '...'}`" required>
<a-input v-model:value="model.slug" class="ltr-input">
<template #suffix>
<a class="text-xs" @click="model.slug = toSlug(model.nameEn)">ساخت از نام انگلیسی</a>
</template>
</a-input>
</FormField>
<FormField path="code" label="کد دسته" required>
<a-input v-model:value="model.code" class="ltr-input" placeholder="IP-C09" />
</FormField>
<FormField path="shortDesc" label="توضیح کوتاه" class="md:col-span-2">
<a-textarea v-model:value="model.shortDesc" :auto-size="{ minRows: 2, maxRows: 4 }" />
</FormField>
<FormField path="fullDesc" label="توضیح کامل (بالای صفحه دسته)" class="md:col-span-2">
<a-textarea v-model:value="model.fullDesc" :auto-size="{ minRows: 3, maxRows: 8 }" />
</FormField>
</div>
</a-card>
<a-card title="زیرگروه‌ها" :body-style="{ background: '#fafafa' }" :class="mobileTab !== 'groups' && 'max-lg:hidden'">
<p class="mb-4 text-sm text-gray-500">
زیرگروه‌ها در صفحه دسته به‌صورت فیلتر نمایش داده می‌شوند. با حذف یک زیرگروه، محصولات آن بدون زیرگروه می‌مانند.
</p>
<ListEditor
v-model="model.groups"
:create="newGroup"
add-label="افزودن زیرگروه"
:item-title="groupTitle"
>
<template #default="{ item, index }">
<div class="grid gap-x-4 md:grid-cols-2">
<FormField :path="`groups.${index}.name`" label="نام" required>
<a-input v-model:value="item.name" />
</FormField>
<FormField :path="`groups.${index}.slug`" label="اسلاگ" required>
<a-input v-model:value="item.slug" class="ltr-input" />
</FormField>
<FormField :path="`groups.${index}.desc`" label="توضیح" class="md:col-span-2">
<a-textarea v-model:value="item.desc" :auto-size="{ minRows: 1, maxRows: 4 }" />
</FormField>
</div>
</template>
</ListEditor>
</a-card>
</div>
<div class="space-y-6">
<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="تصویر کاور" :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="آیکون" :class="mobileTab !== 'media' && 'max-lg:hidden'">
<FormField path="icon">
<IconSelect v-model="model.icon" allow-clear />
</FormField>
</a-card>
<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>
<FormField path="seoDescription" label="توضیحات متا" hint="خالی = توضیح کامل">
<a-textarea v-model:value="model.seoDescription" :maxlength="300" show-count :auto-size="{ minRows: 3 }" />
</FormField>
</a-card>
</div>
</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>