feat: AI Directory Platform 底座升级 + 五套主题站点

- 数据模型: 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事件
This commit is contained in:
AI Directory Platform
2026-09-08 17:38:06 +00:00
parent 41d9fcac06
commit e46b4b52a3
90 changed files with 13642 additions and 7940 deletions
+161
View File
@@ -0,0 +1,161 @@
import { ExternalLink } from "lucide-react";
import { LocaleLink } from "@/components/locale-link";
import { ToolLogo } from "@/components/tool-logo";
import { FavoriteButton } from "@/features/tools/components/favorite-button";
import { VoteButton } from "@/features/voting/components/vote-button";
import { StarRating } from "@/features/reviews/components/star-rating";
import { cn } from "@/lib/utils";
export interface ToolCardData {
id: string;
slug: string;
domain: string | null;
websiteUrl: string | null;
logoUrl: string | null;
nameEn: string;
nameZh: string | null;
taglineEn?: string | null;
taglineZh?: string | null;
descriptionEn?: string | null;
descriptionZh?: string | null;
pricing?: string | null;
voteCount?: number;
ratingAvg?: number;
reviewCount?: number;
}
interface ToolCardProps {
tool: ToolCardData;
locale: string;
/** compact: 单行(榜单);default: 带描述卡片 */
variant?: "default" | "compact";
/** 排名序号(榜单场景展示) */
rank?: number;
className?: string;
}
const pricingLabels: Record<string, { en: string; zh: string }> = {
free: { en: "Free", zh: "免费" },
freemium: { en: "Freemium", zh: "免费增值" },
paid: { en: "Paid", zh: "付费" },
free_trial: { en: "Free Trial", zh: "免费试用" },
contact: { en: "Contact", zh: "联系定价" },
open_source: { en: "Open Source", zh: "开源" },
};
/**
* # 组件 ToolCard
* 说明:Core 通用工具卡片(服务端组件,内嵌客户端投票/收藏按钮)。
* 五套主题均可复用;variant 控制密度。
*/
export function ToolCard({
tool,
locale,
variant = "default",
rank,
className,
}: ToolCardProps) {
const name = locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
const tagline =
locale === "zh"
? tool.taglineZh || tool.descriptionZh || tool.taglineEn || tool.descriptionEn
: tool.taglineEn || tool.descriptionEn || tool.taglineZh || tool.descriptionZh;
const pricing = tool.pricing ? pricingLabels[tool.pricing] : null;
return (
<div
className={cn(
"group relative flex gap-4 rounded-xl border border-border bg-background p-4 transition-all hover:border-primary/30 hover:shadow-sm",
className
)}
>
{typeof rank === "number" && (
<span className="absolute -left-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground shadow tabular-nums">
{rank}
</span>
)}
{/* Product Hunt 风格投票块 */}
<VoteButton toolId={tool.id} variant="card" initialCount={tool.voteCount ?? 0} />
<LocaleLink
href={`/tools/${tool.slug}`}
className="flex min-w-0 flex-1 items-start gap-3"
>
<ToolLogo src={tool.logoUrl} alt={name} size={44} className="shrink-0 rounded-lg" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-foreground group-hover:text-primary">
{name}
</h3>
{pricing && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{locale === "zh" ? pricing.zh : pricing.en}
</span>
)}
{(tool.ratingAvg ?? 0) > 0 && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<StarRating value={tool.ratingAvg ?? 0} />
<span className="tabular-nums">{(tool.ratingAvg ?? 0).toFixed(1)}</span>
</span>
)}
</div>
{tagline && (
<p
className={cn(
"mt-1 text-xs leading-relaxed text-muted-foreground",
variant === "compact" ? "line-clamp-1" : "line-clamp-2"
)}
>
{tagline}
</p>
)}
</div>
</LocaleLink>
<div className="flex shrink-0 flex-col items-center gap-1">
<FavoriteButton toolId={tool.id} />
{tool.websiteUrl && (
<a
href={`/api/outbound?tool=${tool.slug}`}
target="_blank"
rel="noopener noreferrer nofollow"
aria-label={`Visit ${name}`}
className="rounded-full p-2 text-muted-foreground/50 transition-colors hover:text-primary"
>
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
</div>
);
}
/**
* # 组件 ToolCardGrid
* 说明:卡片网格容器(响应式,Mobile First)。
*/
export function ToolCardGrid({
tools,
locale,
ranked = false,
className,
}: {
tools: ToolCardData[];
locale: string;
ranked?: boolean;
className?: string;
}) {
return (
<div className={cn("grid gap-3 md:grid-cols-2", className)}>
{tools.map((tool, index) => (
<ToolCard
key={tool.id}
tool={tool}
locale={locale}
rank={ranked ? index + 1 : undefined}
/>
))}
</div>
);
}