admin: Slice 3d moderation UI (comments + chat)
- hooks: useAdminComments/ChatMessages, useAdminCommentModerate/Delete, AdminCommentRow/AdminChatMessageRow types. - /admin/moderation: tabbed page — comments (hide/unhide/delete, filter by visibility & top/reply) and read-only chat browse. - sidebar "مدیریت محتوا" nav link. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b3189db773
commit
97948767a0
@ -365,6 +365,59 @@ export interface AdminCollectionRow {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/* --------------------------- moderation (3d) --------------------------- */
|
||||
|
||||
export interface AdminCommentRow {
|
||||
id: string;
|
||||
productId: string;
|
||||
productTitle: string | null;
|
||||
userPhone: string | null;
|
||||
comment: string;
|
||||
parentId: string | null;
|
||||
isReply: boolean;
|
||||
repliesCount: number;
|
||||
isHidden: boolean;
|
||||
isVerifiedBuyer: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminChatMessageRow {
|
||||
id: string;
|
||||
content: string;
|
||||
senderPhone: string | null;
|
||||
threadId: string | null;
|
||||
productId: string | null;
|
||||
imageUrl: string | null;
|
||||
isDeleted: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const useAdminComments = (params: AdminListParams) =>
|
||||
useAdminList<AdminCommentRow>("moderation/comments", params);
|
||||
export const useAdminChatMessages = (params: AdminListParams) =>
|
||||
useAdminList<AdminChatMessageRow>("moderation/chat", params);
|
||||
|
||||
export function useAdminCommentModerate() {
|
||||
const token = useAuthToken();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, isHidden }: { id: string; isHidden: boolean }) =>
|
||||
adminPatch(`moderation/comments/${id}/`, { isHidden }, token as string),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: ["admin", "moderation/comments"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminCommentDelete() {
|
||||
const token = useAuthToken();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminDelete(`moderation/comments/${id}/`, token as string),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: ["admin", "moderation/comments"] }),
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------- AI pipelines health (3c) ---------------------- */
|
||||
|
||||
interface FailureRow {
|
||||
|
||||
228
app/routes/admin.moderation.tsx
Normal file
228
app/routes/admin.moderation.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff, Trash2 } from "lucide-react";
|
||||
import {
|
||||
useAdminComments,
|
||||
useAdminChatMessages,
|
||||
useAdminCommentModerate,
|
||||
useAdminCommentDelete,
|
||||
type AdminCommentRow,
|
||||
type AdminChatMessageRow,
|
||||
} from "~/requestHandler/use-admin-hooks";
|
||||
import {
|
||||
AdminTable,
|
||||
AdminPagination,
|
||||
AdminToolbar,
|
||||
AdminSearch,
|
||||
AdminSelect,
|
||||
AdminPageTitle,
|
||||
StatusBadge,
|
||||
faDate,
|
||||
useDebouncedValue,
|
||||
type Column,
|
||||
} from "~/components/admin/DataTable";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function AdminModeration() {
|
||||
const [tab, setTab] = useState<"comments" | "chat">("comments");
|
||||
return (
|
||||
<div>
|
||||
<AdminPageTitle>مدیریت محتوا</AdminPageTitle>
|
||||
<div className="flex gap-1 mb-5 border-b border-WHITE3">
|
||||
{(["comments", "chat"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-[13.5px] font-semibold border-b-2 -mb-px transition-colors",
|
||||
tab === t ? "border-BLACK text-BLACK" : "border-transparent text-GRAY hover:text-BLACK2"
|
||||
)}
|
||||
>
|
||||
{t === "comments" ? "نظرات" : "گفتگوها"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === "comments" ? <CommentsTab /> : <ChatTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsTab() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [hidden, setHidden] = useState("");
|
||||
const [kind, setKind] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const { data, isLoading } = useAdminComments({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
search: debouncedSearch,
|
||||
is_hidden: hidden,
|
||||
kind,
|
||||
});
|
||||
const moderate = useAdminCommentModerate();
|
||||
const del = useAdminCommentDelete();
|
||||
|
||||
const reset = (fn: () => void) => {
|
||||
fn();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const onDelete = (r: AdminCommentRow) => {
|
||||
if (confirm("حذف این نظر؟ این عمل بازگشتپذیر نیست.")) del.mutate(r.id);
|
||||
};
|
||||
|
||||
const columns: Column<AdminCommentRow>[] = [
|
||||
{
|
||||
header: "نظر",
|
||||
render: (r) => (
|
||||
<div className="max-w-[340px]">
|
||||
<p className={cn("text-[13px] break-words", r.isHidden && "text-GRAY line-through")}>
|
||||
{r.comment}
|
||||
</p>
|
||||
<p className="text-GRAY text-[11px] mt-0.5 truncate">
|
||||
{r.productTitle || "—"}
|
||||
{r.isReply && <span className="text-VITROWN_BLUE"> · پاسخ</span>}
|
||||
{!r.isReply && r.repliesCount > 0 && <span> · {r.repliesCount} پاسخ</span>}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "کاربر",
|
||||
render: (r) => (
|
||||
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
|
||||
{r.userPhone || "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "وضعیت",
|
||||
render: (r) =>
|
||||
r.isHidden ? (
|
||||
<StatusBadge label="پنهان" tone="red" />
|
||||
) : (
|
||||
<StatusBadge label="نمایش" tone="green" />
|
||||
),
|
||||
},
|
||||
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
|
||||
{
|
||||
header: "",
|
||||
render: (r) => (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => moderate.mutate({ id: r.id, isHidden: !r.isHidden })}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
|
||||
aria-label={r.isHidden ? "نمایش" : "پنهان کردن"}
|
||||
title={r.isHidden ? "نمایش" : "پنهان کردن"}
|
||||
>
|
||||
{r.isHidden ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(r)}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
|
||||
aria-label="حذف"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminToolbar>
|
||||
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی متن نظر / کاربر…" />
|
||||
<AdminSelect
|
||||
value={hidden}
|
||||
onChange={(v) => reset(() => setHidden(v))}
|
||||
options={[
|
||||
{ value: "", label: "همه" },
|
||||
{ value: "false", label: "نمایشدادهشده" },
|
||||
{ value: "true", label: "پنهانشده" },
|
||||
]}
|
||||
/>
|
||||
<AdminSelect
|
||||
value={kind}
|
||||
onChange={(v) => reset(() => setKind(v))}
|
||||
options={[
|
||||
{ value: "", label: "همه" },
|
||||
{ value: "top", label: "نظر اصلی" },
|
||||
{ value: "reply", label: "پاسخ" },
|
||||
]}
|
||||
/>
|
||||
</AdminToolbar>
|
||||
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="نظری ثبت نشده است" />
|
||||
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatTab() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [deleted, setDeleted] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const { data, isLoading } = useAdminChatMessages({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
search: debouncedSearch,
|
||||
is_deleted: deleted,
|
||||
});
|
||||
|
||||
const reset = (fn: () => void) => {
|
||||
fn();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const columns: Column<AdminChatMessageRow>[] = [
|
||||
{
|
||||
header: "پیام",
|
||||
render: (r) => (
|
||||
<div className="max-w-[360px]">
|
||||
<p className={cn("text-[13px] break-words", r.isDeleted && "text-GRAY line-through")}>
|
||||
{r.content || (r.imageUrl ? "🖼 تصویر" : "—")}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "فرستنده",
|
||||
render: (r) => (
|
||||
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
|
||||
{r.senderPhone || "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "وضعیت",
|
||||
render: (r) =>
|
||||
r.isDeleted ? <StatusBadge label="حذفشده" tone="gray" /> : <StatusBadge label="فعال" tone="green" />,
|
||||
},
|
||||
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminToolbar>
|
||||
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی متن پیام / فرستنده…" />
|
||||
<AdminSelect
|
||||
value={deleted}
|
||||
onChange={(v) => reset(() => setDeleted(v))}
|
||||
options={[
|
||||
{ value: "", label: "همه" },
|
||||
{ value: "false", label: "فعال" },
|
||||
{ value: "true", label: "حذفشده" },
|
||||
]}
|
||||
/>
|
||||
</AdminToolbar>
|
||||
<p className="text-GRAY text-[12px] mb-2">مشاهدهی فقطخواندنی گفتگوها.</p>
|
||||
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => r.id} emptyText="پیامی یافت نشد" />
|
||||
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ import {
|
||||
LayoutGrid,
|
||||
Landmark,
|
||||
Activity,
|
||||
MessageSquareWarning,
|
||||
ScrollText,
|
||||
ArrowRight,
|
||||
ShieldCheck,
|
||||
@ -35,6 +36,7 @@ const NAV: NavItem[] = [
|
||||
{ label: "مجموعهها", to: "/admin/collections", icon: LayoutGrid },
|
||||
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
|
||||
{ label: "سلامت AI", to: "/admin/ai-health", icon: Activity },
|
||||
{ label: "مدیریت محتوا", to: "/admin/moderation", icon: MessageSquareWarning },
|
||||
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
|
||||
];
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user