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