269 lines
11 KiB
Vue
269 lines
11 KiB
Vue
<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>
|