- 数据模型: vote/review/submission/claim/use_case/audience/collection/event (+tool 冗余统计列) - 社区: 投票(限流+原子toggle+API) / 评论(评分+优缺点+Helpful+聚合回写) / 提交(重复检测+审批发布) / 认领(Owner白名单编辑) - Ranking Engine: trending/popular/most-voted/most-saved/highest-rated/new/editors-choice (纯函数可测) - 发现体系: Use Case + Audience + Collection 三维发现 / /ai-tools-for / /best SEO 页 - Programmatic SEO: 页面生成器 / sitemap 270条 / AggregateRating/FAQPage/Breadcrumb/ItemList schema / alternatives + vs 对比页 - Analytics: event 事件流 + 外链跳转埋点 + /admin/analytics CTR 与搜索词分析 - Admin: submissions/claims/reviews/discovery/vote-audit/analytics 六个新后台 + 仪表盘扩展 - 主题系统: themes registry + 五套站点 (classic/product/editorial/niche/minimal) 构建期注入 - 认证: GitHub OAuth (env门控) + 登录回跳 redirect - i18n: 新增 7 个命名空间中英文案 (546 键一致) - 测试: bun test 32 用例 (ranking/slug/去重/SEO生成器) + Playwright e2e 脚本 - 种子: 14分类/16用例/9受众/40工具/6用户/投票/评论/6合集/7718事件
106 lines
3.5 KiB
TypeScript
106 lines
3.5 KiB
TypeScript
"use client";
|
|
|
|
import { useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { EyeOff, Eye, Star } from "lucide-react";
|
|
import { setReviewStatus } from "@/features/reviews/actions";
|
|
import { toast } from "sonner";
|
|
|
|
interface AdminReviewRow {
|
|
id: string;
|
|
rating: number;
|
|
title: string | null;
|
|
content: string;
|
|
status: string;
|
|
createdAt: Date;
|
|
toolName: string;
|
|
toolSlug: string;
|
|
authorName: string;
|
|
}
|
|
|
|
/**
|
|
* # 组件 ReviewsAdminTable
|
|
* 说明:评论审核表 —— 隐藏/恢复(隐藏后重算工具聚合评分)。
|
|
*/
|
|
export function ReviewsAdminTable({ reviews }: { reviews: AdminReviewRow[] }) {
|
|
const router = useRouter();
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
const toggle = (id: string, currentStatus: string) => {
|
|
startTransition(async () => {
|
|
try {
|
|
await setReviewStatus(id, currentStatus === "published" ? "hidden" : "published");
|
|
toast.success(currentStatus === "published" ? "Review hidden" : "Review restored");
|
|
router.refresh();
|
|
} catch {
|
|
toast.error("Failed");
|
|
}
|
|
});
|
|
};
|
|
|
|
if (reviews.length === 0) {
|
|
return (
|
|
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
|
No reviews yet.
|
|
</p>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{reviews.map((review) => (
|
|
<div
|
|
key={review.id}
|
|
className="flex flex-wrap items-start justify-between gap-3 rounded-xl border border-border p-4"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
<span className="font-semibold text-foreground">{review.authorName}</span>
|
|
<span className="text-xs text-muted-foreground">on</span>
|
|
<span className="font-medium text-foreground">{review.toolName}</span>
|
|
<span className="inline-flex items-center gap-0.5 text-xs font-semibold text-amber-500">
|
|
<Star className="h-3 w-3 fill-current" />
|
|
{review.rating}
|
|
</span>
|
|
<span
|
|
className={
|
|
review.status === "published"
|
|
? "rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400"
|
|
: "rounded-full bg-red-500/10 px-2 py-0.5 text-[10px] font-medium text-red-600 dark:text-red-400"
|
|
}
|
|
>
|
|
{review.status}
|
|
</span>
|
|
</div>
|
|
{review.title && (
|
|
<p className="mt-1 text-sm font-medium text-foreground">{review.title}</p>
|
|
)}
|
|
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
|
{review.content}
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={isPending}
|
|
onClick={() => toggle(review.id, review.status)}
|
|
className="inline-flex shrink-0 items-center gap-1 rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground disabled:opacity-50"
|
|
>
|
|
{review.status === "published" ? (
|
|
<>
|
|
<EyeOff className="h-3.5 w-3.5" />
|
|
Hide
|
|
</>
|
|
) : (
|
|
<>
|
|
<Eye className="h-3.5 w-3.5" />
|
|
Restore
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|