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;
|
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) ---------------------- */
|
/* ---------------------- AI pipelines health (3c) ---------------------- */
|
||||||
|
|
||||||
interface FailureRow {
|
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,
|
LayoutGrid,
|
||||||
Landmark,
|
Landmark,
|
||||||
Activity,
|
Activity,
|
||||||
|
MessageSquareWarning,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@ -35,6 +36,7 @@ const NAV: NavItem[] = [
|
|||||||
{ label: "مجموعهها", to: "/admin/collections", icon: LayoutGrid },
|
{ label: "مجموعهها", to: "/admin/collections", icon: LayoutGrid },
|
||||||
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
|
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
|
||||||
{ label: "سلامت AI", to: "/admin/ai-health", icon: Activity },
|
{ label: "سلامت AI", to: "/admin/ai-health", icon: Activity },
|
||||||
|
{ label: "مدیریت محتوا", to: "/admin/moderation", icon: MessageSquareWarning },
|
||||||
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
|
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user