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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user