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:
@@ -94,3 +94,16 @@ NODE_ENV="development"
|
||||
# ====================================
|
||||
# Enable/disable features
|
||||
NEXT_PUBLIC_MAINTENANCE_MODE="false"
|
||||
|
||||
# ====================================
|
||||
# AI Directory Platform 扩展配置
|
||||
# ====================================
|
||||
# GitHub OAuth(配置后登录页自动显示 GitHub 按钮)
|
||||
AUTH_GITHUB_ID=""
|
||||
AUTH_GITHUB_SECRET=""
|
||||
NEXT_PUBLIC_AUTH_GITHUB_ENABLED="false"
|
||||
|
||||
# 站点主题(构建时生效):classic | product | editorial | niche | minimal
|
||||
# 多主题构建:SITE_THEME=product NEXT_DIST_DIR=.next-product bun run build
|
||||
# 多主题启动:SITE_THEME=product NEXT_DIST_DIR=.next-product next start -p 3002
|
||||
SITE_THEME="classic"
|
||||
|
||||
@@ -58,3 +58,9 @@ Desktop.ini
|
||||
|
||||
# scripts
|
||||
/scripts/manage-users.ts
|
||||
|
||||
# 多主题构建产物
|
||||
/.next-product
|
||||
/.next-editorial
|
||||
/.next-niche
|
||||
/.next-minimal
|
||||
|
||||
+119
-2
@@ -208,7 +208,8 @@
|
||||
},
|
||||
"social": {
|
||||
"googleSignIn": "Continue with Google",
|
||||
"or": "Or continue with"
|
||||
"or": "Or continue with",
|
||||
"githubSignIn": "Continue with GitHub"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "Check Your Email",
|
||||
@@ -255,7 +256,13 @@
|
||||
"categories": "Categories",
|
||||
"tags": "Tags",
|
||||
"users": "Users",
|
||||
"backToDashboard": "Back to User Dashboard"
|
||||
"backToDashboard": "Back to User Dashboard",
|
||||
"submissions": "Submissions",
|
||||
"claims": "Claims",
|
||||
"reviews": "Reviews",
|
||||
"discovery": "Discovery",
|
||||
"votes": "Vote Audit",
|
||||
"analytics": "Analytics"
|
||||
},
|
||||
"header": {
|
||||
"welcome": "Welcome",
|
||||
@@ -621,6 +628,23 @@
|
||||
},
|
||||
"categoryPage": {
|
||||
"viewAll": "View all {count} tools"
|
||||
},
|
||||
"reviewCta": "Ratings and reviews come from verified community members. Voted Helpful counts are shown next to each review.",
|
||||
"alternatives": "Alternatives",
|
||||
"seeAllAlternatives": "See all alternatives",
|
||||
"stats": {
|
||||
"votes": "Upvotes",
|
||||
"views": "Views"
|
||||
},
|
||||
"share": {
|
||||
"share": "Share",
|
||||
"copy": "Copy",
|
||||
"copyLink": "Copy link",
|
||||
"copied": "Link copied to clipboard"
|
||||
},
|
||||
"sort": {
|
||||
"latest": "Latest",
|
||||
"trending": "Trending"
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
@@ -675,5 +699,98 @@
|
||||
"visitWebsite": "Visit website",
|
||||
"empty": "No tools yet. Add some in the admin panel.",
|
||||
"viewAll": "View All Tools"
|
||||
},
|
||||
"voting": {
|
||||
"vote": "Upvote",
|
||||
"voted": "Upvoted",
|
||||
"unvote": "Remove vote",
|
||||
"loginRequired": "Please sign in to vote — you'll be brought right back.",
|
||||
"rateLimited": "Slow down a bit — too many votes in a short time.",
|
||||
"error": "Vote failed. Please try again."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Reviews",
|
||||
"writeReview": "Write a review",
|
||||
"empty": "No reviews yet — be the first to share your experience.",
|
||||
"loadMore": "Load more",
|
||||
"loading": "Loading...",
|
||||
"helpful": "Helpful",
|
||||
"verifiedUser": "Verified user",
|
||||
"pros": "Pros",
|
||||
"cons": "Cons",
|
||||
"yourRating": "Your rating",
|
||||
"ratingRequired": "Please pick a star rating first.",
|
||||
"titlePlaceholder": "Sum it up in one line (optional)",
|
||||
"contentPlaceholder": "What did you use it for? How well does it work?",
|
||||
"prosPlaceholder": "What do you like about it?",
|
||||
"consPlaceholder": "What could be better?",
|
||||
"useCasePlaceholder": "Your use case (e.g. social media content)",
|
||||
"saving": "Saving...",
|
||||
"submit": "Publish review",
|
||||
"update": "Update review",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete my review",
|
||||
"deleted": "Review deleted",
|
||||
"tooShort": "Review is a bit short — tell us at least a sentence.",
|
||||
"error": "Could not save your review. Please try again.",
|
||||
"basedOn": "Based on {count} reviews"
|
||||
},
|
||||
"submission": {
|
||||
"name": "Tool name",
|
||||
"websiteUrl": "Website URL",
|
||||
"tagline": "Tagline",
|
||||
"taglinePlaceholder": "One-line pitch shown on cards",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What does it do? Who is it for? What makes it different?",
|
||||
"logoUrl": "Logo URL",
|
||||
"category": "Category",
|
||||
"pricingLabel": "Pricing model",
|
||||
"pricing": {
|
||||
"free": "Free",
|
||||
"freemium": "Freemium",
|
||||
"paid": "Paid",
|
||||
"freeTrial": "Free Trial",
|
||||
"openSource": "Open Source",
|
||||
"contact": "Contact for pricing"
|
||||
},
|
||||
"founderName": "Founder / company",
|
||||
"contactEmail": "Contact email",
|
||||
"socialLinks": "Social links",
|
||||
"note": "Notes for the editors",
|
||||
"notePlaceholder": "Anything that helps us review faster (launch date, metrics...)",
|
||||
"selectPlaceholder": "Select...",
|
||||
"submit": "Submit for review",
|
||||
"submitting": "Submitting...",
|
||||
"submitted": "Submitted! We'll review it within 48 hours.",
|
||||
"reviewNote": "Free listing · reviewed within 48h",
|
||||
"duplicateFound": "Looks like this tool may already be listed:",
|
||||
"duplicateBlocked": "This tool appears to be already listed. Duplicate submissions are not allowed.",
|
||||
"error": "Submission failed. Please check your inputs."
|
||||
},
|
||||
"claim": {
|
||||
"claimThisTool": "Claim this tool",
|
||||
"verifiedOwner": "Verified owner",
|
||||
"formTitle": "Claim ownership of this listing",
|
||||
"rolePlaceholder": "Your role (e.g. Founder, Marketing lead)",
|
||||
"messagePlaceholder": "How should we verify you? (same-domain email, press page...)",
|
||||
"proofPlaceholder": "Proof URL (optional)",
|
||||
"submit": "Send claim",
|
||||
"submitting": "Sending...",
|
||||
"submitted": "Claim sent! We'll review it shortly.",
|
||||
"pendingExists": "You already have a pending claim for this tool.",
|
||||
"cancel": "Cancel",
|
||||
"error": "Could not send your claim."
|
||||
},
|
||||
"rankings": {
|
||||
"title": "Community Rankings",
|
||||
"subtitle": "Seven live leaderboards powered by votes, reviews, saves and engagement — not sponsored placement.",
|
||||
"trending": "Trending",
|
||||
"popular": "Popular",
|
||||
"mostVoted": "Most Voted",
|
||||
"mostSaved": "Most Saved",
|
||||
"highestRated": "Highest Rated",
|
||||
"new": "New",
|
||||
"editorsChoice": "Editor's Choice",
|
||||
"empty": "No tools on this board yet."
|
||||
}
|
||||
}
|
||||
|
||||
+119
-2
@@ -208,7 +208,8 @@
|
||||
},
|
||||
"social": {
|
||||
"googleSignIn": "使用 Google 继续",
|
||||
"or": "或者继续使用"
|
||||
"or": "或者继续使用",
|
||||
"githubSignIn": "使用 GitHub 登录"
|
||||
},
|
||||
"checkEmail": {
|
||||
"title": "请查收您的邮件",
|
||||
@@ -255,7 +256,13 @@
|
||||
"categories": "分类管理",
|
||||
"tags": "标签管理",
|
||||
"users": "用户管理",
|
||||
"backToDashboard": "返回用户面板"
|
||||
"backToDashboard": "返回用户面板",
|
||||
"submissions": "提交审核",
|
||||
"claims": "认领审核",
|
||||
"reviews": "评论审核",
|
||||
"discovery": "发现管理",
|
||||
"votes": "投票审计",
|
||||
"analytics": "数据分析"
|
||||
},
|
||||
"header": {
|
||||
"welcome": "欢迎",
|
||||
@@ -629,6 +636,23 @@
|
||||
},
|
||||
"categoryPage": {
|
||||
"viewAll": "查看该分类全部 {count} 个工具"
|
||||
},
|
||||
"reviewCta": "评分与评论来自已验证的社区用户,每条评论旁展示“有用”计数。",
|
||||
"alternatives": "替代品",
|
||||
"seeAllAlternatives": "查看全部替代品",
|
||||
"stats": {
|
||||
"votes": "票数",
|
||||
"views": "浏览量"
|
||||
},
|
||||
"share": {
|
||||
"share": "分享",
|
||||
"copy": "复制",
|
||||
"copyLink": "复制链接",
|
||||
"copied": "链接已复制"
|
||||
},
|
||||
"sort": {
|
||||
"latest": "最新",
|
||||
"trending": "最热"
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
@@ -683,5 +707,98 @@
|
||||
"visitWebsite": "访问网站",
|
||||
"empty": "暂无工具,请先在后台添加",
|
||||
"viewAll": "查看全部工具"
|
||||
},
|
||||
"voting": {
|
||||
"vote": "投票",
|
||||
"voted": "已投",
|
||||
"unvote": "取消投票",
|
||||
"loginRequired": "投票需要先登录,登录后会自动回到本页。",
|
||||
"rateLimited": "操作太快了,请稍后再试。",
|
||||
"error": "投票失败,请重试。"
|
||||
},
|
||||
"reviews": {
|
||||
"title": "用户评论",
|
||||
"writeReview": "写评论",
|
||||
"empty": "还没有评论——来分享你的使用体验吧。",
|
||||
"loadMore": "加载更多",
|
||||
"loading": "加载中...",
|
||||
"helpful": "有用",
|
||||
"verifiedUser": "已验证用户",
|
||||
"pros": "优点",
|
||||
"cons": "缺点",
|
||||
"yourRating": "你的评分",
|
||||
"ratingRequired": "请先选择星级评分。",
|
||||
"titlePlaceholder": "一句话总结(可选)",
|
||||
"contentPlaceholder": "你用它做什么?效果如何?",
|
||||
"prosPlaceholder": "它哪里做得好?",
|
||||
"consPlaceholder": "哪里还可以改进?",
|
||||
"useCasePlaceholder": "你的使用场景(如 社媒内容创作)",
|
||||
"saving": "保存中...",
|
||||
"submit": "发布评论",
|
||||
"update": "更新评论",
|
||||
"cancel": "取消",
|
||||
"delete": "删除我的评论",
|
||||
"deleted": "评论已删除",
|
||||
"tooShort": "评论有点短——至少写一句话吧。",
|
||||
"error": "评论保存失败,请重试。",
|
||||
"basedOn": "共 {count} 条评论"
|
||||
},
|
||||
"submission": {
|
||||
"name": "工具名称",
|
||||
"websiteUrl": "官网地址",
|
||||
"tagline": "一句话简介",
|
||||
"taglinePlaceholder": "展示在卡片上的一句话卖点",
|
||||
"description": "详细介绍",
|
||||
"descriptionPlaceholder": "它是做什么的?适合谁?有什么不同?",
|
||||
"logoUrl": "Logo 地址",
|
||||
"category": "分类",
|
||||
"pricingLabel": "定价模式",
|
||||
"pricing": {
|
||||
"free": "免费",
|
||||
"freemium": "免费增值",
|
||||
"paid": "付费",
|
||||
"freeTrial": "免费试用",
|
||||
"openSource": "开源",
|
||||
"contact": "联系定价"
|
||||
},
|
||||
"founderName": "创始人 / 公司",
|
||||
"contactEmail": "联系邮箱",
|
||||
"socialLinks": "社交链接",
|
||||
"note": "给编辑的备注",
|
||||
"notePlaceholder": "任何能加快审核的信息(上线时间、数据等)",
|
||||
"selectPlaceholder": "请选择...",
|
||||
"submit": "提交审核",
|
||||
"submitting": "提交中...",
|
||||
"submitted": "已提交!我们会在 48 小时内完成审核。",
|
||||
"reviewNote": "免费收录 · 48 小时内审核",
|
||||
"duplicateFound": "这个工具可能已经被收录:",
|
||||
"duplicateBlocked": "该工具似乎已被收录,不允许重复提交。",
|
||||
"error": "提交失败,请检查填写内容。"
|
||||
},
|
||||
"claim": {
|
||||
"claimThisTool": "认领此工具",
|
||||
"verifiedOwner": "已认证所有者",
|
||||
"formTitle": "认领该收录页的所有权",
|
||||
"rolePlaceholder": "你的身份(如 创始人、市场负责人)",
|
||||
"messagePlaceholder": "我们如何验证你的身份?(同域名邮箱、官方报道页等)",
|
||||
"proofPlaceholder": "证明链接(可选)",
|
||||
"submit": "发送认领申请",
|
||||
"submitting": "发送中...",
|
||||
"submitted": "认领申请已发送,我们会尽快审核。",
|
||||
"pendingExists": "你已有一条待审核的认领申请。",
|
||||
"cancel": "取消",
|
||||
"error": "认领申请发送失败。"
|
||||
},
|
||||
"rankings": {
|
||||
"title": "社区榜单",
|
||||
"subtitle": "七个实时榜单由投票、评论、收藏与互动数据驱动——不接受赞助排名。",
|
||||
"trending": "趋势榜",
|
||||
"popular": "热门榜",
|
||||
"mostVoted": "最多投票",
|
||||
"mostSaved": "最多收藏",
|
||||
"highestRated": "最高评分",
|
||||
"new": "最新收录",
|
||||
"editorsChoice": "编辑精选",
|
||||
"empty": "该榜单暂无工具。"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -14,7 +14,11 @@ const snapshotManagedPaths = fs.existsSync(pnpmManagedPath) ? [pnpmManagedPath]
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Docker 部署使用 standalone 输出
|
||||
output: "standalone",
|
||||
// 多主题构建:SITE_THEME=<id> NEXT_DIST_DIR=.next-<id> next build
|
||||
// 每个主题独立产物(一个构建 = 一个站点),由 next start 直接服务
|
||||
...(process.env.NEXT_DIST_DIR
|
||||
? { distDir: process.env.NEXT_DIST_DIR }
|
||||
: { output: "standalone" }),
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
Generated
-7694
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -40,7 +40,11 @@
|
||||
"seed:tools": "tsx scripts/seed-tools.ts",
|
||||
"generate:blog-manifest": "node scripts/generate-blog-manifest.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"i18n:check": "node scripts/check-i18n.mjs"
|
||||
"i18n:check": "node scripts/check-i18n.mjs",
|
||||
"seed:platform": "bun scripts/seed-platform.ts",
|
||||
"build:theme": "next build",
|
||||
"test:e2e": "bun scripts/e2e-interaction.ts",
|
||||
"shots": "bun scripts/screenshot-pages.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.958.0",
|
||||
@@ -67,7 +71,7 @@
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"fast-glob": "^3.3.2",
|
||||
"framer-motion": "^11.2.9",
|
||||
"framer-motion": "^12",
|
||||
"geist": "^1.3.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -94,6 +98,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键构建并启动五套主题站点(3001-3005)
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "=== 构建 5 套主题 ==="
|
||||
bun run build
|
||||
for theme in product editorial niche minimal; do
|
||||
SITE_THEME=$theme NEXT_DIST_DIR=.next-$theme bun run build
|
||||
done
|
||||
|
||||
echo "=== 启动 5 个站点 ==="
|
||||
bunx next start -p 3001 &
|
||||
SITE_THEME=product NEXT_DIST_DIR=.next-product bunx next start -p 3002 &
|
||||
SITE_THEME=editorial NEXT_DIST_DIR=.next-editorial bunx next start -p 3003 &
|
||||
SITE_THEME=niche NEXT_DIST_DIR=.next-niche bunx next start -p 3004 &
|
||||
SITE_THEME=minimal NEXT_DIST_DIR=.next-minimal bunx next start -p 3005 &
|
||||
|
||||
echo "classic → http://localhost:3001"
|
||||
echo "product → http://localhost:3002"
|
||||
echo "editorial → http://localhost:3003"
|
||||
echo "niche → http://localhost:3004"
|
||||
echo "minimal → http://localhost:3005"
|
||||
wait
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* # 脚本 e2e-interaction
|
||||
* 说明:端到端交互验证 —— 登录 → 投票 → 计数变化 → 退出登录态下投票被拦截。
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = "http://localhost:3001";
|
||||
const results: string[] = [];
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
// 1) 登录
|
||||
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.fill('input[name="email"], input[type="email"]', "alice@demo.dev");
|
||||
await page.fill('input[name="password"], input[type="password"]', "password123");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/dashboard|\/$/, { timeout: 15000 }).catch(() => {});
|
||||
results.push(`登录后 URL: ${page.url()}`);
|
||||
|
||||
// 2) 打开工具详情页,记录票数
|
||||
await page.goto(`${BASE}/tools/claude`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1000);
|
||||
const voteButton = page.locator("button", { hasText: /Upvote/ }).first();
|
||||
const before = await voteButton.textContent();
|
||||
results.push(`投票前按钮: ${(before ?? "").trim()}`);
|
||||
|
||||
// 3) 投票
|
||||
await voteButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
const after = await voteButton.textContent();
|
||||
results.push(`投票后按钮: ${(after ?? "").trim()}`);
|
||||
results.push(`投票生效: ${(before ?? "") !== (after ?? "") ? "PASS" : "FAIL"}`);
|
||||
|
||||
// 4) 写评论
|
||||
try {
|
||||
const hasReviewForm = await page.locator('button:has-text("Write a review")').count();
|
||||
results.push(`找到 Write a review 按钮: ${hasReviewForm}`);
|
||||
if (hasReviewForm > 0) {
|
||||
await page.locator('button:has-text("Write a review")').first().click();
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: "/tmp/aidir-shots/e2e-review-form.png" });
|
||||
// 选 5 星(表单内的大号星级)
|
||||
await page.locator('form button[aria-label="5"]').first().click();
|
||||
await page.locator("form textarea").first().fill("Interactive e2e test review - Claude handles long documents exceptionally well.");
|
||||
await page.locator('form button:has-text("Publish review")').click();
|
||||
await page.waitForTimeout(2000);
|
||||
const reviewVisible = await page.locator("text=Interactive e2e test review").count();
|
||||
results.push(`评论发布: ${reviewVisible > 0 ? "PASS" : "FAIL"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: "/tmp/aidir-shots/e2e-debug.png" });
|
||||
results.push(`评论步骤异常: ${String(error).slice(0, 100)}`);
|
||||
}
|
||||
|
||||
// 5) 提交工具(重复检测)
|
||||
await page.goto(`${BASE}/submit`, { waitUntil: "domcontentloaded" });
|
||||
await page.fill('input[placeholder="ChatGPT"]', "ChatGPT");
|
||||
await page.fill('input[type="url"]', "https://chatgpt.com");
|
||||
await page.locator('input[type="url"]').blur();
|
||||
await page.waitForTimeout(1200);
|
||||
const dupVisible = await page.locator("text=already be listed").count();
|
||||
results.push(`重复检测: ${dupVisible > 0 ? "PASS" : "FAIL"}`);
|
||||
|
||||
await browser.close();
|
||||
console.log(results.join("\n"));
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* # 脚本 screenshot-pages
|
||||
* 说明:用 Playwright 对五个主题站点与关键页面截图 + 控制台错误采集。
|
||||
* 用于交付前的视觉验收。
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const OUT = "/tmp/aidir-shots";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const targets = [
|
||||
{ name: "classic-home", url: "http://localhost:3001/" },
|
||||
{ name: "classic-rankings", url: "http://localhost:3001/rankings" },
|
||||
{ name: "classic-tool-detail", url: "http://localhost:3001/tools/claude" },
|
||||
{ name: "classic-tools-list", url: "http://localhost:3001/tools" },
|
||||
{ name: "classic-best", url: "http://localhost:3001/best/best-ai-writing-tools" },
|
||||
{ name: "classic-usecase", url: "http://localhost:3001/ai-tools-for/blogging" },
|
||||
{ name: "classic-compare", url: "http://localhost:3001/tools/cursor-vs-windsurf" },
|
||||
{ name: "classic-alt", url: "http://localhost:3001/tools/midjourney-alternatives" },
|
||||
{ name: "classic-zh", url: "http://localhost:3001/zh" },
|
||||
{ name: "classic-submit", url: "http://localhost:3001/submit" },
|
||||
{ name: "product-home", url: "http://localhost:3002/" },
|
||||
{ name: "editorial-home", url: "http://localhost:3003/" },
|
||||
{ name: "niche-home", url: "http://localhost:3004/" },
|
||||
{ name: "minimal-home", url: "http://localhost:3005/" },
|
||||
{ name: "minimal-mobile", url: "http://localhost:3005/", mobile: true },
|
||||
{ name: "product-mobile", url: "http://localhost:3002/", mobile: true },
|
||||
];
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const context = await browser.newContext(
|
||||
target.mobile
|
||||
? { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }
|
||||
: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }
|
||||
);
|
||||
const page = await context.newPage();
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") errors.push(`${target.name}: ${msg.text().slice(0, 200)}`);
|
||||
});
|
||||
page.on("pageerror", (err) => errors.push(`${target.name}: PAGEERROR ${String(err).slice(0, 200)}`));
|
||||
|
||||
try {
|
||||
await page.goto(target.url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.screenshot({ path: `${OUT}/${target.name}.png`, fullPage: false });
|
||||
// 顺便记录 H1
|
||||
const h1 = await page.locator("h1").first().textContent().catch(() => "(no h1)");
|
||||
console.log(`OK ${target.name} | h1: ${h1?.trim().slice(0, 60)}`);
|
||||
} catch (error) {
|
||||
console.log(`FAIL ${target.name}: ${String(error).slice(0, 120)}`);
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("\n=== 控制台错误 ===");
|
||||
console.log(errors.length === 0 ? "(none)" : errors.slice(0, 12).join("\n"));
|
||||
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* # 脚本 seed-platform
|
||||
* 说明:AI Directory Platform 一体化种子数据。
|
||||
* - 14 分类(无 Emoji 图标)/ 16 用例 / 9 受众
|
||||
* - 40 个真实 AI 工具(含 tagline/定价/分类/用例/受众关联)
|
||||
* - 6 个演示用户(含 1 个管理员,密码 password123)
|
||||
* - 投票 / 评论(含优缺点)/ 收藏 / 合集 / 事件流(近 30 天)
|
||||
* - 同步冗余统计列(voteCount/ratingAvg/viewCount...)
|
||||
*
|
||||
* 用法:bun scripts/seed-platform.ts (可加 --force 清空重灌)
|
||||
*/
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import dotenv from "dotenv";
|
||||
import { resolve } from "path";
|
||||
import {
|
||||
tool,
|
||||
category,
|
||||
tag,
|
||||
toolCategory,
|
||||
toolTag,
|
||||
useCase,
|
||||
audience,
|
||||
toolUseCase,
|
||||
toolAudience,
|
||||
collection,
|
||||
collectionTool,
|
||||
vote,
|
||||
review,
|
||||
favorite,
|
||||
event,
|
||||
submission,
|
||||
claim,
|
||||
user,
|
||||
} from "../src/lib/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
|
||||
dotenv.config({ path: resolve(process.cwd(), ".env.local") });
|
||||
|
||||
const client = postgres(process.env.DATABASE_URL!, { prepare: false });
|
||||
const db = drizzle(client);
|
||||
|
||||
// ========== 确定性伪随机(可重复灌数) ==========
|
||||
let seedState = 42;
|
||||
function rand(): number {
|
||||
seedState = (seedState * 1103515245 + 12345) % 2147483648;
|
||||
return seedState / 2147483648;
|
||||
}
|
||||
function randInt(min: number, max: number): number {
|
||||
return Math.floor(rand() * (max - min + 1)) + min;
|
||||
}
|
||||
function pick<T>(arr: T[]): T {
|
||||
return arr[randInt(0, arr.length - 1)];
|
||||
}
|
||||
function daysAgo(days: number): Date {
|
||||
return new Date(Date.now() - days * 24 * 3600 * 1000);
|
||||
}
|
||||
|
||||
// ========== 分类(14,设计文档 §11) ==========
|
||||
const categories = [
|
||||
["chatbot", "AI Chatbots", "Conversational AI assistants", "AI 聊天机器人", "对话式 AI 助手"],
|
||||
["writing", "Writing", "AI-powered writing assistants and content tools", "写作与内容", "AI 驱动的写作助手和内容工具"],
|
||||
["image", "Image Generation", "Create and edit images with AI", "图像生成", "用 AI 创作和编辑图像"],
|
||||
["video", "Video", "AI video generation, editing and animation", "视频", "AI 视频生成、编辑与动画"],
|
||||
["audio", "Audio & Music", "Music generation, voice and audio AI", "音频与音乐", "AI 音乐生成、语音与音频"],
|
||||
["coding", "Coding", "AI coding assistants and developer tools", "编程开发", "AI 编程助手与开发者工具"],
|
||||
["marketing", "Marketing", "AI for marketing, SEO and growth", "营销", "面向营销、SEO 与增长的 AI"],
|
||||
["productivity", "Productivity", "Work faster with AI helpers", "效率办公", "用 AI 提升工作效率"],
|
||||
["research", "Research", "AI research and analysis assistants", "研究分析", "AI 研究与数据分析助手"],
|
||||
["business", "Business", "AI for business operations", "商业", "面向商业运营的 AI"],
|
||||
["education", "Education", "AI tutoring and learning tools", "教育", "AI 教学与学习工具"],
|
||||
["finance", "Finance", "AI for finance and investing", "金融", "面向金融与投资的 AI"],
|
||||
["design", "Design", "AI design and creative tools", "设计", "AI 设计与创意工具"],
|
||||
["automation", "Automation", "Workflow and task automation with AI", "自动化", "AI 工作流与任务自动化"],
|
||||
["agent", "AI Agent", "Autonomous agents and copilots", "智能体", "自主智能体与副驾驶"],
|
||||
] as const;
|
||||
|
||||
// ========== 用例(16,设计文档 §11 Use Case) ==========
|
||||
const useCasesData = [
|
||||
["blogging", "Blogging", "Write, outline and edit blog posts faster", "写博客", "更快地撰写、构思和编辑博客"],
|
||||
["presentation", "Making Presentations", "Generate slides and decks from prompts", "制作 PPT", "从提示词生成幻灯片"],
|
||||
["logo-design", "Logo Design", "Create logos and brand marks", "生成 Logo", "创作 Logo 与品牌标识"],
|
||||
["video-editing", "Video Editing", "Cut, caption and polish videos", "视频剪辑", "剪辑、加字幕和精修视频"],
|
||||
["meeting-notes", "Meeting Notes", "Transcribe and summarize meetings", "会议记录", "转写并总结会议内容"],
|
||||
["subtitles", "Subtitles & Captions", "Generate and translate subtitles", "生成字幕", "生成与翻译字幕"],
|
||||
["write-code", "Writing Code", "Autocomplete, refactor and explain code", "写代码", "代码补全、重构与解释"],
|
||||
["seo", "SEO", "Keyword research and content optimization", "搜索引擎优化", "关键词研究与内容优化"],
|
||||
["customer-support", "Customer Support", "Chatbots and helpdesk automation", "客户支持", "客服机器人与工单自动化"],
|
||||
["social-media", "Social Media", "Schedule, write and repurpose posts", "社交媒体", "撰写、排期与复用社媒内容"],
|
||||
["market-analysis", "Market Analysis", "Research markets and competitors", "市场分析", "研究市场与竞品"],
|
||||
["image-upscale", "Image Upscaling", "Upscale and restore images", "图像放大", "放大与修复图像"],
|
||||
["copywriting", "Copywriting", "Ads, landing pages and email copy", "文案写作", "广告、落地页与邮件文案"],
|
||||
["data-extraction", "Data Extraction", "Parse documents and extract structured data", "数据提取", "解析文档并提取结构化数据"],
|
||||
["language-learning", "Language Learning", "Practice and translate languages", "语言学习", "练习与翻译语言"],
|
||||
["music-production", "Music Production", "Compose beats and produce tracks", "音乐制作", "作曲与音乐制作"],
|
||||
] as const;
|
||||
|
||||
// ========== 受众(9,设计文档 §11 Audience) ==========
|
||||
const audiencesData = [
|
||||
["developers", "Developers", "Engineers shipping software", "开发者", "写代码的工程师"],
|
||||
["designers", "Designers", "Product and visual designers", "设计师", "产品与视觉设计师"],
|
||||
["marketers", "Marketers", "Growth, SEO and content marketers", "营销人员", "增长、SEO 与内容营销"],
|
||||
["students", "Students", "Learning and studying", "学生", "学习与备考人群"],
|
||||
["creators", "Creators", "YouTubers, podcasters and streamers", "创作者", "视频博主、播客与主播"],
|
||||
["founders", "Founders", "Startup founders and solopreneurs", "创业者", "创始人与独立开发者"],
|
||||
["researchers", "Researchers", "Academic and market researchers", "研究人员", "学术与市场研究"],
|
||||
["business", "Business Teams", "Ops, sales and finance teams", "商业团队", "运营、销售与财务团队"],
|
||||
["enterprise", "Enterprise", "Large organizations with compliance needs", "大型企业", "有合规需求的大型组织"],
|
||||
] as const;
|
||||
|
||||
// ========== 工具(40 个真实产品) ==========
|
||||
// [name, slug, domain, taglineEn, taglineZh, descEn, pricing, categories, useCases, audiences, featured]
|
||||
const toolsData: [string, string, string, string, string, string, string, string[], string[], string[], boolean][] = [
|
||||
["ChatGPT", "chatgpt", "chatgpt.com", "The AI chatbot that started it all", "开创先河的 AI 聊天机器人", "ChatGPT is OpenAI's conversational AI assistant capable of writing, coding, analysis and more. It popularized large language models for everyday users and now supports multimodal inputs, custom GPTs and web browsing.", "freemium", ["chatbot", "writing", "coding"], ["write-code", "blogging", "copywriting"], ["developers", "marketers", "students", "founders"], true],
|
||||
["Claude", "claude", "claude.ai", "Thoughtful AI assistant by Anthropic", "Anthropic 出品的可靠 AI 助手", "Claude is Anthropic's AI assistant, known for long-context understanding, careful reasoning and strong writing. Ideal for document analysis, coding and nuanced drafting.", "freemium", ["chatbot", "writing", "coding"], ["write-code", "blogging", "data-extraction"], ["developers", "researchers", "business"], true],
|
||||
["Midjourney", "midjourney", "midjourney.com", "Industry-leading AI image generation", "业界领先的 AI 图像生成", "Midjourney creates striking, artistic images from text prompts. It is widely used for concept art, marketing visuals and creative exploration, with a distinctive aesthetic quality.", "paid", ["image", "design"], ["logo-design", "image-upscale"], ["designers", "creators", "marketers"], true],
|
||||
["DALL·E 3", "dall-e-3", "openai.com", "Text-to-image from OpenAI, built into ChatGPT", "OpenAI 的文生图模型,已集成到 ChatGPT", "DALL·E 3 generates detailed images from natural language with excellent prompt adherence, integrated directly into ChatGPT for conversational image creation and iteration.", "freemium", ["image"], ["logo-design", "image-upscale"], ["designers", "marketers"], false],
|
||||
["Stable Diffusion", "stable-diffusion", "stability.ai", "Open-source image generation you can self-host", "可自部署的开源图像生成", "Stable Diffusion is an open-source latent diffusion model for image generation, fine-tuning and local deployment, powering countless creative tools and pipelines.", "open_source", ["image"], ["logo-design", "image-upscale"], ["designers", "developers"], false],
|
||||
["Runway", "runway", "runwayml.com", "Generative video for creators", "面向创作者的生成式视频", "Runway provides AI video generation and editing — text-to-video, motion brush, inpainting and green screen — used across film, advertising and social content.", "freemium", ["video"], ["video-editing"], ["creators", "designers", "marketers"], true],
|
||||
["Pika", "pika", "pika.art", "Playful text-to-video generation", "有趣的文生视频工具", "Pika turns text prompts and images into short animated videos with a friendly creative workflow, popular for social clips and concept visualization.", "freemium", ["video"], ["video-editing", "social-media"], ["creators", "marketers"], false],
|
||||
["ElevenLabs", "elevenlabs", "elevenlabs.io", "The most realistic AI voices", "最逼真的 AI 语音", "ElevenLabs generates hyper-realistic speech in dozens of languages, with voice cloning, dubbing and long-form narration for videos, audiobooks and products.", "freemium", ["audio"], ["subtitles", "video-editing"], ["creators", "developers"], true],
|
||||
["Suno", "suno", "suno.com", "Create songs from text prompts", "从文字生成完整歌曲", "Suno composes full songs — vocals, lyrics and instrumentation — from simple prompts, enabling anyone to produce original music in minutes.", "freemium", ["audio"], ["music-production"], ["creators"], false],
|
||||
["GitHub Copilot", "github-copilot", "github.com", "Your AI pair programmer", "你的 AI 结对程序员", "GitHub Copilot suggests whole lines and functions inside your editor, trained on public code. It accelerates everyday programming across all major languages.", "paid", ["coding"], ["write-code"], ["developers"], true],
|
||||
["Cursor", "cursor", "cursor.com", "The AI-first code editor", "AI 优先的代码编辑器", "Cursor is a fork of VS Code rebuilt around AI: multi-file edits, codebase-aware chat and agent mode that writes and refactors code with full project context.", "freemium", ["coding"], ["write-code"], ["developers"], true],
|
||||
["Windsurf", "windsurf", "codeium.com", "Agentic IDE that keeps you in flow", "保持心流的智能体 IDE", "Windsurf (by Codeium) is an agentic IDE with deep codebase awareness — it plans, writes and runs terminal commands to complete tasks end to end.", "freemium", ["coding"], ["write-code"], ["developers"], false],
|
||||
["Tabnine", "tabnine", "tabnine.com", "Private, secure AI code completion", "私有化部署的 AI 代码补全", "Tabnine offers AI code completion with privacy-first deployment options — self-hosted or air-gapped — for teams with strict compliance requirements.", "freemium", ["coding", "business"], ["write-code"], ["developers", "enterprise"], false],
|
||||
["Jasper", "jasper", "jasper.ai", "AI copywriting for marketing teams", "面向营销团队的 AI 文案", "Jasper helps marketing teams produce on-brand blog posts, ads and emails at scale, with templates, tone control and campaign workflows.", "paid", ["writing", "marketing"], ["copywriting", "blogging", "seo"], ["marketers", "business"], false],
|
||||
["Copy.ai", "copy-ai", "copy.ai", "Sales and marketing copy, automated", "销售与营销文案自动化", "Copy.ai generates marketing and sales copy — from cold emails to product descriptions — with workflow automation for go-to-market teams.", "freemium", ["writing", "marketing"], ["copywriting", "customer-support"], ["marketers", "founders"], false],
|
||||
["Surfer SEO", "surfer-seo", "surferseo.com", "Data-driven on-page SEO optimization", "数据驱动的页面 SEO 优化", "Surfer analyzes SERPs and guides content optimization with keyword density, structure and entity recommendations, helping articles rank faster.", "paid", ["marketing"], ["seo", "blogging"], ["marketers", "business"], false],
|
||||
["Semrush", "semrush", "semrush.com", "The all-in-one SEO and PPC platform", "一站式 SEO 与 PPC 平台", "Semrush provides keyword research, site audits, competitor analysis and rank tracking — the standard toolkit for SEO professionals.", "paid", ["marketing"], ["seo", "market-analysis"], ["marketers", "business"], false],
|
||||
["Notion AI", "notion-ai", "notion.com", "AI built into your workspace", "工作区内置 AI", "Notion AI brings writing, summarization and Q&A into Notion pages and databases, so notes, docs and project data become instantly actionable.", "freemium", ["productivity"], ["meeting-notes", "blogging"], ["business", "founders", "students"], false],
|
||||
["Mem", "mem", "get.mem.ai", "Self-organizing AI notes", "自动整理的 AI 笔记", "Mem is an AI notes app that organizes itself — capture anything and Mem connects, tags and resurfaces it when relevant.", "freemium", ["productivity"], ["meeting-notes"], ["founders", "researchers"], false],
|
||||
["Otter.ai", "otter-ai", "otter.ai", "AI meeting assistant and transcription", "AI 会议助手与转写", "Otter records, transcribes and summarizes meetings in real time, generating action items and searchable transcripts for teams.", "freemium", ["productivity", "audio"], ["meeting-notes", "subtitles"], ["business", "creators"], false],
|
||||
["Perplexity", "perplexity", "perplexity.ai", "The answer engine", "答案引擎", "Perplexity is an AI-powered answer engine that searches the web in real time and responds with cited, sourced summaries — a research shortcut.", "freemium", ["research", "chatbot"], ["market-analysis", "blogging"], ["researchers", "students", "business"], true],
|
||||
["Elicit", "elicit", "elicit.com", "AI research assistant for papers", "面向论文的 AI 研究助手", "Elicit helps researchers find and synthesize academic papers — extracting methods, findings and limitations into structured tables.", "freemium", ["research"], ["market-analysis", "data-extraction"], ["researchers", "students"], false],
|
||||
["Scite", "scite", "scite.ai", "Smart citations for research", "智能引用分析", "Scite shows how papers cite each other — supporting or contrasting — helping researchers evaluate evidence quality quickly.", "paid", ["research", "education"], ["market-analysis"], ["researchers"], false],
|
||||
["Synthesia", "synthesia", "synthesia.io", "AI video avatars for training", "面向培训的 AI 数字人视频", "Synthesia creates studio-quality videos with AI avatars and voiceover from plain text — used for corporate training and product explainers.", "paid", ["video", "business"], ["video-editing"], ["business", "enterprise"], false],
|
||||
["HeyGen", "heygen", "heygen.com", "Spokesperson videos in minutes", "几分钟生成数字人口播视频", "HeyGen generates avatar-led videos with lip-synced translation in 40+ languages, popular for sales outreach and localization.", "freemium", ["video"], ["video-editing", "social-media"], ["marketers", "business"], false],
|
||||
["Canva Magic Studio", "canva", "canva.com", "Design suite with AI everywhere", "全面融入 AI 的设计套件", "Canva's Magic Studio adds AI image generation, magic editing and brand controls to the world's most popular online design suite.", "freemium", ["design", "image"], ["logo-design", "presentation", "social-media"], ["marketers", "students", "business"], false],
|
||||
["Looka", "looka", "looka.com", "AI logo maker for new brands", "面向新品牌的 AI Logo 生成", "Looka generates professional logo sets and brand kits from your preferences — an easy starting point for new businesses.", "freemium", ["design"], ["logo-design"], ["founders"], false],
|
||||
["Figma AI", "figma-ai", "figma.com", "AI in the collaborative design tool", "协作设计工具中的 AI", "Figma integrates AI to generate designs, rename layers, write copy and search files — embedded in the leading collaborative design platform.", "freemium", ["design"], ["presentation"], ["designers"], false],
|
||||
["Zapier AI", "zapier-ai", "zapier.com", "Automate work across 6,000+ apps", "连接 6000+ 应用的自动化", "Zapier connects thousands of apps and adds AI steps — parsing, drafting, routing — so teams automate workflows without code.", "freemium", ["automation"], ["customer-support", "data-extraction"], ["business", "marketers"], false],
|
||||
["Make", "make", "make.com", "Visual automation with AI modules", "可视化自动化平台", "Make (formerly Integromat) offers visual workflow automation with AI modules for content generation, classification and routing.", "freemium", ["automation"], ["data-extraction"], ["business", "developers"], false],
|
||||
["LangChain", "langchain", "langchain.com", "Framework for LLM applications", "大模型应用开发框架", "LangChain is the popular framework for building LLM apps — chains, agents, retrieval and observability for production AI.", "open_source", ["coding", "agent"], ["write-code"], ["developers"], false],
|
||||
["AutoGPT", "autogpt", "agpt.co", "The pioneering autonomous agent", "开创性的自主智能体", "AutoGPT popularized autonomous AI agents that plan, browse and execute multi-step tasks toward a goal.", "open_source", ["agent"], ["write-code", "market-analysis"], ["developers", "founders"], false],
|
||||
["Hugging Face", "hugging-face", "huggingface.co", "The AI models community hub", "AI 模型社区中枢", "Hugging Face hosts hundreds of thousands of models, datasets and demos — the de facto open platform for machine learning.", "freemium", ["agent", "coding", "research"], ["write-code"], ["developers", "researchers"], false],
|
||||
["Grammarly", "grammarly", "grammarly.com", "Writing assistant that follows you everywhere", "随身的写作助手", "Grammarly checks grammar, clarity and tone across apps, with generative rewriting to improve any text you write.", "freemium", ["writing", "productivity"], ["copywriting", "blogging"], ["students", "business", "marketers"], false],
|
||||
["QuillBot", "quillbot", "quillbot.com", "Paraphrasing and summarizing tool", "改写与摘要工具", "QuillBot paraphrases, summarizes and checks grammar, widely used by students and writers to rewrite text quickly.", "freemium", ["writing", "education"], ["blogging", "copywriting"], ["students", "creators"], false],
|
||||
["Duolingo Max", "duolingo-max", "duolingo.com", "Language learning with GPT-4", "GPT-4 加持的语言学习", "Duolingo Max adds AI conversation practice and detailed explanations to the world's most popular language learning app.", "freemium", ["education"], ["language-learning"], ["students"], false],
|
||||
["Khanmigo", "khanmigo", "khanmigo.ai", "Khan Academy's AI tutor", "可汗学院的 AI 导师", "Khanmigo is Khan Academy's AI tutor that guides students through problems step by step instead of giving answers away.", "paid", ["education"], ["language-learning"], ["students"], false],
|
||||
["AlphaSense", "alpha-sense", "alpha-sense.com", "Market intelligence search", "市场情报搜索", "AlphaSense searches earnings calls, filings and expert transcripts with NLP for investment and competitive research.", "contact", ["finance", "research"], ["market-analysis", "data-extraction"], ["business", "enterprise"], false],
|
||||
["Betterment", "betterment", "betterment.com", "Investing with AI-guided portfolios", "AI 引导的投资组合", "Betterment uses automated portfolio management and tax-loss harvesting to make investing hands-off.", "freemium", ["finance"], ["market-analysis"], ["business"], false],
|
||||
["Beautiful.ai", "beautiful-ai", "beautiful.ai", "Presentations that design themselves", "自动设计的演示文稿", "Beautiful.ai applies smart design rules as you add content, keeping slides polished without manual layout work.", "paid", ["productivity", "design"], ["presentation"], ["business", "marketers"], false],
|
||||
] as const;
|
||||
|
||||
// ========== 标签 ==========
|
||||
const tags = [
|
||||
["free", "Free", "免费"],
|
||||
["freemium", "Freemium", "免费增值"],
|
||||
["paid", "Paid", "付费"],
|
||||
["open-source", "Open Source", "开源"],
|
||||
["api", "API Available", "提供 API"],
|
||||
["llm", "LLM", "大语言模型"],
|
||||
["beginner", "Beginner Friendly", "新手友好"],
|
||||
["enterprise", "Enterprise", "企业级"],
|
||||
["real-time", "Real-time", "实时"],
|
||||
["collaboration", "Collaboration", "协作"],
|
||||
] as const;
|
||||
|
||||
// ========== 合集(Best Lists) ==========
|
||||
const collectionsData = [
|
||||
["best-ai-writing-tools", "Best AI Writing Tools in 2026", "Our community-ranked shortlist of AI writing tools for blogs, ads and long-form content — based on votes, reviews and saves.", "2026 年最佳 AI 写作工具:基于投票、评论与收藏的社区榜单,覆盖博客、广告与长文写作。", ["chatgpt", "claude", "jasper", "copy-ai", "grammarly", "quillbot"]],
|
||||
["best-free-ai-tools", "Best Free AI Tools", "Genuinely useful AI tools with free plans — no trials that vanish in 7 days. Ranked by the community.", "真正好用的免费 AI 工具合集——不是 7 天就失效的试用。由社区投票排名。", ["chatgpt", "claude", "stable-diffusion", "grammarly", "canva", "perplexity"]],
|
||||
["best-ai-coding-tools", "Best AI Coding Tools", "From autocomplete to agentic IDEs: the coding AI that developers actually upvote.", "从代码补全到智能体 IDE:开发者真正会投票的编程 AI。", ["cursor", "github-copilot", "claude", "windsurf", "tabnine"]],
|
||||
["best-ai-video-generators", "Best AI Video Generators", "Text-to-video and editing AI compared on quality, speed and price — with community votes breaking ties.", "文生视频与视频剪辑 AI 对比:画质、速度与价格,由社区投票分高下。", ["runway", "pika", "synthesia", "heygen"]],
|
||||
["best-ai-tools-for-marketing", "Best AI Tools for Marketing Teams", "The marketing stack that ranked highest with our community: SEO, copy and social.", "社区评分最高的营销工具栈:SEO、文案与社交媒体。", ["surfer-seo", "semrush", "jasper", "copy-ai", "heygen"]],
|
||||
["best-chatgpt-alternatives", "Best ChatGPT Alternatives", "Looking beyond ChatGPT? These assistants ranked highest with users for writing, coding and research.", "ChatGPT 之外的选择:这些助手在写作、编程和研究上获得最高用户评价。", ["claude", "perplexity", "chatgpt", "notion-ai"]],
|
||||
] as const;
|
||||
|
||||
// ========== 主流程 ==========
|
||||
async function main() {
|
||||
const force = process.argv.includes("--force");
|
||||
const existing = await db.select({ cnt: sql<number>`count(*)` }).from(tool);
|
||||
if (Number(existing[0].cnt) > 0 && !force) {
|
||||
console.log(`数据库已有 ${Number(existing[0].cnt)} 个工具,跳过种子。使用 --force 清空重灌。`);
|
||||
await client.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("清空平台数据...");
|
||||
await db.delete(event);
|
||||
await db.delete(vote);
|
||||
await db.delete(review);
|
||||
await db.delete(favorite);
|
||||
await db.delete(collectionTool);
|
||||
await db.delete(collection);
|
||||
await db.delete(toolUseCase);
|
||||
await db.delete(toolAudience);
|
||||
await db.delete(toolCategory);
|
||||
await db.delete(toolTag);
|
||||
await db.delete(tool);
|
||||
await db.delete(useCase);
|
||||
await db.delete(audience);
|
||||
await db.delete(category);
|
||||
await db.delete(tag);
|
||||
await db.delete(submission);
|
||||
await db.delete(claim);
|
||||
await db.delete(user);
|
||||
|
||||
console.log("写入用户...");
|
||||
const passwordHash = await hashPassword("password123");
|
||||
const usersData = [
|
||||
["user_admin", "Platform Admin", "admin@aidirectory.dev", "admin"],
|
||||
["user_alice", "Alice Chen", "alice@demo.dev", "user"],
|
||||
["user_bob", "Bob Martinez", "bob@demo.dev", "user"],
|
||||
["user_carol", "Carol Wang", "carol@demo.dev", "user"],
|
||||
["user_dave", "Dave Kim", "dave@demo.dev", "user"],
|
||||
["user_erin", "Erin Patel", "erin@demo.dev", "user"],
|
||||
] as const;
|
||||
for (const [id, name, email, role] of usersData) {
|
||||
await db.insert(user).values({
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
emailVerified: true,
|
||||
role,
|
||||
createdAt: daysAgo(randInt(30, 120)),
|
||||
updatedAt: daysAgo(randInt(0, 5)),
|
||||
// better-auth 凭据字段(credential 账户,演示密码 password123)
|
||||
...({}),
|
||||
});
|
||||
// 写入 account 表(credential provider)—— 简化:直接操作 SQL
|
||||
await client`
|
||||
INSERT INTO account (id, account_id, provider_id, user_id, password, created_at, updated_at)
|
||||
VALUES (${nanoid()}, ${id}, ${"credential"}, ${id}, ${passwordHash}, NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
|
||||
console.log("写入分类 / 标签 / 用例 / 受众...");
|
||||
for (const [i, [slug, en, descEn, zh, descZh]] of categories.entries()) {
|
||||
await db.insert(category).values({
|
||||
id: `cat_${slug}`,
|
||||
slug,
|
||||
icon: null, // 设计文档 §4.1:禁止 Emoji 图标
|
||||
sortOrder: i + 1,
|
||||
nameEn: en,
|
||||
descriptionEn: descEn,
|
||||
nameZh: zh,
|
||||
descriptionZh: descZh,
|
||||
});
|
||||
}
|
||||
for (const [slug, en, zh] of tags) {
|
||||
await db.insert(tag).values({ id: `tag_${slug}`, slug, nameEn: en, nameZh: zh });
|
||||
}
|
||||
for (const [i, [slug, en, descEn, zh, descZh]] of useCasesData.entries()) {
|
||||
await db.insert(useCase).values({
|
||||
id: `uc_${slug}`,
|
||||
slug,
|
||||
sortOrder: i + 1,
|
||||
nameEn: en,
|
||||
descriptionEn: descEn,
|
||||
nameZh: zh,
|
||||
descriptionZh: descZh,
|
||||
});
|
||||
}
|
||||
for (const [i, [slug, en, descEn, zh, descZh]] of audiencesData.entries()) {
|
||||
await db.insert(audience).values({
|
||||
id: `aud_${slug}`,
|
||||
slug,
|
||||
sortOrder: i + 1,
|
||||
nameEn: en,
|
||||
descriptionEn: descEn,
|
||||
nameZh: zh,
|
||||
descriptionZh: descZh,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("写入工具与关联...");
|
||||
const toolIds: string[] = [];
|
||||
for (const [i, row] of toolsData.entries()) {
|
||||
const [name, slug, domain, taglineEn, taglineZh, descEn, pricing, cats, ucs, auds, featured] = row;
|
||||
const id = `tool_${slug}`;
|
||||
toolIds.push(id);
|
||||
|
||||
await db.insert(tool).values({
|
||||
id,
|
||||
slug,
|
||||
domain,
|
||||
websiteUrl: `https://${domain}`,
|
||||
// favicon 作为演示 logo(next.config 已放开 https 图片域名)
|
||||
logoUrl: `https://www.google.com/s2/favicons?domain=${domain}&sz=128`,
|
||||
nameEn: name,
|
||||
nameZh: name,
|
||||
taglineEn,
|
||||
taglineZh,
|
||||
descriptionEn: descEn,
|
||||
descriptionZh: descEn, // 演示数据中英共用描述
|
||||
pricing,
|
||||
featured,
|
||||
status: "published",
|
||||
// 时间分布:越靠前的工具越早收录(新工具在后面),制造真实的榜单时间差
|
||||
createdAt: daysAgo(300 - i * 6 + randInt(0, 5)),
|
||||
updatedAt: daysAgo(randInt(0, 10)),
|
||||
});
|
||||
|
||||
for (const cat of cats) {
|
||||
await db.insert(toolCategory).values({ toolId: id, categoryId: `cat_${cat}` }).onConflictDoNothing();
|
||||
}
|
||||
const tagPick = [pricing === "free" ? "free" : pricing === "open_source" ? "open-source" : pricing === "freemium" ? "freemium" : "paid", pick(["api", "llm", "beginner"]), pick(["real-time", "collaboration", "enterprise"])];
|
||||
for (const t of tagPick) {
|
||||
await db.insert(toolTag).values({ toolId: id, tagId: `tag_${t}` }).onConflictDoNothing();
|
||||
}
|
||||
for (const uc of ucs) {
|
||||
await db.insert(toolUseCase).values({ toolId: id, useCaseId: `uc_${uc}` }).onConflictDoNothing();
|
||||
}
|
||||
for (const aud of auds) {
|
||||
await db.insert(toolAudience).values({ toolId: id, audienceId: `aud_${aud}` }).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
console.log("写入投票 / 收藏 / 评论...");
|
||||
const reviewTexts = [
|
||||
["Saved me hours every week", "I use this daily and it consistently saves me hours of repetitive work. The output quality keeps improving.", "Fast, reliable output", "Occasionally needs a second pass on nuance"],
|
||||
["Great for our team workflow", "Rolled it out to the whole team. Onboarding took minutes and everyone found it immediately useful.", "Easy onboarding, great docs", "Pricey at team tier"],
|
||||
["Solid but not perfect", "Does the core job well. Some advanced features feel half-finished, but the fundamentals are strong.", "Strong core features", "Advanced features feel unfinished"],
|
||||
["Worth every penny", "Paid plan pays for itself within the first week. Support responded within hours when I hit an issue.", "Excellent ROI and support", "No offline mode"],
|
||||
["Good, with caveats", "Impressive capability for the price, though I hit some rate limits during peak hours.", "Great value for money", "Peak-hour rate limits"],
|
||||
] as const;
|
||||
|
||||
const voteCountByTool = new Map<string, number>();
|
||||
const favCountByTool = new Map<string, number>();
|
||||
for (const [i, toolId] of toolIds.entries()) {
|
||||
const popularity = Math.max(1, 40 - i); // 列表靠前 = 更热门
|
||||
const voters = usersData.filter(([, , , role]) => role === "user" || role === "admin").map(([id]) => id);
|
||||
|
||||
// 投票:热门工具更多近 7 天投票(驱动 Trending)
|
||||
const totalVotes = Math.min(voters.length, Math.round((popularity / 4) * (0.5 + rand())));
|
||||
const recentVotes = Math.min(totalVotes, Math.ceil(totalVotes * (i < 12 ? 0.8 : 0.3)));
|
||||
const votedUsers = new Set<string>();
|
||||
for (let v = 0; v < totalVotes; v++) {
|
||||
const userId = pick(voters);
|
||||
if (votedUsers.has(userId)) continue;
|
||||
votedUsers.add(userId);
|
||||
const isRecent = v < recentVotes;
|
||||
const createdAt = isRecent ? daysAgo(randInt(0, 6)) : daysAgo(randInt(8, 120));
|
||||
await db.insert(vote).values({ id: nanoid(), userId, toolId, createdAt }).onConflictDoNothing();
|
||||
}
|
||||
voteCountByTool.set(toolId, votedUsers.size);
|
||||
|
||||
// 收藏
|
||||
const favUsers = voters.filter(() => rand() < popularity / 60);
|
||||
for (const userId of favUsers) {
|
||||
await db.insert(favorite).values({ userId, toolId, createdAt: daysAgo(randInt(0, 30)) }).onConflictDoNothing();
|
||||
}
|
||||
favCountByTool.set(toolId, favUsers.length);
|
||||
|
||||
// 评论(热门工具更容易被评论)
|
||||
if (rand() < popularity / 14) {
|
||||
const numReviews = randInt(1, 3);
|
||||
const reviewingUsers = new Set<string>();
|
||||
for (let r = 0; r < numReviews; r++) {
|
||||
const userId = pick(voters);
|
||||
if (reviewingUsers.has(userId)) continue;
|
||||
reviewingUsers.add(userId);
|
||||
const [title, content, pros, cons] = pick([...reviewTexts]);
|
||||
const rating = i < 10 ? randInt(4, 5) : randInt(3, 5);
|
||||
await db.insert(review).values({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
toolId,
|
||||
rating,
|
||||
title,
|
||||
content,
|
||||
pros,
|
||||
cons,
|
||||
status: "published",
|
||||
helpfulCount: randInt(0, 12),
|
||||
createdAt: daysAgo(randInt(1, 40)),
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("同步冗余统计列...");
|
||||
// 投票 / 收藏数
|
||||
for (const [toolId, cnt] of voteCountByTool) {
|
||||
await db.update(tool).set({ voteCount: cnt, favoriteCount: favCountByTool.get(toolId) ?? 0 }).where(sql`${tool.id} = ${toolId}`);
|
||||
}
|
||||
// 评分(真实聚合)
|
||||
await client`
|
||||
UPDATE tool t SET
|
||||
review_count = COALESCE(agg.cnt, 0),
|
||||
rating_avg = COALESCE(ROUND(agg.avg::numeric, 2), 0)
|
||||
FROM (
|
||||
SELECT tool_id, COUNT(*) AS cnt, AVG(rating) AS avg
|
||||
FROM review WHERE status = 'published'
|
||||
GROUP BY tool_id
|
||||
) agg
|
||||
WHERE t.id = agg.tool_id`;
|
||||
|
||||
console.log("写入合集...");
|
||||
for (const [i, [slug, nameEn, introEn, introZh, toolSlugs]] of collectionsData.entries()) {
|
||||
const id = `col_${slug}`;
|
||||
await db.insert(collection).values({
|
||||
id,
|
||||
slug,
|
||||
sortOrder: i + 1,
|
||||
nameEn,
|
||||
nameZh: nameEn,
|
||||
introEn,
|
||||
introZh,
|
||||
status: "published",
|
||||
});
|
||||
for (const [order, toolSlug] of toolSlugs.entries()) {
|
||||
await db.insert(collectionTool).values({
|
||||
collectionId: id,
|
||||
toolId: `tool_${toolSlug}`,
|
||||
sortOrder: order,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
console.log("写入事件流(近 30 天)...");
|
||||
const events: { id: string; event: string; entityType: string | null; entityId: string | null; userId: string | null; sessionId: string | null; metadata: Record<string, unknown> | null; createdAt: Date }[] = [];
|
||||
for (const [i, toolId] of toolIds.entries()) {
|
||||
const popularity = Math.max(1, 40 - i);
|
||||
const views = Math.round(popularity * (4 + rand() * 8));
|
||||
for (let v = 0; v < views; v++) {
|
||||
events.push({
|
||||
id: nanoid(),
|
||||
event: "tool_view",
|
||||
entityType: "tool",
|
||||
entityId: toolId,
|
||||
userId: null,
|
||||
sessionId: nanoid(10),
|
||||
metadata: null,
|
||||
createdAt: daysAgo(randInt(0, 29)),
|
||||
});
|
||||
}
|
||||
const clicks = Math.round(views * (0.15 + rand() * 0.2));
|
||||
for (let v = 0; v < clicks; v++) {
|
||||
events.push({
|
||||
id: nanoid(),
|
||||
event: "tool_click",
|
||||
entityType: "tool",
|
||||
entityId: toolId,
|
||||
userId: null,
|
||||
sessionId: nanoid(10),
|
||||
metadata: null,
|
||||
createdAt: daysAgo(randInt(0, 29)),
|
||||
});
|
||||
}
|
||||
}
|
||||
const searchTerms = ["ai writing", "video generator", "code assistant", "logo maker", "free ai tools", "meeting notes", "seo", "chatbot", "voice cloning", "image upscaler"];
|
||||
for (let v = 0; v < 120; v++) {
|
||||
events.push({
|
||||
id: nanoid(),
|
||||
event: "search",
|
||||
entityType: null,
|
||||
entityId: null,
|
||||
userId: null,
|
||||
sessionId: nanoid(10),
|
||||
metadata: { query: pick(searchTerms) },
|
||||
createdAt: daysAgo(randInt(0, 29)),
|
||||
});
|
||||
}
|
||||
// 分批插入
|
||||
for (let i = 0; i < events.length; i += 500) {
|
||||
await db.insert(event).values(events.slice(i, i + 500));
|
||||
}
|
||||
// 同步 viewCount / clickCount
|
||||
await client`
|
||||
UPDATE tool t SET
|
||||
view_count = COALESCE(v.cnt, 0),
|
||||
click_count = COALESCE(c.cnt, 0)
|
||||
FROM (SELECT entity_id, COUNT(*) cnt FROM event WHERE event = 'tool_view' GROUP BY entity_id) v
|
||||
FULL OUTER JOIN (SELECT entity_id, COUNT(*) cnt FROM event WHERE event = 'tool_click' GROUP BY entity_id) c
|
||||
ON v.entity_id = c.entity_id
|
||||
WHERE t.id = COALESCE(v.entity_id, c.entity_id)`;
|
||||
|
||||
console.log("写入演示提交与认领...");
|
||||
await db.insert(submission).values({
|
||||
id: nanoid(),
|
||||
userId: "user_alice",
|
||||
name: "Demo New Tool",
|
||||
websiteUrl: "https://demonewtool.example.com",
|
||||
domain: "demonewtool.example.com",
|
||||
tagline: "An example submission waiting for review",
|
||||
description: "This is a sample submission in the admin queue so you can try the approve/reject flow immediately. Approving it will create a published tool listing.",
|
||||
pricing: "freemium",
|
||||
founderName: "Alice Chen",
|
||||
contactEmail: "alice@demo.dev",
|
||||
status: "pending",
|
||||
});
|
||||
await db.insert(claim).values({
|
||||
id: nanoid(),
|
||||
toolId: "tool_notion-ai",
|
||||
userId: "user_bob",
|
||||
role: "Product Manager",
|
||||
message: "I work at the company and can verify via corporate email. Happy to provide additional proof if needed.",
|
||||
proofUrl: "https://www.linkedin.com/in/example",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
const [{ cnt: toolCnt }] = await db.select({ cnt: sql<number>`count(*)` }).from(tool);
|
||||
const [{ cnt: voteCnt }] = await db.select({ cnt: sql<number>`count(*)` }).from(vote);
|
||||
const [{ cnt: reviewCnt }] = await db.select({ cnt: sql<number>`count(*)` }).from(review);
|
||||
const [{ cnt: eventCnt }] = await db.select({ cnt: sql<number>`count(*)` }).from(event);
|
||||
console.log(`\n种子完成:${Number(toolCnt)} 工具 / ${Number(voteCnt)} 投票 / ${Number(reviewCnt)} 评论 / ${Number(eventCnt)} 事件`);
|
||||
console.log("管理员:admin@aidirectory.dev / password123");
|
||||
await client.end();
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
console.error("种子失败:", error);
|
||||
await client.end();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { chromium } from "playwright";
|
||||
import postgres from "postgres";
|
||||
|
||||
const sql = postgres(process.env.DATABASE_URL || "postgresql://postgres:postgres@127.0.0.1:15433/ai_directory", { prepare: false });
|
||||
const count = async () => Number((await sql`SELECT COUNT(*) FROM vote WHERE tool_id='tool_claude'`)[0].count);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// 登录
|
||||
await page.goto("http://localhost:3001/login", { waitUntil: "domcontentloaded" });
|
||||
await page.fill('input[type="email"]', "bob@demo.dev");
|
||||
await page.fill('input[type="password"]', "password123");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// 记录 → 点击 → 对比
|
||||
const before = await count();
|
||||
await page.goto("http://localhost:3001/tools/claude", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(1500);
|
||||
const btn = page.locator("button", { hasText: /Upvote|Upvoted/ }).first();
|
||||
await btn.click();
|
||||
await page.waitForTimeout(2500);
|
||||
const after = await count();
|
||||
const label = await btn.textContent();
|
||||
console.log(`DB votes before=${before} after=${after} | button="${(label ?? "").trim()}"`);
|
||||
console.log(before !== after ? "TOGGLE PASS" : "TOGGLE FAIL");
|
||||
|
||||
// 再点一次恢复
|
||||
await btn.click();
|
||||
await page.waitForTimeout(2500);
|
||||
const restored = await count();
|
||||
console.log(`再次点击后 DB=${restored} (${restored === before ? "恢复原状 PASS" : "FAIL"})`);
|
||||
|
||||
await browser.close();
|
||||
await sql.end();
|
||||
@@ -0,0 +1,148 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { event, tool } from "@/lib/db/schema";
|
||||
import { and, desc, eq, gte, sql } from "drizzle-orm";
|
||||
|
||||
/** 事件显示名 */
|
||||
const eventNames = [
|
||||
"tool_view",
|
||||
"tool_click",
|
||||
"tool_vote",
|
||||
"tool_favorite",
|
||||
"tool_review",
|
||||
"tool_share",
|
||||
"search",
|
||||
"category_view",
|
||||
"submit_tool",
|
||||
"claim_tool",
|
||||
] as const;
|
||||
|
||||
export default async function AdminAnalyticsPage() {
|
||||
const since = new Date(Date.now() - 30 * 24 * 3600 * 1000);
|
||||
|
||||
const [totals, topTools, topSearches] = await Promise.all([
|
||||
// 近 30 天事件总量(按类型)
|
||||
db
|
||||
.select({ event: event.event, cnt: sql<number>`count(*)` })
|
||||
.from(event)
|
||||
.where(gte(event.createdAt, since))
|
||||
.groupBy(event.event),
|
||||
// 工具 CTR 排行(view ≥ 10 才计入,避免小样本)
|
||||
db
|
||||
.select({
|
||||
slug: tool.slug,
|
||||
name: tool.nameEn,
|
||||
views: tool.viewCount,
|
||||
clicks: tool.clickCount,
|
||||
votes: tool.voteCount,
|
||||
favorites: tool.favoriteCount,
|
||||
ctr: sql<number>`ROUND((${tool.clickCount}::numeric / GREATEST(${tool.viewCount}, 1)) * 100, 1)`,
|
||||
})
|
||||
.from(tool)
|
||||
.where(and(eq(tool.status, "published"), gte(tool.viewCount, 10)))
|
||||
.orderBy(desc(sql`(${tool.clickCount}::numeric / GREATEST(${tool.viewCount}, 1))`))
|
||||
.limit(10),
|
||||
// 热门搜索词(近 30 天)
|
||||
db
|
||||
.select({
|
||||
query: sql<string>`metadata->>'query'`,
|
||||
cnt: sql<number>`count(*)`,
|
||||
})
|
||||
.from(event)
|
||||
.where(and(eq(event.event, "search"), gte(event.createdAt, since)))
|
||||
.groupBy(sql`metadata->>'query'`)
|
||||
.orderBy(desc(sql`count(*)`))
|
||||
.limit(10),
|
||||
]);
|
||||
|
||||
const totalMap = new Map(totals.map((row) => [row.event, Number(row.cnt)]));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-foreground">Analytics</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Event system overview — last 30 days (design doc §37/§38).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 事件总量卡片 */}
|
||||
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{eventNames.map((name) => (
|
||||
<div key={name} className="rounded-xl border border-border p-4">
|
||||
<p className="truncate text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{name.replace("_", " ")}
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-bold tabular-nums text-foreground">
|
||||
{(totalMap.get(name) ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
{/* Tool CTR */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-semibold text-foreground">
|
||||
Tool CTR leaders (views ≥ 10)
|
||||
</h2>
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-secondary/40 text-left text-xs uppercase text-muted-foreground">
|
||||
<th className="px-3 py-2">Tool</th>
|
||||
<th className="px-3 py-2">Views</th>
|
||||
<th className="px-3 py-2">Clicks</th>
|
||||
<th className="px-3 py-2">CTR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topTools.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center text-muted-foreground">
|
||||
Not enough data yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{topTools.map((row) => (
|
||||
<tr key={row.slug} className="border-b border-border/60 last:border-b-0">
|
||||
<td className="px-3 py-2 font-medium text-foreground">{row.name}</td>
|
||||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{row.views}</td>
|
||||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{row.clicks}</td>
|
||||
<td className="px-3 py-2 tabular-nums font-semibold text-primary">
|
||||
{Number(row.ctr)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索词 */}
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-semibold text-foreground">
|
||||
Top search queries
|
||||
</h2>
|
||||
<div className="rounded-xl border border-border">
|
||||
{topSearches.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No search events yet.
|
||||
</p>
|
||||
)}
|
||||
{topSearches.map((row, i) => (
|
||||
<div
|
||||
key={`${row.query}-${i}`}
|
||||
className="flex items-center justify-between border-b border-border/60 px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<span className="truncate text-sm text-foreground">{row.query}</span>
|
||||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||||
{Number(row.cnt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getClaimsAdmin } from "@/features/claim/actions";
|
||||
import { ClaimsTable } from "@/features/claim/components/claims-table";
|
||||
|
||||
export default async function AdminClaimsPage() {
|
||||
const { claims } = await getClaimsAdmin({});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-foreground">Claims</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Tool ownership claims. Approving grants the user owner rights.
|
||||
</p>
|
||||
</div>
|
||||
<ClaimsTable claims={claims} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { useCase, audience, collection, collectionTool } from "@/lib/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
createUseCase,
|
||||
createAudience,
|
||||
createCollection,
|
||||
deleteDiscoveryEntity,
|
||||
} from "@/features/discovery/actions";
|
||||
import { DiscoveryManager } from "@/features/discovery/components/discovery-manager";
|
||||
|
||||
export default async function AdminDiscoveryPage() {
|
||||
const [useCases, audiences, collections] = await Promise.all([
|
||||
db.select().from(useCase).orderBy(useCase.sortOrder),
|
||||
db.select().from(audience).orderBy(audience.sortOrder),
|
||||
db
|
||||
.select({
|
||||
id: collection.id,
|
||||
slug: collection.slug,
|
||||
nameEn: collection.nameEn,
|
||||
introEn: collection.introEn,
|
||||
toolCount: sql<number>`count(${collectionTool.toolId})`,
|
||||
})
|
||||
.from(collection)
|
||||
.leftJoin(collectionTool, eq(collectionTool.collectionId, collection.id))
|
||||
.groupBy(collection.id)
|
||||
.orderBy(collection.sortOrder),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-foreground">Discovery</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Manage the three-dimension discovery system: use cases, audiences and
|
||||
curated collections (best lists).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DiscoveryManager
|
||||
useCases={useCases.map((u) => ({ id: u.id, slug: u.slug, name: u.nameEn }))}
|
||||
audiences={audiences.map((a) => ({ id: a.id, slug: a.slug, name: a.nameEn }))}
|
||||
collections={collections.map((col) => ({
|
||||
id: col.id,
|
||||
slug: col.slug,
|
||||
name: col.nameEn,
|
||||
toolCount: Number(col.toolCount),
|
||||
}))}
|
||||
onCreateUseCase={createUseCase}
|
||||
onCreateAudience={createAudience}
|
||||
onCreateCollection={createCollection}
|
||||
onDelete={deleteDiscoveryEntity}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { user } from "@/lib/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { user, tool, submission, review, vote, event } from "@/lib/db/schema";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import { AdminDashboard } from "@/features/admin/components/admin-dashboard";
|
||||
|
||||
export default async function AdminPage() {
|
||||
// 获取统计数据
|
||||
const [totalUsers, activeUsers] = await Promise.all([
|
||||
const [
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
totalTools,
|
||||
pendingSubmissions,
|
||||
totalReviews,
|
||||
totalVotes,
|
||||
events30d,
|
||||
] = await Promise.all([
|
||||
// 总用户数
|
||||
db.select({ count: sql<number>`count(*)` }).from(user),
|
||||
|
||||
@@ -14,6 +22,25 @@ export default async function AdminPage() {
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(user)
|
||||
.where(sql`${user.updatedAt} > NOW() - INTERVAL '30 days'`),
|
||||
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tool)
|
||||
.where(eq(tool.status, "published")),
|
||||
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(submission)
|
||||
.where(eq(submission.status, "pending")),
|
||||
|
||||
db.select({ count: sql<number>`count(*)` }).from(review),
|
||||
|
||||
db.select({ count: sql<number>`count(*)` }).from(vote),
|
||||
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(event)
|
||||
.where(sql`${event.createdAt} > NOW() - INTERVAL '30 days'`),
|
||||
]);
|
||||
|
||||
// 获取最近的用户
|
||||
@@ -32,6 +59,11 @@ export default async function AdminPage() {
|
||||
const stats = {
|
||||
totalUsers: totalUsers[0].count,
|
||||
activeUsers: activeUsers[0].count,
|
||||
totalTools: totalTools[0].count,
|
||||
pendingSubmissions: pendingSubmissions[0].count,
|
||||
totalReviews: totalReviews[0].count,
|
||||
totalVotes: totalVotes[0].count,
|
||||
events30d: events30d[0].count,
|
||||
};
|
||||
|
||||
return <AdminDashboard stats={stats} recentUsers={recentUsers} />;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getReviewsAdmin } from "@/features/reviews/actions";
|
||||
import { ReviewsAdminTable } from "@/features/reviews/components/reviews-admin-table";
|
||||
|
||||
export default async function AdminReviewsPage() {
|
||||
const { reviews } = await getReviewsAdmin({});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-foreground">Reviews</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Moderate community reviews. Hiding a review recalculates the tool's rating.
|
||||
</p>
|
||||
</div>
|
||||
<ReviewsAdminTable reviews={reviews} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getSubmissionsAdmin } from "@/features/submission/actions";
|
||||
import { SubmissionsTable } from "@/features/submission/components/submissions-table";
|
||||
|
||||
export default async function AdminSubmissionsPage() {
|
||||
const { submissions } = await getSubmissionsAdmin({});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-foreground">Submissions</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Review user-submitted tools. Approving creates a published listing.
|
||||
</p>
|
||||
</div>
|
||||
<SubmissionsTable submissions={submissions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { vote, tool, user as userTable } from "@/lib/db/schema";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
/**
|
||||
* 页面:Vote Audit(设计文档 §50 反作弊)
|
||||
* 说明:管理员审计投票记录(用户 / 工具 / IP / 时间),支持识别刷票模式。
|
||||
*/
|
||||
export default async function AdminVotesPage() {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: vote.id,
|
||||
createdAt: vote.createdAt,
|
||||
source: vote.source,
|
||||
userName: sql<string>`COALESCE(${userTable.name}, '—')`,
|
||||
userEmail: sql<string>`COALESCE(${userTable.email}, '—')`,
|
||||
toolName: sql<string>`COALESCE(${tool.nameEn}, '—')`,
|
||||
toolSlug: tool.slug,
|
||||
ip: sql<string | null>`(
|
||||
SELECT ip_address FROM session
|
||||
WHERE session."userId" = ${vote.userId}
|
||||
ORDER BY session."createdAt" DESC
|
||||
LIMIT 1
|
||||
)`,
|
||||
})
|
||||
.from(vote)
|
||||
.innerJoin(userTable, eq(vote.userId, userTable.id))
|
||||
.innerJoin(tool, eq(vote.toolId, tool.id))
|
||||
.orderBy(desc(vote.createdAt))
|
||||
.limit(100);
|
||||
|
||||
// 反作弊信号:近 24h 投票最多账号
|
||||
const suspicious = await db
|
||||
.select({
|
||||
userName: sql<string>`COALESCE(${userTable.name}, '—')`,
|
||||
cnt: sql<number>`count(*)`,
|
||||
})
|
||||
.from(vote)
|
||||
.innerJoin(userTable, eq(vote.userId, userTable.id))
|
||||
.where(sql`${vote.createdAt} > NOW() - INTERVAL '24 hours'`)
|
||||
.groupBy(userTable.id)
|
||||
.orderBy(desc(sql`count(*)`))
|
||||
.limit(5);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-foreground">Vote Audit</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Latest 100 votes with user / IP / source signals for abuse detection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{suspicious.length > 0 && Number(suspicious[0].cnt) >= 10 && (
|
||||
<div className="mb-6 rounded-xl border border-amber-300/60 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
<p className="font-semibold">Velocity alert (24h)</p>
|
||||
<ul className="mt-1.5 space-y-0.5 text-xs">
|
||||
{suspicious.map((row) => (
|
||||
<li key={row.userName}>
|
||||
{row.userName}: {Number(row.cnt)} votes
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-secondary/40 text-left text-xs uppercase text-muted-foreground">
|
||||
<th className="px-3 py-2">Time</th>
|
||||
<th className="px-3 py-2">User</th>
|
||||
<th className="px-3 py-2">Tool</th>
|
||||
<th className="px-3 py-2">IP</th>
|
||||
<th className="px-3 py-2">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-8 text-center text-muted-foreground">
|
||||
No votes recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
|
||||
<td className="px-3 py-2 text-xs tabular-nums text-muted-foreground">
|
||||
{new Date(row.createdAt).toISOString().slice(0, 16).replace("T", " ")}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-medium text-foreground">{row.userName}</span>
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">{row.userEmail}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-foreground">{row.toolName}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{row.ip ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{row.source}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import {
|
||||
getUseCaseBySlug,
|
||||
getAudienceBySlug,
|
||||
getToolsForUseCase,
|
||||
getToolsForAudience,
|
||||
getUseCases,
|
||||
getAudiences,
|
||||
} from "@/features/discovery/actions";
|
||||
import { pickName, pickDescription } from "@/features/discovery/localize";
|
||||
import { generateSeoPage } from "@/lib/seo/page-generator";
|
||||
import { FaqPageJsonLd, BreadcrumbJsonLd, ItemListJsonLd } from "@/components/json-ld";
|
||||
import { ToolCardGrid } from "@/components/tool-card";
|
||||
import { FavoritesProvider } from "@/features/tools/components/favorite-button";
|
||||
import { VotesProvider } from "@/features/voting/components/vote-button";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const [useCases, audiences] = await Promise.all([
|
||||
getUseCases(),
|
||||
getAudiences(),
|
||||
]);
|
||||
const slugs = [
|
||||
...useCases.map((u) => u.slug),
|
||||
...audiences.map((a) => a.slug),
|
||||
];
|
||||
return slugs.flatMap((slug) =>
|
||||
["en", "zh"].map((locale) => ({ locale, slug }))
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 实体解析:先按 use case 找,再按 audience 找 */
|
||||
async function resolveEntity(slug: string) {
|
||||
const useCase = await getUseCaseBySlug(slug);
|
||||
if (useCase) return { kind: "use-case" as const, entity: useCase };
|
||||
const audience = await getAudienceBySlug(slug);
|
||||
if (audience) return { kind: "audience" as const, entity: audience };
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug, locale } = await params;
|
||||
const resolved = await resolveEntity(slug);
|
||||
if (!resolved) return { title: "Not found" };
|
||||
|
||||
const name = pickName(resolved.entity, locale);
|
||||
const brandName = process.env.NEXT_PUBLIC_APP_NAME || "AI Directory";
|
||||
const seo = generateSeoPage({
|
||||
name,
|
||||
kind: resolved.kind,
|
||||
brandName,
|
||||
toolCount: 0,
|
||||
description: pickDescription(resolved.entity, locale),
|
||||
});
|
||||
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: `/ai-tools-for/${slug}`,
|
||||
title: seo.title,
|
||||
description: seo.description,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AiToolsForEntityPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { slug, locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const resolved = await resolveEntity(slug);
|
||||
if (!resolved) notFound();
|
||||
|
||||
const { kind, entity } = resolved;
|
||||
const [tools, relatedUseCases, relatedAudiences] = await Promise.all([
|
||||
kind === "use-case"
|
||||
? getToolsForUseCase(entity.id, 30)
|
||||
: getToolsForAudience(entity.id, 30),
|
||||
getUseCases(),
|
||||
getAudiences(),
|
||||
]);
|
||||
|
||||
const name = pickName(entity, locale);
|
||||
const description = pickDescription(entity, locale);
|
||||
const brandName = process.env.NEXT_PUBLIC_APP_NAME || "AI Directory";
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
|
||||
|
||||
const seo = generateSeoPage({
|
||||
name,
|
||||
kind,
|
||||
brandName,
|
||||
toolCount: tools.length,
|
||||
description,
|
||||
topToolNames: tools.slice(0, 3).map((tool) => tool.nameEn),
|
||||
freeCount: tools.filter((tool) => tool.pricing === "free").length,
|
||||
audienceName: kind === "use-case" ? relatedAudiences[0]?.nameEn ?? null : null,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
|
||||
<FaqPageJsonLd faqs={seo.faqs} />
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", url: `${baseUrl}/${locale}` },
|
||||
{ name: "AI Tools For", url: `${baseUrl}/${locale}/ai-tools-for` },
|
||||
{ name, url: `${baseUrl}/${locale}/ai-tools-for/${slug}` },
|
||||
]}
|
||||
/>
|
||||
{tools.length > 0 && (
|
||||
<ItemListJsonLd
|
||||
name={seo.h1}
|
||||
url={`${baseUrl}/${locale}/ai-tools-for/${slug}`}
|
||||
items={tools.slice(0, 10).map((tool) => ({
|
||||
name: tool.nameEn,
|
||||
url: `${baseUrl}/${locale}/tools/${tool.slug}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<nav className="mb-6 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<LocaleLink href="/" className="hover:text-foreground">Home</LocaleLink>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<LocaleLink href="/ai-tools-for" className="hover:text-foreground">
|
||||
AI Tools For
|
||||
</LocaleLink>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground">{name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-primary">
|
||||
{kind === "use-case" ? "Use case" : "Audience"}
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
{seo.h1}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
{seo.intro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{tools.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
No tools tagged yet.
|
||||
</p>
|
||||
) : (
|
||||
<FavoritesProvider>
|
||||
<VotesProvider>
|
||||
<ToolCardGrid tools={tools} locale={locale} ranked />
|
||||
</VotesProvider>
|
||||
</FavoritesProvider>
|
||||
)}
|
||||
|
||||
<section className="mt-10 rounded-xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{seo.faqs.map((faq, i) => (
|
||||
<div key={i}>
|
||||
<h3 className="text-sm font-semibold text-foreground">{faq.question}</h3>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 相关维度内链 */}
|
||||
<section className="mt-10 space-y-4">
|
||||
{kind === "use-case" ? (
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-semibold text-foreground">
|
||||
Browse by audience
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relatedAudiences.slice(0, 9).map((aud) => (
|
||||
<LocaleLink
|
||||
key={aud.id}
|
||||
href={`/ai-tools-for/${aud.slug}`}
|
||||
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
{pickName(aud, locale)}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-semibold text-foreground">
|
||||
Popular use cases
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relatedUseCases.slice(0, 9).map((uc) => (
|
||||
<LocaleLink
|
||||
key={uc.id}
|
||||
href={`/ai-tools-for/${uc.slug}`}
|
||||
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
{pickName(uc, locale)}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Metadata } from "next";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import { getUseCases, getAudiences } from "@/features/discovery/actions";
|
||||
import { pickName } from "@/features/discovery/localize";
|
||||
import { CollectionPageJsonLd } from "@/components/json-ld";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { Target, Users } from "lucide-react";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: "/ai-tools-for",
|
||||
title: "AI Tools by Use Case & Audience",
|
||||
description:
|
||||
"Find the best AI tools for what you're trying to do: blogging, video editing, SEO, coding — or for who you are: developers, marketers, students and more.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AiToolsForPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const [useCases, audiences] = await Promise.all([
|
||||
getUseCases().catch(() => []),
|
||||
getAudiences().catch(() => []),
|
||||
]);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
|
||||
<CollectionPageJsonLd
|
||||
name="AI Tools by Use Case & Audience"
|
||||
description="Task-oriented and audience-oriented AI tool discovery"
|
||||
url={`${baseUrl}/${locale}/ai-tools-for`}
|
||||
itemCount={useCases.length + audiences.length}
|
||||
/>
|
||||
|
||||
<div className="mb-10">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
AI tools for what you do
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||
Skip the categories. Start from your task or your role — every page
|
||||
ranks tools by real community votes and reviews.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Use cases */}
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<Target className="h-4 w-4 text-primary" />
|
||||
By task
|
||||
</h2>
|
||||
{useCases.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No use cases yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{useCases.map((uc) => (
|
||||
<LocaleLink
|
||||
key={uc.id}
|
||||
href={`/ai-tools-for/${uc.slug}`}
|
||||
className="group rounded-xl border border-border p-4 transition-all hover:border-primary/30 hover:shadow-sm"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary">
|
||||
{pickName(uc, locale)}
|
||||
</h3>
|
||||
{uc.descriptionEn && (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{locale === "zh" && uc.descriptionZh
|
||||
? uc.descriptionZh
|
||||
: uc.descriptionEn}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums">
|
||||
{uc.toolCount} tools
|
||||
</p>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Audiences */}
|
||||
<section>
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
By audience
|
||||
</h2>
|
||||
{audiences.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No audiences yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{audiences.map((aud) => (
|
||||
<LocaleLink
|
||||
key={aud.id}
|
||||
href={`/ai-tools-for/${aud.slug}`}
|
||||
className="group rounded-xl border border-border p-4 transition-all hover:border-primary/30 hover:shadow-sm"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-foreground group-hover:text-primary">
|
||||
{pickName(aud, locale)}
|
||||
</h3>
|
||||
{aud.descriptionEn && (
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{locale === "zh" && aud.descriptionZh
|
||||
? aud.descriptionZh
|
||||
: aud.descriptionEn}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums">
|
||||
{aud.toolCount} tools
|
||||
</p>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import {
|
||||
getCollectionBySlug,
|
||||
getCollectionTools,
|
||||
getCollections,
|
||||
} from "@/features/discovery/actions";
|
||||
import { pickName } from "@/features/discovery/localize";
|
||||
import { generateSeoPage } from "@/lib/seo/page-generator";
|
||||
import { FaqPageJsonLd, BreadcrumbJsonLd, ItemListJsonLd } from "@/components/json-ld";
|
||||
import { ToolCardGrid } from "@/components/tool-card";
|
||||
import { FavoritesProvider } from "@/features/tools/components/favorite-button";
|
||||
import { VotesProvider } from "@/features/voting/components/vote-button";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { ChevronRight, Award } from "lucide-react";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const collections = await getCollections();
|
||||
return collections.flatMap((c) =>
|
||||
["en", "zh"].map((locale) => ({ locale, slug: c.slug }))
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug, locale } = await params;
|
||||
const collection = await getCollectionBySlug(slug);
|
||||
if (!collection) {
|
||||
return { title: "Collection not found" };
|
||||
}
|
||||
const name = pickName(collection, locale);
|
||||
const brandName = process.env.NEXT_PUBLIC_APP_NAME || "AI Directory";
|
||||
const seo = generateSeoPage({
|
||||
name,
|
||||
kind: "collection",
|
||||
brandName,
|
||||
toolCount: 0,
|
||||
description: locale === "zh" && collection.introZh ? collection.introZh : collection.introEn,
|
||||
});
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: `/best/${slug}`,
|
||||
title: seo.title,
|
||||
description: seo.description,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function BestCollectionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { slug, locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const collection = await getCollectionBySlug(slug);
|
||||
if (!collection) notFound();
|
||||
|
||||
const [tools, allCollections] = await Promise.all([
|
||||
getCollectionTools(collection.id),
|
||||
getCollections(),
|
||||
]);
|
||||
|
||||
const name = pickName(collection, locale);
|
||||
const intro =
|
||||
locale === "zh"
|
||||
? collection.introZh || collection.introEn
|
||||
: collection.introEn || collection.introZh;
|
||||
const brandName = process.env.NEXT_PUBLIC_APP_NAME || "AI Directory";
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
|
||||
|
||||
// Programmatic SEO:文案由真实数据驱动(§24)
|
||||
const seo = generateSeoPage({
|
||||
name,
|
||||
kind: "collection",
|
||||
brandName,
|
||||
toolCount: tools.length,
|
||||
description: intro,
|
||||
topToolNames: tools.slice(0, 3).map((tool) => tool.nameEn),
|
||||
freeCount: tools.filter((tool) => tool.pricing === "free").length,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
|
||||
<FaqPageJsonLd faqs={seo.faqs} />
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", url: `${baseUrl}/${locale}` },
|
||||
{ name: "Best Lists", url: `${baseUrl}/${locale}/best` },
|
||||
{ name, url: `${baseUrl}/${locale}/best/${slug}` },
|
||||
]}
|
||||
/>
|
||||
{tools.length > 0 && (
|
||||
<ItemListJsonLd
|
||||
name={name}
|
||||
url={`${baseUrl}/${locale}/best/${slug}`}
|
||||
items={tools.slice(0, 10).map((tool) => ({
|
||||
name: tool.nameEn,
|
||||
url: `${baseUrl}/${locale}/tools/${tool.slug}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 面包屑 */}
|
||||
<nav className="mb-6 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<LocaleLink href="/" className="hover:text-foreground">Home</LocaleLink>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<LocaleLink href="/best" className="hover:text-foreground">Best Lists</LocaleLink>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground">{name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
|
||||
<Award className="h-3.5 w-3.5" />
|
||||
Curated list
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
{seo.h1}
|
||||
</h1>
|
||||
{intro && (
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
{intro}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<FavoritesProvider>
|
||||
<VotesProvider>
|
||||
<ToolCardGrid tools={tools} locale={locale} ranked />
|
||||
</VotesProvider>
|
||||
</FavoritesProvider>
|
||||
</div>
|
||||
|
||||
{/* FAQ(内链 + FAQPage schema) */}
|
||||
<section className="rounded-xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{seo.faqs.map((faq, i) => (
|
||||
<div key={i}>
|
||||
<h3 className="text-sm font-semibold text-foreground">{faq.question}</h3>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 相关榜单(内链) */}
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-3 text-sm font-semibold text-foreground">More best lists</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allCollections
|
||||
.filter((col) => col.id !== collection.id)
|
||||
.slice(0, 8)
|
||||
.map((col) => (
|
||||
<LocaleLink
|
||||
key={col.id}
|
||||
href={`/best/${col.slug}`}
|
||||
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
{pickName(col, locale)}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Metadata } from "next";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import { getCollections } from "@/features/discovery/actions";
|
||||
import { CollectionPageJsonLd } from "@/components/json-ld";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { Award } from "lucide-react";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: "/best",
|
||||
title: "Best AI Tools — Curated Lists & Buying Guides",
|
||||
description:
|
||||
"Editor-curated best AI tool lists: writing, video, coding, marketing and more. Ranked by community votes and reviews.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function BestIndexPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const collections = await getCollections().catch(() => []);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
|
||||
<CollectionPageJsonLd
|
||||
name="Best AI Tools — Curated Lists"
|
||||
description="Editor-curated best AI tool lists ranked by the community"
|
||||
url={`${baseUrl}/${locale}/best`}
|
||||
itemCount={collections.length}
|
||||
/>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
Best AI Tools — Curated Lists
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Hand-picked rankings, updated with community signal. Every list is
|
||||
labeled and never sold.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{collections.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
No collections published yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{collections.map((col) => (
|
||||
<LocaleLink
|
||||
key={col.id}
|
||||
href={`/best/${col.slug}`}
|
||||
className="group rounded-xl border border-border p-6 transition-all hover:border-primary/30 hover:shadow-sm"
|
||||
>
|
||||
<Award className="h-5 w-5 text-primary" />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground group-hover:text-primary">
|
||||
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
|
||||
</h2>
|
||||
<p className="mt-1.5 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{locale === "zh" && col.introZh ? col.introZh : col.introEn}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground tabular-nums">
|
||||
{col.toolCount} tools
|
||||
</p>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { NavBar } from "@/features/navigation/components/navbar";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { ThemeChrome } from "@/themes/chrome";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from "@/i18n.config";
|
||||
|
||||
@@ -28,9 +27,9 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<main>
|
||||
<NavBar />
|
||||
{children}
|
||||
<Footer />
|
||||
<ThemeChrome>
|
||||
{children}
|
||||
</ThemeChrome>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Container } from "@/components/container";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { Background } from "@/components/background";
|
||||
import { CTA } from "@/components/cta";
|
||||
import { FeaturedTools } from "@/components/featured-tools";
|
||||
import { WebSiteJsonLd, OrganizationJsonLd } from "@/components/json-ld";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import type { Locale } from "@/i18n.config";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import { getFeaturedTools, getCategoriesWithCount } from "@/features/tools/actions";
|
||||
import { getActiveTheme } from "@/themes/registry";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
@@ -36,64 +31,25 @@ export default async function Home({
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
||||
|
||||
// 获取精选工具和分类数据(构建时数据库不可用则先渲染空态,运行时 ISR 补全)
|
||||
let tools: Awaited<ReturnType<typeof getFeaturedTools>> = [];
|
||||
let categories: Awaited<ReturnType<typeof getCategoriesWithCount>> = [];
|
||||
try {
|
||||
[tools, categories] = await Promise.all([
|
||||
getFeaturedTools(8),
|
||||
getCategoriesWithCount(),
|
||||
]);
|
||||
} catch {
|
||||
console.warn("home: database unavailable at build time");
|
||||
}
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
|
||||
const theme = getActiveTheme();
|
||||
const ThemeHomePage = theme.HomePage;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Structured Data for SEO */}
|
||||
<WebSiteJsonLd
|
||||
name="Toolso.AI"
|
||||
description="Discover the best AI tools to boost your productivity"
|
||||
name={theme.brand.name}
|
||||
description={theme.brand.tagline}
|
||||
url={`${baseUrl}/${locale}`}
|
||||
locale={locale}
|
||||
/>
|
||||
<OrganizationJsonLd
|
||||
name="Toolso.AI"
|
||||
name={theme.brand.name}
|
||||
url={baseUrl}
|
||||
logo={`${baseUrl}/banner.png`}
|
||||
sameAs={[
|
||||
'https://coolchange.ai',
|
||||
'https://chatppt.app',
|
||||
'https://aihealthguide.co',
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Background 放在最外层,覆盖整个页面 */}
|
||||
<div className="absolute inset-0 h-full w-full overflow-hidden">
|
||||
<Background />
|
||||
</div>
|
||||
|
||||
{/* Hero 部分 */}
|
||||
<Container className="flex flex-col items-center justify-center pt-32 pb-20 md:pt-40 md:pb-32">
|
||||
<Hero />
|
||||
</Container>
|
||||
|
||||
{/* 精选工具部分 */}
|
||||
<div className="relative bg-background">
|
||||
<Container>
|
||||
<FeaturedTools tools={tools} categories={categories} />
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{/* CTA 部分 */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 h-full w-full overflow-hidden">
|
||||
<Background />
|
||||
</div>
|
||||
<CTA />
|
||||
</div>
|
||||
<ThemeHomePage locale={locale} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import { getRankedTools, type RankedTool } from "@/lib/ranking/queries";
|
||||
import type { RankingBoard } from "@/lib/ranking/score";
|
||||
import { parseRankingBoard } from "@/lib/ranking/boards";
|
||||
import { ToolCard } from "@/components/tool-card";
|
||||
import { CollectionPageJsonLd } from "@/components/json-ld";
|
||||
import { FavoritesProvider } from "@/features/tools/components/favorite-button";
|
||||
import { VotesProvider } from "@/features/voting/components/vote-button";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { Flame, Trophy, ChevronUp, Heart, Star, Clock, Award } from "lucide-react";
|
||||
|
||||
export const revalidate = 1800;
|
||||
|
||||
const boards: {
|
||||
id: RankingBoard;
|
||||
labelKey: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{ id: "trending", labelKey: "trending", icon: <Flame className="h-3.5 w-3.5" /> },
|
||||
{ id: "popular", labelKey: "popular", icon: <Trophy className="h-3.5 w-3.5" /> },
|
||||
{ id: "most-voted", labelKey: "mostVoted", icon: <ChevronUp className="h-3.5 w-3.5" /> },
|
||||
{ id: "most-saved", labelKey: "mostSaved", icon: <Heart className="h-3.5 w-3.5" /> },
|
||||
{ id: "highest-rated", labelKey: "highestRated", icon: <Star className="h-3.5 w-3.5" /> },
|
||||
{ id: "new", labelKey: "new", icon: <Clock className="h-3.5 w-3.5" /> },
|
||||
{ id: "editors-choice", labelKey: "editorsChoice", icon: <Award className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: "/rankings",
|
||||
title: "AI Tool Rankings — Trending, Popular & Community Ranked",
|
||||
description:
|
||||
"Live community rankings of AI tools: trending this week, most voted, most saved, highest rated and editor's choices.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function RankingsPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{ board?: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const { board: boardParam } = await searchParams;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("rankings");
|
||||
|
||||
const board = parseRankingBoard(boardParam);
|
||||
const tools: RankedTool[] = await getRankedTools(board, 24).catch(() => []);
|
||||
const activeBoard = boards.find((b) => b.id === board)!;
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
|
||||
<CollectionPageJsonLd
|
||||
name={`AI Tool Rankings — ${activeBoard.id}`}
|
||||
description="Community-ranked AI tools"
|
||||
url={`${baseUrl}/${locale}/rankings`}
|
||||
itemCount={tools.length}
|
||||
/>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* 榜单 Tab */}
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{boards.map((b) => (
|
||||
<LocaleLink
|
||||
key={b.id}
|
||||
href={`/rankings?board=${b.id}`}
|
||||
className={
|
||||
b.id === board
|
||||
? "inline-flex items-center gap-1.5 rounded-full bg-primary px-3.5 py-1.5 text-xs font-semibold text-primary-foreground"
|
||||
: "inline-flex items-center gap-1.5 rounded-full border border-border px-3.5 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground"
|
||||
}
|
||||
>
|
||||
{b.icon}
|
||||
{t(b.labelKey)}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tools.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
{t("empty")}
|
||||
</p>
|
||||
) : (
|
||||
<FavoritesProvider>
|
||||
<VotesProvider>
|
||||
<div className="space-y-3">
|
||||
{tools.map((tool, index) => (
|
||||
<ToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
locale={locale}
|
||||
rank={index + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</VotesProvider>
|
||||
</FavoritesProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import { getCategoriesWithCount } from "@/features/tools/actions";
|
||||
import { SubmitToolForm } from "@/features/submission/components/submit-tool-form";
|
||||
import { pickName } from "@/features/discovery/localize";
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: "/submit",
|
||||
title: "Submit Your AI Tool",
|
||||
description:
|
||||
"Submit your AI tool to our directory. Free listing, community voting, user reviews and a do-follow link to your site.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function SubmitPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const categories = await getCategoriesWithCount().catch(() => []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
Submit your AI tool
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
Get a free listing with community votes, user reviews and analytics.
|
||||
Submissions are reviewed by our editors, usually within 48 hours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border p-6">
|
||||
<SubmitToolForm
|
||||
categories={categories.map((category) => ({
|
||||
id: category.id,
|
||||
name: pickName(category, locale),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,15 +7,52 @@ import { tool as toolTable } from "@/lib/db/schema";
|
||||
import { getToolBySlug } from "@/features/tools/actions";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { locales } from "@/i18n.config";
|
||||
import { ExternalLink, ArrowLeft, Tag } from "lucide-react";
|
||||
import {
|
||||
ExternalLink,
|
||||
ArrowLeft,
|
||||
Tag,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Users,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { SoftwareApplicationJsonLd } from "@/components/json-ld";
|
||||
import {
|
||||
SoftwareApplicationJsonLd,
|
||||
BreadcrumbJsonLd,
|
||||
FaqPageJsonLd,
|
||||
} from "@/components/json-ld";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import {
|
||||
FavoritesProvider,
|
||||
FavoriteButton,
|
||||
} from "@/features/tools/components/favorite-button";
|
||||
import { VotesProvider, VoteButton } from "@/features/voting/components/vote-button";
|
||||
import { ToolLogo } from "@/components/tool-logo";
|
||||
import { ShareButton } from "@/components/share-button";
|
||||
import { parseToolSlugRoute } from "@/lib/ranking/score";
|
||||
import { generateToolFaqs, generateSeoPage } from "@/lib/seo/page-generator";
|
||||
import { StarRating } from "@/features/reviews/components/star-rating";
|
||||
import {
|
||||
ReviewsSection,
|
||||
RatingSummary,
|
||||
} from "@/features/reviews/components/reviews-section";
|
||||
import { ClaimToolButton } from "@/features/claim/components/claim-button";
|
||||
import {
|
||||
getToolDiscoveryRelations,
|
||||
getAlternativeTools,
|
||||
} from "@/features/discovery/actions";
|
||||
import { pickName } from "@/features/discovery/localize";
|
||||
import {
|
||||
getToolReviews,
|
||||
getRatingSummary,
|
||||
} from "@/features/reviews/actions";
|
||||
import { recordToolView } from "@/lib/analytics/record";
|
||||
import { ToolCard } from "@/components/tool-card";
|
||||
import {
|
||||
AlternativesView,
|
||||
ComparisonView,
|
||||
} from "@/features/alternatives/components/views";
|
||||
|
||||
interface ToolDetailPageProps {
|
||||
params: Promise<{
|
||||
@@ -45,26 +82,59 @@ export async function generateStaticParams() {
|
||||
|
||||
export async function generateMetadata({ params }: ToolDetailPageProps) {
|
||||
const { slug, locale } = await params;
|
||||
const parsed = parseToolSlugRoute(slug);
|
||||
|
||||
// Alternatives / Comparison 页面独立 SEO 文案
|
||||
if (parsed.kind === "alternatives") {
|
||||
const base = await getToolBySlug(parsed.base);
|
||||
if (base) {
|
||||
const brandName = process.env.NEXT_PUBLIC_APP_NAME || "AI Directory";
|
||||
const name = locale === "zh" && base.nameZh ? base.nameZh : base.nameEn;
|
||||
const seo = generateSeoPage({
|
||||
name: `${name} Alternatives`,
|
||||
kind: "collection",
|
||||
brandName,
|
||||
toolCount: 0,
|
||||
description: base.descriptionEn,
|
||||
});
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: `/tools/${slug}`,
|
||||
title: `Top ${name} Alternatives in 2026 — Compared`,
|
||||
description: seo.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (parsed.kind === "comparison") {
|
||||
const [a, b] = await Promise.all([
|
||||
getToolBySlug(parsed.a),
|
||||
getToolBySlug(parsed.b),
|
||||
]);
|
||||
if (a && b) {
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: `/tools/${slug}`,
|
||||
title: `${a.nameEn} vs ${b.nameEn} — Which Is Better in 2026?`,
|
||||
description: `Compare ${a.nameEn} and ${b.nameEn}: community ratings, votes, pricing, features and real user reviews side by side.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "tools" });
|
||||
const tool = await getToolBySlug(slug);
|
||||
|
||||
if (!tool) {
|
||||
return {
|
||||
title: t("notFound"),
|
||||
};
|
||||
return { title: t("notFound") };
|
||||
}
|
||||
|
||||
const name = locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
|
||||
const description =
|
||||
locale === "zh" && tool.descriptionZh
|
||||
? tool.descriptionZh
|
||||
: tool.descriptionEn;
|
||||
locale === "zh" && tool.descriptionZh ? tool.descriptionZh : tool.descriptionEn;
|
||||
|
||||
return generatePageMetadata({
|
||||
locale,
|
||||
path: `/tools/${slug}`,
|
||||
title: `${name} - ${t("title")}`,
|
||||
description: description || `${t("subtitle")} - ${name}`,
|
||||
description: tool.taglineEn || description || `${t("subtitle")} - ${name}`,
|
||||
ogImage: tool.coverImageUrl || tool.logoUrl || undefined,
|
||||
});
|
||||
}
|
||||
@@ -72,6 +142,16 @@ export async function generateMetadata({ params }: ToolDetailPageProps) {
|
||||
export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
const { slug, locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
// ===== 复合路由分发:alternatives / comparison / 普通详情 =====
|
||||
const parsed = parseToolSlugRoute(slug);
|
||||
if (parsed.kind === "alternatives") {
|
||||
return <AlternativesView baseSlug={parsed.base} locale={locale} />;
|
||||
}
|
||||
if (parsed.kind === "comparison") {
|
||||
return <ComparisonView slugA={parsed.a} slugB={parsed.b} locale={locale} />;
|
||||
}
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "tools" });
|
||||
const tool = await getToolBySlug(slug);
|
||||
|
||||
@@ -79,176 +159,378 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 并行取详情页全部数据
|
||||
const [relations, alternatives, reviewsData, ratingSummary] = await Promise.all([
|
||||
getToolDiscoveryRelations(tool.id),
|
||||
getAlternativeTools(tool.id, 4),
|
||||
getToolReviews(tool.id),
|
||||
getRatingSummary(tool.id),
|
||||
]);
|
||||
|
||||
// 埋点:tool_view(失败不阻塞渲染)
|
||||
recordToolView(tool.id).catch(() => {});
|
||||
|
||||
const name = locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
|
||||
const description =
|
||||
locale === "zh" && tool.descriptionZh
|
||||
? tool.descriptionZh
|
||||
: tool.descriptionEn;
|
||||
locale === "zh" && tool.descriptionZh ? tool.descriptionZh : tool.descriptionEn;
|
||||
const tagline =
|
||||
locale === "zh"
|
||||
? tool.taglineZh || tool.descriptionZh || tool.taglineEn
|
||||
: tool.taglineEn || tool.descriptionEn;
|
||||
|
||||
const getCategoryName = (category: { nameEn: string; nameZh: string | null }) =>
|
||||
locale === "zh" && category.nameZh ? category.nameZh : category.nameEn;
|
||||
pickName(category, locale);
|
||||
|
||||
const getTagName = (tag: { nameEn: string; nameZh: string | null }) =>
|
||||
locale === "zh" && tag.nameZh ? tag.nameZh : tag.nameEn;
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
||||
const primaryCategory = tool.categories?.[0]?.nameEn;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
|
||||
const primaryCategory = tool.categories?.[0] ?? null;
|
||||
|
||||
const faqs = generateToolFaqs(
|
||||
{
|
||||
name,
|
||||
description: description,
|
||||
pricing: tool.pricing,
|
||||
categoryName: primaryCategory ? getCategoryName(primaryCategory) : null,
|
||||
},
|
||||
alternatives.map((a) => (locale === "zh" && a.nameZh ? a.nameZh : a.nameEn))
|
||||
);
|
||||
|
||||
const pricingLabels: Record<string, string> = {
|
||||
free: "Free",
|
||||
freemium: "Freemium",
|
||||
paid: "Paid",
|
||||
free_trial: "Free Trial",
|
||||
open_source: "Open Source",
|
||||
contact: "Contact for pricing",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 pt-24 pb-12">
|
||||
{/* Structured Data for AI Tool */}
|
||||
<div className="mx-auto max-w-6xl px-4 pt-8 pb-16 sm:px-6">
|
||||
<SoftwareApplicationJsonLd
|
||||
name={name}
|
||||
description={description || ''}
|
||||
description={description || ""}
|
||||
url={tool.websiteUrl || `${baseUrl}/${locale}/tools/${slug}`}
|
||||
image={tool.logoUrl || tool.coverImageUrl || undefined}
|
||||
category={primaryCategory}
|
||||
category={primaryCategory ? primaryCategory.nameEn : undefined}
|
||||
applicationCategory="AI Tool"
|
||||
ratingAvg={ratingSummary.avg}
|
||||
ratingCount={ratingSummary.total}
|
||||
/>
|
||||
<FaqPageJsonLd faqs={faqs} />
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", url: `${baseUrl}/${locale}` },
|
||||
{ name: "AI Tools", url: `${baseUrl}/${locale}/tools` },
|
||||
...(primaryCategory
|
||||
? [
|
||||
{
|
||||
name: primaryCategory.nameEn,
|
||||
url: `${baseUrl}/${locale}/category/${primaryCategory.slug}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ name, url: `${baseUrl}/${locale}/tools/${slug}` },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 返回按钮 */}
|
||||
<Link
|
||||
href={`/${locale}/tools`}
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("backToTools")}
|
||||
</Link>
|
||||
{/* 面包屑内链 */}
|
||||
<nav className="mb-8 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Link
|
||||
href={`/${locale}/tools`}
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
AI Tools
|
||||
</Link>
|
||||
{primaryCategory && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
href={`/${locale}/category/${primaryCategory.slug}`}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
{getCategoryName(primaryCategory)}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground">{name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<div className="grid grid-cols-1 gap-10 lg:grid-cols-3">
|
||||
{/* 主内容区 */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<div className="space-y-10 lg:col-span-2">
|
||||
{/* 工具头部 */}
|
||||
<div className="flex items-start gap-6">
|
||||
<ToolLogo src={tool.logoUrl} alt={name} size={80} className="rounded-2xl" />
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">
|
||||
{name}
|
||||
</h1>
|
||||
{tool.domain && (
|
||||
<p className="text-muted-foreground">{tool.domain}</p>
|
||||
<div className="flex items-start gap-4 sm:gap-6">
|
||||
<ToolLogo src={tool.logoUrl} alt={name} size={72} className="rounded-2xl" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">
|
||||
{name}
|
||||
</h1>
|
||||
{tool.claimedByUserId && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tagline && (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground sm:text-base">
|
||||
{tagline}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
{(tool.ratingAvg ?? 0) > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<StarRating value={tool.ratingAvg ?? 0} />
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{(tool.ratingAvg ?? 0).toFixed(1)}
|
||||
</span>
|
||||
<span>({tool.reviewCount})</span>
|
||||
</span>
|
||||
)}
|
||||
{tool.pricing && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5">
|
||||
{pricingLabels[tool.pricing] ?? tool.pricing}
|
||||
</span>
|
||||
)}
|
||||
{tool.domain && <span>{tool.domain}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面图 */}
|
||||
{tool.coverImageUrl && (
|
||||
<div className="rounded-xl overflow-hidden border border-border">
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<Image
|
||||
src={tool.coverImageUrl}
|
||||
alt={name}
|
||||
width={1200}
|
||||
height={630}
|
||||
className="w-full h-auto"
|
||||
className="h-auto w-full"
|
||||
sizes="(max-width: 1024px) 100vw, 66vw"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 描述 */}
|
||||
{/* 描述 Overview */}
|
||||
{description && (
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
{t("about")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold text-foreground">{t("about")}</h2>
|
||||
<p className="leading-relaxed text-muted-foreground whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Use Cases & Audiences(三维发现内链) */}
|
||||
{(relations.useCases.length > 0 || relations.audiences.length > 0) && (
|
||||
<section className="grid gap-6 sm:grid-cols-2">
|
||||
{relations.useCases.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Target className="h-4 w-4 text-primary" />
|
||||
Use cases
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relations.useCases.map((uc) => (
|
||||
<Link
|
||||
key={uc.id}
|
||||
href={`/${locale}/ai-tools-for/${uc.slug}`}
|
||||
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
{pickName(uc, locale)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{relations.audiences.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
Good for
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relations.audiences.map((aud) => (
|
||||
<Link
|
||||
key={aud.id}
|
||||
href={`/${locale}/ai-tools-for/${aud.slug}`}
|
||||
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
{pickName(aud, locale)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 分类 */}
|
||||
{tool.categories && tool.categories.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Code2 className="h-4 w-4 text-primary" />
|
||||
{t("categories")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.categories.map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
href={`/${locale}/tools?category=${category.slug}`}
|
||||
className="px-4 py-2 rounded-lg bg-secondary text-foreground hover:bg-hover transition-colors"
|
||||
href={`/${locale}/category/${category.slug}`}
|
||||
className="rounded-lg bg-secondary px-3.5 py-2 text-sm text-foreground transition-colors hover:bg-hover"
|
||||
>
|
||||
{category.icon && (
|
||||
<span className="mr-2">{category.icon}</span>
|
||||
)}
|
||||
{getCategoryName(category)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{tool.tags && tool.tags.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Tag className="h-4 w-4 text-primary" />
|
||||
{t("tags")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-secondary text-sm text-muted-foreground"
|
||||
className="inline-flex items-center gap-1 rounded-full bg-secondary px-3 py-1 text-sm text-muted-foreground"
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
{getTagName(tag)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 评分概览 */}
|
||||
<div className="grid gap-6 sm:grid-cols-[240px_1fr] sm:items-start">
|
||||
<RatingSummary
|
||||
distribution={ratingSummary.distribution}
|
||||
total={ratingSummary.total}
|
||||
avg={ratingSummary.avg}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("reviewCta")}</p>
|
||||
</div>
|
||||
|
||||
{/* 评论 */}
|
||||
<ReviewsSection
|
||||
toolId={tool.id}
|
||||
initialReviews={reviewsData.reviews}
|
||||
initialTotal={reviewsData.total}
|
||||
/>
|
||||
|
||||
{/* Alternatives 内链区块 */}
|
||||
{alternatives.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{t("alternatives")}
|
||||
</h2>
|
||||
<Link
|
||||
href={`/${locale}/tools/${slug}-alternatives`}
|
||||
className="text-xs font-medium text-muted-foreground hover:text-primary"
|
||||
>
|
||||
{t("seeAllAlternatives")}
|
||||
</Link>
|
||||
</div>
|
||||
<FavoritesProvider>
|
||||
<VotesProvider>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{alternatives.map((alt) => (
|
||||
<ToolCard key={alt.id} tool={alt} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</VotesProvider>
|
||||
</FavoritesProvider>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="rounded-xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">FAQ</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{faqs.map((faq, i) => (
|
||||
<div key={i}>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{faq.question}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="sticky top-24 space-y-6">
|
||||
{/* 访问按钮 */}
|
||||
<div className="sticky top-20 space-y-4">
|
||||
{/* 投票 + 访问 */}
|
||||
<VotesProvider>
|
||||
<VoteButton
|
||||
toolId={tool.id}
|
||||
variant="button"
|
||||
initialCount={tool.voteCount}
|
||||
/>
|
||||
</VotesProvider>
|
||||
|
||||
{tool.websiteUrl && (
|
||||
<a
|
||||
href={tool.websiteUrl}
|
||||
href={`/api/outbound?tool=${tool.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="block"
|
||||
>
|
||||
<Button className="w-full">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{t("visitButton")}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* 收藏按钮 */}
|
||||
<FavoritesProvider>
|
||||
<FavoriteButton toolId={tool.id} appearance="button" />
|
||||
</FavoritesProvider>
|
||||
|
||||
{/* 工具信息卡片 */}
|
||||
<div className="bg-secondary rounded-xl p-6 space-y-4">
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{t("toolInfo")}
|
||||
</h3>
|
||||
<ShareButton toolName={name} />
|
||||
|
||||
<ClaimToolButton toolId={tool.id} claimed={!!tool.claimedByUserId} />
|
||||
|
||||
{/* 工具信息卡片 */}
|
||||
<div className="space-y-4 rounded-xl bg-secondary p-5">
|
||||
<h3 className="font-semibold text-foreground">{t("toolInfo")}</h3>
|
||||
{tool.domain && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("domain")}
|
||||
</p>
|
||||
<p className="text-foreground">{tool.domain}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("domain")}</p>
|
||||
<p className="text-sm text-foreground">{tool.domain}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("added")}
|
||||
</p>
|
||||
<p className="text-foreground">
|
||||
<p className="text-xs text-muted-foreground">{t("added")}</p>
|
||||
<p className="text-sm text-foreground">
|
||||
{new Date(tool.createdAt).toLocaleDateString(
|
||||
locale === "zh" ? "zh-CN" : "en-US"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("stats.votes")}</p>
|
||||
<p className="text-sm tabular-nums text-foreground">{tool.voteCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("stats.views")}</p>
|
||||
<p className="text-sm tabular-nums text-foreground">{tool.viewCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions";
|
||||
import { getUseCases } from "@/features/discovery/actions";
|
||||
import { pickName } from "@/features/discovery/localize";
|
||||
import { VotesProvider } from "@/features/voting/components/vote-button";
|
||||
import { ToolsList } from "@/features/tools/components/tools-list";
|
||||
import { FavoritesProvider } from "@/features/tools/components/favorite-button";
|
||||
import { CollectionPageJsonLd } from "@/components/json-ld";
|
||||
@@ -26,6 +29,9 @@ interface ToolsPageProps {
|
||||
category?: string;
|
||||
search?: string;
|
||||
pricing?: string;
|
||||
usecase?: string;
|
||||
audience?: string;
|
||||
sort?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -41,9 +47,15 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
||||
pricingParam === "free" || pricingParam === "freemium" || pricingParam === "paid"
|
||||
? pricingParam
|
||||
: undefined;
|
||||
const useCaseSlug = params.usecase;
|
||||
const audienceSlug = params.audience;
|
||||
const sort = params.sort === "trending" ? "trending" : "newest";
|
||||
|
||||
// 获取分类列表用于筛选
|
||||
const categories = await getCategoriesWithCount();
|
||||
const [categories, useCases] = await Promise.all([
|
||||
getCategoriesWithCount(),
|
||||
getUseCases().catch(() => []),
|
||||
]);
|
||||
|
||||
// 找到对应的 categoryId
|
||||
let categoryId: string | undefined;
|
||||
@@ -59,6 +71,9 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
||||
categoryId,
|
||||
search,
|
||||
pricing,
|
||||
useCaseSlug,
|
||||
audienceSlug,
|
||||
sort,
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
@@ -75,6 +90,8 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
||||
pricingFree: t("pricing.free"),
|
||||
pricingFreemium: t("pricing.freemium"),
|
||||
pricingPaid: t("pricing.paid"),
|
||||
sortLatest: t("sort.latest"),
|
||||
sortTrending: t("sort.trending"),
|
||||
};
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
||||
@@ -99,18 +116,26 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
||||
</div>
|
||||
|
||||
<FavoritesProvider>
|
||||
<ToolsList
|
||||
tools={tools}
|
||||
categories={categories}
|
||||
currentCategory={categorySlug}
|
||||
currentPricing={pricing}
|
||||
currentSearch={search}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
locale={locale}
|
||||
translations={translations}
|
||||
/>
|
||||
<VotesProvider>
|
||||
<ToolsList
|
||||
tools={tools}
|
||||
categories={categories}
|
||||
currentCategory={categorySlug}
|
||||
currentPricing={pricing}
|
||||
currentSearch={search}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
locale={locale}
|
||||
translations={translations}
|
||||
useCases={useCases.map((uc) => ({
|
||||
slug: uc.slug,
|
||||
name: pickName(uc, locale),
|
||||
toolCount: uc.toolCount,
|
||||
}))}
|
||||
currentSort={sort}
|
||||
/>
|
||||
</VotesProvider>
|
||||
</FavoritesProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { tool } from "@/lib/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { recordEvent } from "@/lib/analytics/record";
|
||||
|
||||
/**
|
||||
* GET /api/outbound?tool=<slug>
|
||||
* 说明:外链跳转 + 点击埋点(tool_click 事件 + tool.clickCount 冗余计数)。
|
||||
* 这是 Analytics CTR 数据(设计文档 §37)的采集入口。
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const slug = req.nextUrl.searchParams.get("tool");
|
||||
if (!slug) {
|
||||
return NextResponse.redirect(new URL("/", req.url));
|
||||
}
|
||||
|
||||
const [toolRow] = await db
|
||||
.select({ id: tool.id, websiteUrl: tool.websiteUrl })
|
||||
.from(tool)
|
||||
.where(eq(tool.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (!toolRow?.websiteUrl) {
|
||||
return NextResponse.redirect(new URL("/", req.url));
|
||||
}
|
||||
|
||||
await recordEvent({
|
||||
event: "tool_click",
|
||||
entityType: "tool",
|
||||
entityId: toolRow.id,
|
||||
metadata: { slug },
|
||||
});
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(tool)
|
||||
.set({ clickCount: sql`${tool.clickCount} + 1` })
|
||||
.where(eq(tool.id, toolRow.id));
|
||||
} catch {
|
||||
// 计数失败不阻塞跳转
|
||||
}
|
||||
|
||||
return NextResponse.redirect(toolRow.websiteUrl, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { tool, toolCategory, category, useCase, toolUseCase, audience, toolAudience } from "@/lib/db/schema";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { recordEvent } from "@/lib/analytics/record";
|
||||
|
||||
/**
|
||||
* GET /api/search?q=<query>
|
||||
* 说明:PostgreSQL 搜索(第一阶段,设计文档 §27)。
|
||||
* 匹配名称/标语/描述 + 分类/用例/受众名称;按互动热度排序。
|
||||
* 埋点 search 事件(供 Admin 分析搜索词)。
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const q = (req.nextUrl.searchParams.get("q") || "").trim();
|
||||
if (!q) {
|
||||
return NextResponse.json({ query: q, results: [] });
|
||||
}
|
||||
|
||||
const pattern = `%${q}%`;
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: tool.id,
|
||||
slug: tool.slug,
|
||||
name: tool.nameEn,
|
||||
nameZh: tool.nameZh,
|
||||
tagline: tool.taglineEn,
|
||||
taglineZh: tool.taglineZh,
|
||||
description: tool.descriptionEn,
|
||||
descriptionZh: tool.descriptionZh,
|
||||
logoUrl: tool.logoUrl,
|
||||
pricing: tool.pricing,
|
||||
voteCount: tool.voteCount,
|
||||
ratingAvg: tool.ratingAvg,
|
||||
score: sql<number>`(
|
||||
GREATEST(${tool.voteCount}, 0) * 2 + ${tool.favoriteCount} * 1.5 + ${tool.reviewCount} * 3 + ${tool.viewCount} * 0.1
|
||||
)`,
|
||||
})
|
||||
.from(tool)
|
||||
.leftJoin(toolCategory, eq(toolCategory.toolId, tool.id))
|
||||
.leftJoin(category, eq(category.id, toolCategory.categoryId))
|
||||
.leftJoin(toolUseCase, eq(toolUseCase.toolId, tool.id))
|
||||
.leftJoin(useCase, eq(useCase.id, toolUseCase.useCaseId))
|
||||
.leftJoin(toolAudience, eq(toolAudience.toolId, tool.id))
|
||||
.leftJoin(audience, eq(audience.id, toolAudience.audienceId))
|
||||
.where(
|
||||
and(
|
||||
eq(tool.status, "published"),
|
||||
sql`(
|
||||
${tool.nameEn} ILIKE ${pattern}
|
||||
OR ${tool.nameZh} ILIKE ${pattern}
|
||||
OR ${tool.taglineEn} ILIKE ${pattern}
|
||||
OR ${tool.taglineZh} ILIKE ${pattern}
|
||||
OR ${tool.descriptionEn} ILIKE ${pattern}
|
||||
OR ${tool.descriptionZh} ILIKE ${pattern}
|
||||
OR ${category.nameEn} ILIKE ${pattern}
|
||||
OR ${category.nameZh} ILIKE ${pattern}
|
||||
OR ${useCase.nameEn} ILIKE ${pattern}
|
||||
OR ${useCase.nameZh} ILIKE ${pattern}
|
||||
OR ${audience.nameEn} ILIKE ${pattern}
|
||||
OR ${audience.nameZh} ILIKE ${pattern}
|
||||
)`
|
||||
)
|
||||
)
|
||||
.orderBy(desc(sql`(
|
||||
GREATEST(${tool.voteCount}, 0) * 2 + ${tool.favoriteCount} * 1.5 + ${tool.reviewCount} * 3 + ${tool.viewCount} * 0.1
|
||||
)`))
|
||||
.limit(20)
|
||||
.then((rows) => {
|
||||
// 多对多 join 可能产生重复行
|
||||
const seen = new Set<string>();
|
||||
return rows.filter((r) => {
|
||||
if (seen.has(r.id)) return false;
|
||||
seen.add(r.id);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
await recordEvent({
|
||||
event: "search",
|
||||
metadata: { query: q, resultCount: results.length },
|
||||
});
|
||||
|
||||
return NextResponse.json({ query: q, results });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toggleVote } from "@/features/voting/actions";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
async function requireSession() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
return session?.user ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/tools/:id/vote — 投票
|
||||
*/
|
||||
export async function POST(_req: NextRequest, ctx: RouteContext) {
|
||||
const user = await requireSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { id } = await ctx.params;
|
||||
const result = await toggleVote(id, "api");
|
||||
if (!result.success) {
|
||||
const status = result.error === "RATE_LIMITED" ? 429 : 400;
|
||||
return NextResponse.json({ error: result.error }, { status });
|
||||
}
|
||||
return NextResponse.json({
|
||||
voted: result.voted,
|
||||
voteCount: result.voteCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/tools/:id/vote — 取消投票
|
||||
*/
|
||||
export async function DELETE(_req: NextRequest, ctx: RouteContext) {
|
||||
const user = await requireSession();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { id } = await ctx.params;
|
||||
const result = await toggleVote(id, "api");
|
||||
if (!result.success) {
|
||||
const status = result.error === "RATE_LIMITED" ? 429 : 400;
|
||||
return NextResponse.json({ error: result.error }, { status });
|
||||
}
|
||||
return NextResponse.json({
|
||||
voted: result.voted,
|
||||
voteCount: result.voteCount,
|
||||
});
|
||||
}
|
||||
+66
-3
@@ -4,17 +4,28 @@ import { eq } from 'drizzle-orm'
|
||||
// 每小时重新生成,保证新收录的工具能进入 sitemap
|
||||
export const revalidate = 3600
|
||||
import { db } from '@/lib/db'
|
||||
import { tool, category } from '@/lib/db/schema'
|
||||
import {
|
||||
tool,
|
||||
category,
|
||||
useCase,
|
||||
audience,
|
||||
collection,
|
||||
} from '@/lib/db/schema'
|
||||
import { getAllBlogs } from '@/lib/blog'
|
||||
import { locales } from '@/i18n.config'
|
||||
import { absoluteUrl } from '@/lib/metadata'
|
||||
import { getMostVotedTools } from '@/lib/ranking/queries'
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// Static routes with SEO priority for AI tools directory
|
||||
const staticRoutes = [
|
||||
{ path: '', changeFreq: 'daily' as const, priority: 1.0 },
|
||||
{ path: '/tools', changeFreq: 'daily' as const, priority: 0.95 },
|
||||
{ path: '/rankings', changeFreq: 'daily' as const, priority: 0.85 },
|
||||
{ path: '/ai-tools-for', changeFreq: 'weekly' as const, priority: 0.85 },
|
||||
{ path: '/best', changeFreq: 'weekly' as const, priority: 0.85 },
|
||||
{ path: '/blog', changeFreq: 'weekly' as const, priority: 0.8 },
|
||||
{ path: '/submit', changeFreq: 'monthly' as const, priority: 0.6 },
|
||||
{ path: '/contact', changeFreq: 'monthly' as const, priority: 0.5 },
|
||||
{ path: '/privacy', changeFreq: 'monthly' as const, priority: 0.3 },
|
||||
{ path: '/terms', changeFreq: 'monthly' as const, priority: 0.3 },
|
||||
@@ -25,21 +36,42 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// 运行时依靠 revalidate 补全动态条目
|
||||
let tools: { slug: string; updatedAt: Date }[] = []
|
||||
let categories: { slug: string; updatedAt: Date }[] = []
|
||||
let useCases: { slug: string; updatedAt: Date }[] = []
|
||||
let audiences: { slug: string; updatedAt: Date }[] = []
|
||||
let collections: { slug: string; updatedAt: Date }[] = []
|
||||
let topTools: { slug: string }[] = []
|
||||
let blogs: Awaited<ReturnType<typeof getAllBlogs>> = []
|
||||
try {
|
||||
;[tools, categories, blogs] = await Promise.all([
|
||||
;[
|
||||
tools,
|
||||
categories,
|
||||
useCases,
|
||||
audiences,
|
||||
collections,
|
||||
topTools,
|
||||
blogs,
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select({ slug: tool.slug, updatedAt: tool.updatedAt })
|
||||
.from(tool)
|
||||
.where(eq(tool.status, 'published')),
|
||||
db.select({ slug: category.slug, updatedAt: category.updatedAt }).from(category),
|
||||
db.select({ slug: useCase.slug, updatedAt: useCase.updatedAt }).from(useCase),
|
||||
db.select({ slug: audience.slug, updatedAt: audience.updatedAt }).from(audience),
|
||||
db
|
||||
.select({ slug: collection.slug, updatedAt: collection.updatedAt })
|
||||
.from(collection)
|
||||
.where(eq(collection.status, 'published')),
|
||||
// Alternatives / Comparison 只为热门工具生成(避免 N² 低质页面,§24)
|
||||
getMostVotedTools(20),
|
||||
getAllBlogs(),
|
||||
])
|
||||
} catch (error) {
|
||||
console.warn('sitemap: database unavailable, falling back to static routes only')
|
||||
}
|
||||
|
||||
const dynamicRoutes = [
|
||||
type SitemapRoute = { path: string; changeFreq: 'daily' | 'weekly' | 'monthly'; priority: number; lastModified?: Date | undefined };
|
||||
const dynamicRoutes: SitemapRoute[] = [
|
||||
...tools.map((t) => ({
|
||||
path: `/tools/${t.slug}`,
|
||||
changeFreq: 'weekly' as const,
|
||||
@@ -52,6 +84,37 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
priority: 0.85,
|
||||
lastModified: c.updatedAt,
|
||||
})),
|
||||
// ===== Programmatic SEO 页面矩阵(§52:8 × N 体系) =====
|
||||
...useCases.map((u) => ({
|
||||
path: `/ai-tools-for/${u.slug}`,
|
||||
changeFreq: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
lastModified: u.updatedAt,
|
||||
})),
|
||||
...audiences.map((a) => ({
|
||||
path: `/ai-tools-for/${a.slug}`,
|
||||
changeFreq: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
lastModified: a.updatedAt,
|
||||
})),
|
||||
...collections.map((col) => ({
|
||||
path: `/best/${col.slug}`,
|
||||
changeFreq: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
lastModified: col.updatedAt,
|
||||
})),
|
||||
// Alternative 页面(Top 20 工具)
|
||||
...topTools.map((t) => ({
|
||||
path: `/tools/${t.slug}-alternatives`,
|
||||
changeFreq: 'weekly' as const,
|
||||
priority: 0.7,
|
||||
})),
|
||||
// Comparison 页面(Top 工具两两配对,每工具只配一个对手,控制页面质量)
|
||||
...topTools.slice(0, 10).map((t, i) => ({
|
||||
path: `/tools/${t.slug}-vs-${topTools[(i + 1) % topTools.length].slug}`,
|
||||
changeFreq: 'weekly' as const,
|
||||
priority: 0.6,
|
||||
})),
|
||||
...blogs.map((b) => ({
|
||||
path: `/blog/${b.slug}`,
|
||||
changeFreq: 'monthly' as const,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimationProps, MotionProps } from "framer-motion";
|
||||
import { MotionProps, Transition } from "framer-motion";
|
||||
import React from "react";
|
||||
|
||||
export const Heading = ({
|
||||
@@ -13,9 +13,9 @@ export const Heading = ({
|
||||
as?: any;
|
||||
children: any;
|
||||
size?: "sm" | "md" | "xl" | "2xl";
|
||||
props?: React.HTMLAttributes<HTMLHeadingElement | AnimationProps>;
|
||||
props?: React.HTMLAttributes<HTMLHeadingElement | Transition>;
|
||||
} & MotionProps &
|
||||
React.HTMLAttributes<HTMLHeadingElement | AnimationProps>) => {
|
||||
React.HTMLAttributes<HTMLHeadingElement | Transition>) => {
|
||||
const sizeVariants = {
|
||||
sm: "text-xl md:text-2xl md:leading-snug",
|
||||
md: "text-3xl md:text-5xl md:leading-tight",
|
||||
|
||||
+153
-41
@@ -64,47 +64,6 @@ export function OrganizationJsonLd({ name, url, logo, sameAs = [] }: Organizatio
|
||||
)
|
||||
}
|
||||
|
||||
interface SoftwareApplicationJsonLdProps {
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
image?: string
|
||||
category?: string
|
||||
applicationCategory?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* SoftwareApplication structured data for individual AI tools
|
||||
* Use this on tool detail pages
|
||||
*/
|
||||
export function SoftwareApplicationJsonLd({
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
image,
|
||||
category,
|
||||
applicationCategory = 'AI Tool',
|
||||
}: SoftwareApplicationJsonLdProps) {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
...(image && { image }),
|
||||
...(category && { category }),
|
||||
applicationCategory,
|
||||
operatingSystem: 'Web',
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface CollectionPageJsonLdProps {
|
||||
name: string
|
||||
description: string
|
||||
@@ -142,3 +101,156 @@ export function CollectionPageJsonLd({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 扩展 Schema(AI Directory Platform) ==========
|
||||
|
||||
interface SoftwareApplicationJsonLdExtendedProps {
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
image?: string
|
||||
category?: string
|
||||
applicationCategory?: string
|
||||
/** 平均评分(0-5),> 0 时输出 AggregateRating */
|
||||
ratingAvg?: number
|
||||
/** 评分数 */
|
||||
ratingCount?: number
|
||||
/** offers 价格信息(可选) */
|
||||
price?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* SoftwareApplicationJsonLd(扩展版)
|
||||
* 说明:带 AggregateRating 的工具结构化数据(新页面使用)。
|
||||
*/
|
||||
export function SoftwareApplicationJsonLd({
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
image,
|
||||
category,
|
||||
applicationCategory = 'AI Tool',
|
||||
ratingAvg,
|
||||
ratingCount,
|
||||
price,
|
||||
}: SoftwareApplicationJsonLdExtendedProps) {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
...(image && { image }),
|
||||
...(category && { category }),
|
||||
applicationCategory,
|
||||
operatingSystem: 'Web',
|
||||
...((ratingAvg ?? 0) > 0 &&
|
||||
(ratingCount ?? 0) > 0 && {
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: ratingAvg,
|
||||
ratingCount: ratingCount,
|
||||
bestRating: 5,
|
||||
worstRating: 1,
|
||||
},
|
||||
}),
|
||||
...(price && {
|
||||
offers: { '@type': 'Offer', price, priceCurrency: 'USD' },
|
||||
}),
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface FaqPageJsonLdProps {
|
||||
faqs: { question: string; answer: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* FaqPageJsonLd
|
||||
* 说明:FAQPage 结构化数据(Tool Detail / Best / Use Case 页共用)。
|
||||
*/
|
||||
export function FaqPageJsonLd({ faqs }: FaqPageJsonLdProps) {
|
||||
if (!faqs || faqs.length === 0) return null
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: { '@type': 'Answer', text: faq.answer },
|
||||
})),
|
||||
}
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface BreadcrumbJsonLdProps {
|
||||
items: { name: string; url: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* BreadcrumbJsonLd
|
||||
* 说明:BreadcrumbList 结构化数据。
|
||||
*/
|
||||
export function BreadcrumbJsonLd({ items }: BreadcrumbJsonLdProps) {
|
||||
if (!items || items.length === 0) return null
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: item.url,
|
||||
})),
|
||||
}
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ItemListJsonLdProps {
|
||||
name: string
|
||||
url: string
|
||||
items: { name: string; url: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemListJsonLd
|
||||
* 说明:ItemList 结构化数据(榜单 / 合集 / 用例页的工具列表)。
|
||||
*/
|
||||
export function ItemListJsonLd({ name, url, items }: ItemListJsonLdProps) {
|
||||
if (!items || items.length === 0) return null
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name,
|
||||
url,
|
||||
numberOfItems: items.length,
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
})),
|
||||
}
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Share2, Link2, Twitter, Linkedin } from "lucide-react";
|
||||
|
||||
/**
|
||||
* # 组件 ShareButton
|
||||
* 说明:分享(Copy Link / X / LinkedIn)。图标全部为 SVG(Lucide)。
|
||||
*/
|
||||
export function ShareButton({ toolName }: { toolName: string }) {
|
||||
const t = useTranslations("tools.share");
|
||||
const locale = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const url = () =>
|
||||
`${window.location.origin}/${locale}/tools/${window.location.pathname
|
||||
.split("/")
|
||||
.pop()}`;
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(url());
|
||||
toast.success(t("copied"));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const popup = (shareUrl: string) => {
|
||||
window.open(shareUrl, "_blank", "noopener,width=600,height=500");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={t("share")}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-border px-4 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-hover"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
{t("share")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
aria-label={t("copyLink")}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2 py-2 text-xs font-medium text-foreground hover:bg-hover"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("copy")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
popup(
|
||||
`https://twitter.com/intent/tweet?text=${encodeURIComponent(
|
||||
`${toolName} — AI tool`
|
||||
)}&url=${encodeURIComponent(url())}`
|
||||
)
|
||||
}
|
||||
aria-label="Share on X"
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2 py-2 text-xs font-medium text-foreground hover:bg-hover"
|
||||
>
|
||||
<Twitter className="h-3.5 w-3.5" />
|
||||
X
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
popup(
|
||||
`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url())}`
|
||||
)
|
||||
}
|
||||
aria-label="Share on LinkedIn"
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2 py-2 text-xs font-medium text-foreground hover:bg-hover"
|
||||
>
|
||||
<Linkedin className="h-3.5 w-3.5" />
|
||||
LinkedIn
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimationProps, MotionProps } from "framer-motion";
|
||||
import { MotionProps, Transition } from "framer-motion";
|
||||
import React from "react";
|
||||
|
||||
export const Subheading = ({
|
||||
@@ -11,9 +11,9 @@ export const Subheading = ({
|
||||
className?: string;
|
||||
as?: any;
|
||||
children: any;
|
||||
props?: React.HTMLAttributes<HTMLHeadingElement | AnimationProps>;
|
||||
props?: React.HTMLAttributes<HTMLHeadingElement | Transition>;
|
||||
} & MotionProps &
|
||||
React.HTMLAttributes<HTMLHeadingElement | AnimationProps>) => {
|
||||
React.HTMLAttributes<HTMLHeadingElement | Transition>) => {
|
||||
return (
|
||||
<Tag
|
||||
className={cn(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Users, Activity } from "lucide-react";
|
||||
import { Users, Activity, Wrench, Inbox, MessageSquare, Vote, BarChart3 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "next-intl";
|
||||
|
||||
@@ -9,6 +9,11 @@ interface AdminDashboardProps {
|
||||
stats: {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
totalTools: number;
|
||||
pendingSubmissions: number;
|
||||
totalReviews: number;
|
||||
totalVotes: number;
|
||||
events30d: number;
|
||||
};
|
||||
recentUsers: {
|
||||
id: string;
|
||||
@@ -38,6 +43,41 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
|
||||
color: "bg-muted",
|
||||
link: `/admin/users`,
|
||||
},
|
||||
{
|
||||
title: "Published Tools",
|
||||
value: stats.totalTools.toLocaleString(),
|
||||
icon: Wrench,
|
||||
color: "bg-muted",
|
||||
link: `/admin/tools`,
|
||||
},
|
||||
{
|
||||
title: "Pending Submissions",
|
||||
value: stats.pendingSubmissions.toLocaleString(),
|
||||
icon: Inbox,
|
||||
color: "bg-muted",
|
||||
link: `/admin/submissions`,
|
||||
},
|
||||
{
|
||||
title: "Reviews",
|
||||
value: stats.totalReviews.toLocaleString(),
|
||||
icon: MessageSquare,
|
||||
color: "bg-muted",
|
||||
link: `/admin/reviews`,
|
||||
},
|
||||
{
|
||||
title: "Votes",
|
||||
value: stats.totalVotes.toLocaleString(),
|
||||
icon: Vote,
|
||||
color: "bg-muted",
|
||||
link: `/admin/votes`,
|
||||
},
|
||||
{
|
||||
title: "Events (30d)",
|
||||
value: stats.events30d.toLocaleString(),
|
||||
icon: BarChart3,
|
||||
color: "bg-muted",
|
||||
link: `/admin/analytics`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -55,7 +95,7 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{statCards.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Users, Home, Wrench, FolderTree, Tags } from "lucide-react";
|
||||
import { Users, Home, Wrench, FolderTree, Tags, Inbox, ShieldQuestion, MessageSquare, Compass, BarChart3, Vote } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const adminNavItems = [
|
||||
@@ -27,6 +27,36 @@ const adminNavItems = [
|
||||
href: "/admin/tags",
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
title: "submissions",
|
||||
href: "/admin/submissions",
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: "claims",
|
||||
href: "/admin/claims",
|
||||
icon: ShieldQuestion,
|
||||
},
|
||||
{
|
||||
title: "reviews",
|
||||
href: "/admin/reviews",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
title: "discovery",
|
||||
href: "/admin/discovery",
|
||||
icon: Compass,
|
||||
},
|
||||
{
|
||||
title: "votes",
|
||||
href: "/admin/votes",
|
||||
icon: Vote,
|
||||
},
|
||||
{
|
||||
title: "analytics",
|
||||
href: "/admin/analytics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
title: "users",
|
||||
href: "/admin/users",
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getToolBySlug } from "@/features/tools/actions";
|
||||
import { getAlternativeTools } from "@/features/discovery/actions";
|
||||
import { pickName } from "@/features/discovery/localize";
|
||||
import { getRatingSummary } from "@/features/reviews/actions";
|
||||
import { getToolReviews } from "@/features/reviews/actions";
|
||||
import { generateSeoPage } from "@/lib/seo/page-generator";
|
||||
import {
|
||||
FaqPageJsonLd,
|
||||
BreadcrumbJsonLd,
|
||||
ItemListJsonLd,
|
||||
} from "@/components/json-ld";
|
||||
import { generatePageMetadata } from "@/lib/metadata";
|
||||
import { ToolCardGrid } from "@/components/tool-card";
|
||||
import { FavoritesProvider } from "@/features/tools/components/favorite-button";
|
||||
import { VotesProvider } from "@/features/voting/components/vote-button";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { ChevronRight, Star, ChevronUp } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
const brandName = () => process.env.NEXT_PUBLIC_APP_NAME || "AI Directory";
|
||||
const baseUrl = () => process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001";
|
||||
|
||||
/**
|
||||
* # 组件 AlternativesView
|
||||
* 说明:/tools/<slug>-alternatives —— Alternative SEO 页(设计文档 §23.5)。
|
||||
* 列出同分类替代工具(按热度排序)+ 对比表 + FAQ。
|
||||
*/
|
||||
export async function AlternativesView({
|
||||
baseSlug,
|
||||
locale,
|
||||
}: {
|
||||
baseSlug: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const base = await getToolBySlug(baseSlug);
|
||||
if (!base) notFound();
|
||||
|
||||
const alternatives = await getAlternativeTools(base.id, 12);
|
||||
const name = locale === "zh" && base.nameZh ? base.nameZh : base.nameEn;
|
||||
|
||||
const seo = generateSeoPage({
|
||||
name: `${name} Alternatives`,
|
||||
kind: "collection",
|
||||
brandName: brandName(),
|
||||
toolCount: alternatives.length,
|
||||
description: base.descriptionEn,
|
||||
topToolNames: alternatives.slice(0, 3).map((a) => a.nameEn),
|
||||
freeCount: alternatives.filter((a) => a.pricing === "free").length,
|
||||
});
|
||||
const url = `${baseUrl()}/${locale}/tools/${baseSlug}-alternatives`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
|
||||
<FaqPageJsonLd faqs={seo.faqs} />
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", url: `${baseUrl()}/${locale}` },
|
||||
{ name: "AI Tools", url: `${baseUrl()}/${locale}/tools` },
|
||||
{ name, url: `${baseUrl()}/${locale}/tools/${baseSlug}` },
|
||||
{ name: `${name} Alternatives`, url },
|
||||
]}
|
||||
/>
|
||||
{alternatives.length > 0 && (
|
||||
<ItemListJsonLd
|
||||
name={seo.h1}
|
||||
url={url}
|
||||
items={alternatives.slice(0, 10).map((tool) => ({
|
||||
name: tool.nameEn,
|
||||
url: `${baseUrl()}/${locale}/tools/${tool.slug}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<nav className="mb-6 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<LocaleLink href="/tools" className="hover:text-foreground">AI Tools</LocaleLink>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<LocaleLink href={`/tools/${baseSlug}`} className="hover:text-foreground">
|
||||
{name}
|
||||
</LocaleLink>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground">Alternatives</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
{seo.h1}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
{seo.intro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{alternatives.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
No alternatives found yet.
|
||||
</p>
|
||||
) : (
|
||||
<FavoritesProvider>
|
||||
<VotesProvider>
|
||||
<ToolCardGrid tools={alternatives} locale={locale} ranked />
|
||||
</VotesProvider>
|
||||
</FavoritesProvider>
|
||||
)}
|
||||
|
||||
{/* 快速对比表 */}
|
||||
{alternatives.length > 0 && (
|
||||
<section className="mt-10 overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-secondary/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-3">Tool</th>
|
||||
<th className="px-4 py-3">Rating</th>
|
||||
<th className="px-4 py-3">Votes</th>
|
||||
<th className="px-4 py-3">Pricing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[base, ...alternatives].map((tool) => (
|
||||
<tr key={tool.id} className="border-b border-border/60 last:border-b-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/${locale}/tools/${tool.slug}`}
|
||||
className="font-medium text-foreground hover:text-primary"
|
||||
>
|
||||
{tool.nameEn === base.nameEn && tool.id === base.id
|
||||
? `${name} (this tool)`
|
||||
: locale === "zh" && tool.nameZh
|
||||
? tool.nameZh
|
||||
: tool.nameEn}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground tabular-nums">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{(tool.ratingAvg ?? 0) > 0 ? (
|
||||
<>
|
||||
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
|
||||
{(tool.ratingAvg ?? 0).toFixed(1)}
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground tabular-nums">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
{tool.voteCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground capitalize">
|
||||
{tool.pricing ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mt-10 rounded-xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{seo.faqs.map((faq, i) => (
|
||||
<div key={i}>
|
||||
<h3 className="text-sm font-semibold text-foreground">{faq.question}</h3>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 ComparisonView
|
||||
* 说明:/tools/<a>-vs-<b> —— Comparison SEO 页(设计文档 §23.4)。
|
||||
* 两工具并排对比:评分/投票/定价/分类 + 最新评论摘录。
|
||||
*/
|
||||
export async function ComparisonView({
|
||||
slugA,
|
||||
slugB,
|
||||
locale,
|
||||
}: {
|
||||
slugA: string;
|
||||
slugB: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const [toolA, toolB] = await Promise.all([
|
||||
getToolBySlug(slugA),
|
||||
getToolBySlug(slugB),
|
||||
]);
|
||||
if (!toolA || !toolB) notFound();
|
||||
|
||||
const [ratingA, ratingB, reviewsA, reviewsB] = await Promise.all([
|
||||
getRatingSummary(toolA.id),
|
||||
getRatingSummary(toolB.id),
|
||||
getToolReviews(toolA.id, 1, 1),
|
||||
getToolReviews(toolB.id, 1, 1),
|
||||
]);
|
||||
|
||||
const nameA = pickName(toolA, locale);
|
||||
const nameB = pickName(toolB, locale);
|
||||
const url = `${baseUrl()}/${locale}/tools/${slugA}-vs-${slugB}`;
|
||||
|
||||
const pricingLabels: Record<string, string> = {
|
||||
free: "Free",
|
||||
freemium: "Freemium",
|
||||
paid: "Paid",
|
||||
free_trial: "Free Trial",
|
||||
open_source: "Open Source",
|
||||
contact: "Contact",
|
||||
};
|
||||
|
||||
const rows: { label: string; a: string; b: string; winner?: "a" | "b" | "tie" }[] = [
|
||||
{
|
||||
label: "Community rating",
|
||||
a: ratingA.total > 0 ? `${ratingA.avg.toFixed(1)} / 5 (${ratingA.total})` : "—",
|
||||
b: ratingB.total > 0 ? `${ratingB.avg.toFixed(1)} / 5 (${ratingB.total})` : "—",
|
||||
winner:
|
||||
ratingA.avg === ratingB.avg ? "tie" : ratingA.avg > ratingB.avg ? "a" : "b",
|
||||
},
|
||||
{
|
||||
label: "Upvotes",
|
||||
a: String(toolA.voteCount),
|
||||
b: String(toolB.voteCount),
|
||||
winner:
|
||||
toolA.voteCount === toolB.voteCount
|
||||
? "tie"
|
||||
: toolA.voteCount > toolB.voteCount
|
||||
? "a"
|
||||
: "b",
|
||||
},
|
||||
{
|
||||
label: "Favorites",
|
||||
a: String(toolA.favoriteCount),
|
||||
b: String(toolB.favoriteCount),
|
||||
winner:
|
||||
toolA.favoriteCount === toolB.favoriteCount
|
||||
? "tie"
|
||||
: toolA.favoriteCount > toolB.favoriteCount
|
||||
? "a"
|
||||
: "b",
|
||||
},
|
||||
{
|
||||
label: "Pricing",
|
||||
a: toolA.pricing ? (pricingLabels[toolA.pricing] ?? toolA.pricing) : "—",
|
||||
b: toolB.pricing ? (pricingLabels[toolB.pricing] ?? toolB.pricing) : "—",
|
||||
winner: "tie",
|
||||
},
|
||||
{
|
||||
label: "Category",
|
||||
a: toolA.categories?.[0]?.nameEn ?? "—",
|
||||
b: toolB.categories?.[0]?.nameEn ?? "—",
|
||||
winner: "tie",
|
||||
},
|
||||
{
|
||||
label: "Latest review",
|
||||
a: reviewsA.reviews[0]?.content?.slice(0, 120) ?? "—",
|
||||
b: reviewsB.reviews[0]?.content?.slice(0, 120) ?? "—",
|
||||
winner: "tie",
|
||||
},
|
||||
];
|
||||
|
||||
const winnerLabel =
|
||||
ratingA.avg !== ratingB.avg
|
||||
? ratingA.avg > ratingB.avg
|
||||
? nameA
|
||||
: nameB
|
||||
: toolA.voteCount !== toolB.voteCount
|
||||
? toolA.voteCount > toolB.voteCount
|
||||
? nameA
|
||||
: nameB
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", url: `${baseUrl()}/${locale}` },
|
||||
{ name: "AI Tools", url: `${baseUrl()}/${locale}/tools` },
|
||||
{ name: `${nameA} vs ${nameB}`, url },
|
||||
]}
|
||||
/>
|
||||
|
||||
<nav className="mb-6 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<LocaleLink href="/tools" className="hover:text-foreground">AI Tools</LocaleLink>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-foreground">
|
||||
{nameA} vs {nameB}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
{nameA} vs {nameB}: which should you use?
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
A side-by-side comparison based on community votes, ratings and
|
||||
reviews — not sponsored placement.
|
||||
{winnerLabel && (
|
||||
<>
|
||||
{" "}Right now the community leans toward{" "}
|
||||
<Link
|
||||
href={`/${locale}/tools/${slugA === toolA.slug ? slugA : slugB}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
{winnerLabel}
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-secondary/40">
|
||||
<th className="px-4 py-3 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Compare
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left">
|
||||
<Link href={`/${locale}/tools/${slugA}`} className="font-semibold text-foreground hover:text-primary">
|
||||
{nameA}
|
||||
</Link>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left">
|
||||
<Link href={`/${locale}/tools/${slugB}`} className="font-semibold text-foreground hover:text-primary">
|
||||
{nameB}
|
||||
</Link>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.label} className="border-b border-border/60 last:border-b-0">
|
||||
<td className="px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{row.label}
|
||||
</td>
|
||||
<td className={`px-4 py-3 ${row.winner === "a" ? "font-semibold text-primary" : "text-muted-foreground"}`}>
|
||||
{row.a}
|
||||
</td>
|
||||
<td className={`px-4 py-3 ${row.winner === "b" ? "font-semibold text-primary" : "text-muted-foreground"}`}>
|
||||
{row.b}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<LocaleLink
|
||||
href={`/tools/${slugA}`}
|
||||
className="rounded-xl border border-border p-5 transition-colors hover:border-primary/40"
|
||||
>
|
||||
<p className="text-sm font-semibold text-foreground">{nameA}</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{toolA.taglineEn || toolA.descriptionEn}
|
||||
</p>
|
||||
</LocaleLink>
|
||||
<LocaleLink
|
||||
href={`/tools/${slugB}`}
|
||||
className="rounded-xl border border-border p-5 transition-colors hover:border-primary/40"
|
||||
>
|
||||
<p className="text-sm font-semibold text-foreground">{nameB}</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{toolB.taglineEn || toolB.descriptionEn}
|
||||
</p>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,12 +52,26 @@ export function LoginForm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
async function handleGoogleSignIn(callbackURL?: string) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/",
|
||||
callbackURL: callbackURL || "/",
|
||||
});
|
||||
} catch (err) {
|
||||
setError(t('errors.googleLoginFailed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGithubSignIn(callbackURL?: string) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await signIn.social({
|
||||
provider: "github",
|
||||
callbackURL: callbackURL || "/",
|
||||
});
|
||||
} catch (err) {
|
||||
setError(t('errors.googleLoginFailed'));
|
||||
@@ -83,7 +97,13 @@ export function LoginForm() {
|
||||
</Link>
|
||||
</p>
|
||||
}
|
||||
socialSlot={<SocialAuthButtons onGoogleSignIn={handleGoogleSignIn} isLoading={isLoading} />}
|
||||
socialSlot={
|
||||
<SocialAuthButtons
|
||||
onGoogleSignIn={handleGoogleSignIn}
|
||||
onGithubSignIn={handleGithubSignIn}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormTextField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,29 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import { IconBrandGoogle } from "@tabler/icons-react";
|
||||
import { IconBrandGoogle, IconBrandGithub } from "@tabler/icons-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/** 点击时读取 ?redirect= 参数(避免 useSearchParams 的 Suspense 要求) */
|
||||
function getCallbackURL(): string {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return new URLSearchParams(window.location.search).get("redirect") || "/";
|
||||
}
|
||||
|
||||
interface SocialAuthButtonsProps {
|
||||
onGoogleSignIn: () => Promise<void> | void;
|
||||
onGoogleSignIn: (callbackURL?: string) => Promise<void> | void;
|
||||
onGithubSignIn?: (callbackURL?: string) => Promise<void> | void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 SocialAuthButtons
|
||||
* 说明:第三方登录按钮组(Google / GitHub)。
|
||||
* 回跳地址取 ?redirect= 参数(投票/评论未登录时跳登录后自动返回原页面)。
|
||||
*/
|
||||
export function SocialAuthButtons({
|
||||
onGoogleSignIn,
|
||||
onGithubSignIn,
|
||||
isLoading,
|
||||
}: SocialAuthButtonsProps) {
|
||||
const t = useTranslations('auth.social');
|
||||
const googleEnabled = process.env.NEXT_PUBLIC_AUTH_GOOGLE_ENABLED !== "false";
|
||||
const githubEnabled = process.env.NEXT_PUBLIC_AUTH_GITHUB_ENABLED === "true";
|
||||
|
||||
const showGoogle = googleEnabled && !!onGoogleSignIn;
|
||||
const showGithub = githubEnabled && !!onGithubSignIn;
|
||||
|
||||
if (!showGoogle && !showGithub) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={onGoogleSignIn}
|
||||
className="w-full py-1.5"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
>
|
||||
<IconBrandGoogle className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold leading-6">{t('googleSignIn')}</span>
|
||||
</Button>
|
||||
<div className={showGoogle && showGithub ? "flex flex-col gap-2" : undefined}>
|
||||
{showGoogle && (
|
||||
<Button
|
||||
onClick={() => onGoogleSignIn(getCallbackURL())}
|
||||
className="w-full py-1.5"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
>
|
||||
<IconBrandGoogle className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold leading-6">{t('googleSignIn')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{showGithub && (
|
||||
<Button
|
||||
onClick={() => onGithubSignIn(getCallbackURL())}
|
||||
className="w-full py-1.5"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold leading-6">{t('githubSignIn')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { claim, tool, user as userTable } from "@/lib/db/schema";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { nanoid } from "nanoid";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { isAdmin } from "@/lib/auth/admin";
|
||||
import { z } from "zod";
|
||||
import { recordEvent } from "@/lib/analytics/record";
|
||||
|
||||
export type ClaimStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
const claimSchema = z.object({
|
||||
toolId: z.string().min(1),
|
||||
role: z.string().max(80).optional(),
|
||||
message: z.string().min(10, "MESSAGE_TOO_SHORT").max(2000),
|
||||
proofUrl: z.string().url().max(500).optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
/** 认领后 owner 可编辑的字段(白名单,防止越权改核心字段) */
|
||||
const OWNER_EDITABLE_FIELDS = z.object({
|
||||
taglineEn: z.string().max(200).optional(),
|
||||
descriptionEn: z.string().max(4000).optional(),
|
||||
websiteUrl: z.string().url().max(500).optional(),
|
||||
logoUrl: z.string().url().max(500).optional(),
|
||||
pricing: z
|
||||
.enum(["free", "freemium", "paid", "free_trial", "contact", "open_source"])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
async function getSessionUser() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
return session?.user ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 createClaim
|
||||
* 说明:用户认领工具(设计文档 §21)。登录必填;pending 唯一索引防重复提交。
|
||||
*/
|
||||
export async function createClaim(
|
||||
input: z.infer<typeof claimSchema>
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { success: false, error: "UNAUTHORIZED" };
|
||||
|
||||
const parsed = claimSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "VALIDATION" };
|
||||
|
||||
const [toolRow] = await db
|
||||
.select({ id: tool.id, claimedByUserId: tool.claimedByUserId })
|
||||
.from(tool)
|
||||
.where(and(eq(tool.id, parsed.data.toolId), eq(tool.status, "published")))
|
||||
.limit(1);
|
||||
if (!toolRow) return { success: false, error: "NOT_FOUND" };
|
||||
if (toolRow.claimedByUserId) return { success: false, error: "ALREADY_CLAIMED" };
|
||||
|
||||
const inserted = await db
|
||||
.insert(claim)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
toolId: parsed.data.toolId,
|
||||
userId: user.id,
|
||||
role: parsed.data.role ?? null,
|
||||
message: parsed.data.message,
|
||||
proofUrl: parsed.data.proofUrl || null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: claim.id });
|
||||
|
||||
if (inserted.length === 0) {
|
||||
return { success: false, error: "PENDING_EXISTS" };
|
||||
}
|
||||
|
||||
await recordEvent({
|
||||
event: "claim_tool",
|
||||
userId: user.id,
|
||||
entityType: "tool",
|
||||
entityId: parsed.data.toolId,
|
||||
});
|
||||
|
||||
revalidatePath("/[locale]/admin/claims", "page");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getMyClaims
|
||||
* 说明:当前用户的认领历史。
|
||||
*/
|
||||
export async function getMyClaims() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return [];
|
||||
return db
|
||||
.select({
|
||||
id: claim.id,
|
||||
status: claim.status,
|
||||
createdAt: claim.createdAt,
|
||||
adminNote: claim.adminNote,
|
||||
toolName: sql<string>`COALESCE(${tool.nameEn}, '')`,
|
||||
toolSlug: tool.slug,
|
||||
})
|
||||
.from(claim)
|
||||
.innerJoin(tool, eq(claim.toolId, tool.id))
|
||||
.where(eq(claim.userId, user.id))
|
||||
.orderBy(desc(claim.createdAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 isToolOwner
|
||||
* 说明:当前用户是否为某工具的认领所有者。
|
||||
*/
|
||||
export async function isToolOwner(toolId: string): Promise<boolean> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return false;
|
||||
const [row] = await db
|
||||
.select({ claimedByUserId: tool.claimedByUserId })
|
||||
.from(tool)
|
||||
.where(eq(tool.id, toolId))
|
||||
.limit(1);
|
||||
return row?.claimedByUserId === user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 ownerUpdateTool
|
||||
* 说明:工具 owner 编辑白名单字段(认领成功后的权益,见设计文档 §21)。
|
||||
*/
|
||||
export async function ownerUpdateTool(
|
||||
toolId: string,
|
||||
input: z.infer<typeof OWNER_EDITABLE_FIELDS>
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { success: false as const, error: "UNAUTHORIZED" };
|
||||
|
||||
const [toolRow] = await db
|
||||
.select({ claimedByUserId: tool.claimedByUserId })
|
||||
.from(tool)
|
||||
.where(eq(tool.id, toolId))
|
||||
.limit(1);
|
||||
if (toolRow?.claimedByUserId !== user.id) {
|
||||
return { success: false as const, error: "FORBIDDEN" };
|
||||
}
|
||||
|
||||
const parsed = OWNER_EDITABLE_FIELDS.safeParse(input);
|
||||
if (!parsed.success) return { success: false as const, error: "VALIDATION" };
|
||||
|
||||
const { logoUrl, websiteUrl, ...rest } = parsed.data;
|
||||
await db
|
||||
.update(tool)
|
||||
.set({
|
||||
...rest,
|
||||
...(logoUrl !== undefined ? { logoUrl: logoUrl || null } : {}),
|
||||
...(websiteUrl !== undefined ? { websiteUrl } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tool.id, toolId));
|
||||
|
||||
revalidatePath("/[locale]/tools/[slug]", "page");
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
// ========== 管理员操作 ==========
|
||||
|
||||
/**
|
||||
* # 函数 getClaimsAdmin
|
||||
* 说明:认领审核队列(管理员)。
|
||||
*/
|
||||
export async function getClaimsAdmin(
|
||||
params: { status?: ClaimStatus; page?: number; pageSize?: number } = {}
|
||||
) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
const { status, page = 1, pageSize = 20 } = params;
|
||||
|
||||
const whereClause = status ? eq(claim.status, status) : undefined;
|
||||
|
||||
const [rows, countRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: claim.id,
|
||||
status: claim.status,
|
||||
role: claim.role,
|
||||
message: claim.message,
|
||||
proofUrl: claim.proofUrl,
|
||||
createdAt: claim.createdAt,
|
||||
toolName: sql<string>`COALESCE(${tool.nameEn}, '')`,
|
||||
toolSlug: tool.slug,
|
||||
userName: sql<string>`COALESCE(${userTable.name}, '')`,
|
||||
})
|
||||
.from(claim)
|
||||
.innerJoin(tool, eq(claim.toolId, tool.id))
|
||||
.innerJoin(userTable, eq(claim.userId, userTable.id))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(claim.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize),
|
||||
db.select({ count: sql<number>`count(*)` }).from(claim).where(whereClause),
|
||||
]);
|
||||
|
||||
return { claims: rows, total: Number(countRows[0]?.count ?? 0), page, pageSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 reviewClaim
|
||||
* 说明:管理员审批认领。批准 → tool.claimedByUserId = 申请人。
|
||||
*/
|
||||
export async function reviewClaim(
|
||||
claimId: string,
|
||||
decision: "approved" | "rejected",
|
||||
adminNote?: string
|
||||
) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
|
||||
const [claimRow] = await db
|
||||
.update(claim)
|
||||
.set({ status: decision, adminNote: adminNote ?? null })
|
||||
.where(and(eq(claim.id, claimId), eq(claim.status, "pending")))
|
||||
.returning({ toolId: claim.toolId, userId: claim.userId });
|
||||
|
||||
if (claimRow && decision === "approved") {
|
||||
await db
|
||||
.update(tool)
|
||||
.set({ claimedByUserId: claimRow.userId })
|
||||
.where(eq(tool.id, claimRow.toolId));
|
||||
revalidatePath("/[locale]/tools/[slug]", "page");
|
||||
}
|
||||
|
||||
revalidatePath("/[locale]/admin/claims", "page");
|
||||
return { success: true as const };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { BadgeCheck, Flag } from "lucide-react";
|
||||
import { createClaim } from "@/features/claim/actions";
|
||||
import { Button } from "@/components/button";
|
||||
|
||||
/**
|
||||
* # 组件 ClaimToolButton
|
||||
* 说明:"认领此工具"入口。已认领工具显示 Owner 徽标。
|
||||
*/
|
||||
export function ClaimToolButton({
|
||||
toolId,
|
||||
claimed,
|
||||
}: {
|
||||
toolId: string;
|
||||
/** 该工具已被认领 */
|
||||
claimed: boolean;
|
||||
}) {
|
||||
const t = useTranslations("claim");
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [role, setRole] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [proofUrl, setProofUrl] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
if (claimed) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-xs font-medium text-primary">
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
{t("verifiedOwner")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (message.trim().length < 10) return;
|
||||
setSubmitting(true);
|
||||
const result = await createClaim({
|
||||
toolId,
|
||||
role: role || undefined,
|
||||
message,
|
||||
proofUrl: proofUrl || undefined,
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (result.success) {
|
||||
toast.success(t("submitted"));
|
||||
setOpen(false);
|
||||
} else if (result.error === "UNAUTHORIZED") {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
} else if (result.error === "PENDING_EXISTS") {
|
||||
toast.info(t("pendingExists"));
|
||||
setOpen(false);
|
||||
} else {
|
||||
toast.error(t("error"));
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" />
|
||||
{t("claimThisTool")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-border p-4">
|
||||
<p className="text-sm font-medium text-foreground">{t("formTitle")}</p>
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={t("rolePlaceholder")}
|
||||
value={role}
|
||||
maxLength={80}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={inputClass}
|
||||
rows={3}
|
||||
placeholder={t("messagePlaceholder")}
|
||||
value={message}
|
||||
maxLength={2000}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={t("proofPlaceholder")}
|
||||
value={proofUrl}
|
||||
onChange={(e) => setProofUrl(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" onClick={submit} disabled={submitting}>
|
||||
{submitting ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="simple" onClick={() => setOpen(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { reviewClaim } from "@/features/claim/actions";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ClaimRow {
|
||||
id: string;
|
||||
status: string;
|
||||
role: string | null;
|
||||
message: string | null;
|
||||
proofUrl: string | null;
|
||||
createdAt: Date;
|
||||
toolName: string;
|
||||
toolSlug: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 ClaimsTable
|
||||
* 说明:认领审核队列。批准 → 该用户成为工具 Owner。
|
||||
*/
|
||||
export function ClaimsTable({ claims }: { claims: ClaimRow[] }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const review = (id: string, decision: "approved" | "rejected") => {
|
||||
startTransition(async () => {
|
||||
const result = await reviewClaim(id, decision);
|
||||
if (result.success) {
|
||||
toast.success(decision === "approved" ? "Claim approved" : "Claim rejected");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("Failed");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (claims.length === 0) {
|
||||
return (
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
No claims in the queue.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{claims.map((claimRow) => (
|
||||
<div key={claimRow.id} className="rounded-xl border border-border p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-foreground">{claimRow.userName}</span>
|
||||
<span className="text-xs text-muted-foreground">claims</span>
|
||||
<span className="font-semibold text-foreground">{claimRow.toolName}</span>
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
||||
{claimRow.status}
|
||||
</span>
|
||||
</div>
|
||||
{claimRow.role && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Role: {claimRow.role}</p>
|
||||
)}
|
||||
{claimRow.message && (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{claimRow.message}</p>
|
||||
)}
|
||||
{claimRow.proofUrl && (
|
||||
<a
|
||||
href={claimRow.proofUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-block text-xs text-primary hover:underline"
|
||||
>
|
||||
Proof: {claimRow.proofUrl}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{claimRow.status === "pending" && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => review(claimRow.id, "approved")}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => review(claimRow.id, "rejected")}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-red-500 disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
useCase,
|
||||
audience,
|
||||
collection,
|
||||
collectionTool,
|
||||
toolUseCase,
|
||||
toolAudience,
|
||||
tool,
|
||||
toolCategory,
|
||||
category,
|
||||
} from "@/lib/db/schema";
|
||||
import { and, desc, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import { isAdmin } from "@/lib/auth/admin";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { nanoid } from "nanoid";
|
||||
import { slugify } from "@/lib/ranking/score";
|
||||
import { z } from "zod";
|
||||
import type { LocalizedEntity } from "./localize";
|
||||
|
||||
// ========== 本地化辅助(见 ./localize.ts) ==========
|
||||
// ========== Use Case ==========
|
||||
|
||||
/**
|
||||
* # 函数 getUseCases
|
||||
* 说明:全部用例(带已发布工具数),按 sortOrder 排序。发现页 / 筛选器共用。
|
||||
*/
|
||||
export async function getUseCases(): Promise<
|
||||
(LocalizedEntity & { toolCount: number })[]
|
||||
> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: useCase.id,
|
||||
slug: useCase.slug,
|
||||
icon: useCase.icon,
|
||||
nameEn: useCase.nameEn,
|
||||
nameZh: useCase.nameZh,
|
||||
descriptionEn: useCase.descriptionEn,
|
||||
descriptionZh: useCase.descriptionZh,
|
||||
sortOrder: useCase.sortOrder,
|
||||
toolCount: sql<number>`count(${toolUseCase.toolId})`,
|
||||
})
|
||||
.from(useCase)
|
||||
.leftJoin(
|
||||
toolUseCase,
|
||||
eq(toolUseCase.useCaseId, useCase.id)
|
||||
)
|
||||
.leftJoin(tool, and(eq(tool.id, toolUseCase.toolId), eq(tool.status, "published")))
|
||||
.groupBy(useCase.id)
|
||||
.orderBy(useCase.sortOrder);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
slug: r.slug,
|
||||
icon: r.icon,
|
||||
nameEn: r.nameEn,
|
||||
nameZh: r.nameZh,
|
||||
descriptionEn: r.descriptionEn,
|
||||
descriptionZh: r.descriptionZh,
|
||||
toolCount: Number(r.toolCount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getUseCaseBySlug
|
||||
*/
|
||||
export async function getUseCaseBySlug(slug: string) {
|
||||
const [row] = await db.select().from(useCase).where(eq(useCase.slug, slug)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// ========== Audience ==========
|
||||
|
||||
/**
|
||||
* # 函数 getAudiences
|
||||
* 说明:全部受众(带工具数)。
|
||||
*/
|
||||
export async function getAudiences(): Promise<
|
||||
(LocalizedEntity & { toolCount: number })[]
|
||||
> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: audience.id,
|
||||
slug: audience.slug,
|
||||
icon: audience.icon,
|
||||
nameEn: audience.nameEn,
|
||||
nameZh: audience.nameZh,
|
||||
descriptionEn: audience.descriptionEn,
|
||||
descriptionZh: audience.descriptionZh,
|
||||
sortOrder: audience.sortOrder,
|
||||
toolCount: sql<number>`count(${toolAudience.toolId})`,
|
||||
})
|
||||
.from(audience)
|
||||
.leftJoin(toolAudience, eq(toolAudience.audienceId, audience.id))
|
||||
.leftJoin(tool, and(eq(tool.id, toolAudience.toolId), eq(tool.status, "published")))
|
||||
.groupBy(audience.id)
|
||||
.orderBy(audience.sortOrder);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
slug: r.slug,
|
||||
icon: r.icon,
|
||||
nameEn: r.nameEn,
|
||||
nameZh: r.nameZh,
|
||||
descriptionEn: r.descriptionEn,
|
||||
descriptionZh: r.descriptionZh,
|
||||
toolCount: Number(r.toolCount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getAudienceBySlug
|
||||
*/
|
||||
export async function getAudienceBySlug(slug: string) {
|
||||
const [row] = await db.select().from(audience).where(eq(audience.slug, slug)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// ========== Collection(Best Lists) ==========
|
||||
|
||||
/**
|
||||
* # 函数 getCollections
|
||||
* 说明:已发布合集列表。
|
||||
*/
|
||||
export async function getCollections() {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: collection.id,
|
||||
slug: collection.slug,
|
||||
icon: collection.icon,
|
||||
nameEn: collection.nameEn,
|
||||
nameZh: collection.nameZh,
|
||||
introEn: collection.introEn,
|
||||
introZh: collection.introZh,
|
||||
sortOrder: collection.sortOrder,
|
||||
toolCount: sql<number>`count(${collectionTool.toolId})`,
|
||||
})
|
||||
.from(collection)
|
||||
.leftJoin(collectionTool, eq(collectionTool.collectionId, collection.id))
|
||||
.where(eq(collection.status, "published"))
|
||||
.groupBy(collection.id)
|
||||
.orderBy(collection.sortOrder);
|
||||
return rows.map((r) => ({ ...r, toolCount: Number(r.toolCount) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getCollectionBySlug
|
||||
*/
|
||||
export async function getCollectionBySlug(slug: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(collection)
|
||||
.where(and(eq(collection.slug, slug), eq(collection.status, "published")))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getCollectionTools
|
||||
* 说明:合集内的工具(按策展顺序)。
|
||||
*/
|
||||
export async function getCollectionTools(collectionId: string) {
|
||||
const rows = await db
|
||||
.select({ tool, note: collectionTool.note, sortOrder: collectionTool.sortOrder })
|
||||
.from(collectionTool)
|
||||
.innerJoin(tool, eq(collectionTool.toolId, tool.id))
|
||||
.where(
|
||||
and(eq(collectionTool.collectionId, collectionId), eq(tool.status, "published"))
|
||||
)
|
||||
.orderBy(collectionTool.sortOrder);
|
||||
return rows.map((r) => ({ ...r.tool, note: r.note }));
|
||||
}
|
||||
|
||||
// ========== 工具关系查询 ==========
|
||||
|
||||
/**
|
||||
* # 函数 getToolsForUseCase / getToolsForAudience
|
||||
* 说明:维度 → 工具列表(按互动热度排序)。
|
||||
*/
|
||||
export async function getToolsForUseCase(useCaseId: string, limit = 50) {
|
||||
return db
|
||||
.select({ tool })
|
||||
.from(toolUseCase)
|
||||
.innerJoin(tool, eq(toolUseCase.toolId, tool.id))
|
||||
.where(and(eq(toolUseCase.useCaseId, useCaseId), eq(tool.status, "published")))
|
||||
.orderBy(
|
||||
desc(sql`${tool.voteCount} * 2 + ${tool.favoriteCount} * 1.5 + ${tool.reviewCount} * 3 + ${tool.viewCount} * 0.1`)
|
||||
)
|
||||
.limit(limit)
|
||||
.then((rows) => rows.map((r) => r.tool));
|
||||
}
|
||||
|
||||
export async function getToolsForAudience(audienceId: string, limit = 50) {
|
||||
return db
|
||||
.select({ tool })
|
||||
.from(toolAudience)
|
||||
.innerJoin(tool, eq(toolAudience.toolId, tool.id))
|
||||
.where(and(eq(toolAudience.audienceId, audienceId), eq(tool.status, "published")))
|
||||
.orderBy(
|
||||
desc(sql`${tool.voteCount} * 2 + ${tool.favoriteCount} * 1.5 + ${tool.reviewCount} * 3 + ${tool.viewCount} * 0.1`)
|
||||
)
|
||||
.limit(limit)
|
||||
.then((rows) => rows.map((r) => r.tool));
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getToolRelations
|
||||
* 说明:工具的 use case / audience 实体(详情页内链)。
|
||||
*/
|
||||
export async function getToolDiscoveryRelations(toolId: string) {
|
||||
const [ucs, auds] = await Promise.all([
|
||||
db
|
||||
.select({ useCase })
|
||||
.from(toolUseCase)
|
||||
.innerJoin(useCase, eq(toolUseCase.useCaseId, useCase.id))
|
||||
.where(eq(toolUseCase.toolId, toolId)),
|
||||
db
|
||||
.select({ audience })
|
||||
.from(toolAudience)
|
||||
.innerJoin(audience, eq(toolAudience.audienceId, audience.id))
|
||||
.where(eq(toolAudience.toolId, toolId)),
|
||||
]);
|
||||
return {
|
||||
useCases: ucs.map((r) => r.useCase),
|
||||
audiences: auds.map((r) => r.audience),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getAlternativeTools
|
||||
* 说明:替代品推荐 —— 同分类、排除自身,按互动热度排序。
|
||||
* Alternatives SEO 页 + 详情页"Alternatives"区块共用。
|
||||
*/
|
||||
export async function getAlternativeTools(
|
||||
toolId: string,
|
||||
limit = 8
|
||||
): Promise<(typeof tool.$inferSelect)[]> {
|
||||
const categoryRows = await db
|
||||
.select({ categoryId: toolCategory.categoryId })
|
||||
.from(toolCategory)
|
||||
.where(eq(toolCategory.toolId, toolId));
|
||||
const categoryIds = categoryRows.map((r) => r.categoryId);
|
||||
|
||||
if (categoryIds.length === 0) {
|
||||
// 无分类时退化为同定价的热门工具
|
||||
const [self] = await db.select().from(tool).where(eq(tool.id, toolId)).limit(1);
|
||||
if (!self) return [];
|
||||
return db
|
||||
.select()
|
||||
.from(tool)
|
||||
.where(
|
||||
and(
|
||||
eq(tool.status, "published"),
|
||||
ne(tool.id, toolId),
|
||||
self.pricing ? eq(tool.pricing, self.pricing) : sql`true`
|
||||
)
|
||||
)
|
||||
.orderBy(desc(tool.voteCount))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
return db
|
||||
.select({ tool })
|
||||
.from(toolCategory)
|
||||
.innerJoin(tool, eq(toolCategory.toolId, tool.id))
|
||||
.where(
|
||||
and(
|
||||
eq(tool.status, "published"),
|
||||
ne(tool.id, toolId),
|
||||
inArray(toolCategory.categoryId, categoryIds)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(sql`${tool.voteCount} * 2 + ${tool.favoriteCount} * 1.5`))
|
||||
.limit(limit * 2)
|
||||
.then((rows) => {
|
||||
// 同分类多对多会产生重复行,去重后截断
|
||||
const seen = new Set<string>();
|
||||
const unique: (typeof tool.$inferSelect)[] = [];
|
||||
for (const row of rows) {
|
||||
if (seen.has(row.tool.id)) continue;
|
||||
seen.add(row.tool.id);
|
||||
unique.push(row.tool);
|
||||
if (unique.length >= limit) break;
|
||||
}
|
||||
return unique;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getToolWithCategories
|
||||
* 说明:取工具 + 主分类(对比页 / alternatives 用)。
|
||||
*/
|
||||
export async function getToolsByIds(ids: string[]) {
|
||||
if (ids.length === 0) return [];
|
||||
return db.select().from(tool).where(inArray(tool.id, ids));
|
||||
}
|
||||
|
||||
export async function getCategoriesForTools() {
|
||||
return db.select().from(category).orderBy(category.sortOrder);
|
||||
}
|
||||
|
||||
// ========== 管理员 CRUD(Use Case / Audience / Collection) ==========
|
||||
|
||||
const localizedInput = z.object({
|
||||
nameEn: z.string().min(1).max(80),
|
||||
nameZh: z.string().max(80).optional(),
|
||||
descriptionEn: z.string().max(500).optional(),
|
||||
descriptionZh: z.string().max(500).optional(),
|
||||
icon: z.string().max(50).optional(),
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* # 函数 createUseCase
|
||||
*/
|
||||
export async function createUseCase(input: z.infer<typeof localizedInput>) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
const id = nanoid();
|
||||
await db.insert(useCase).values({
|
||||
id,
|
||||
slug: slugify(input.nameEn) || id,
|
||||
nameEn: input.nameEn,
|
||||
nameZh: input.nameZh ?? null,
|
||||
descriptionEn: input.descriptionEn ?? null,
|
||||
descriptionZh: input.descriptionZh ?? null,
|
||||
icon: input.icon ?? null,
|
||||
});
|
||||
revalidateDiscoveryPaths();
|
||||
return { success: true, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 createAudience
|
||||
*/
|
||||
export async function createAudience(input: z.infer<typeof localizedInput>) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
const id = nanoid();
|
||||
await db.insert(audience).values({
|
||||
id,
|
||||
slug: slugify(input.nameEn) || id,
|
||||
nameEn: input.nameEn,
|
||||
nameZh: input.nameZh ?? null,
|
||||
descriptionEn: input.descriptionEn ?? null,
|
||||
descriptionZh: input.descriptionZh ?? null,
|
||||
icon: input.icon ?? null,
|
||||
});
|
||||
revalidateDiscoveryPaths();
|
||||
return { success: true, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 createCollection
|
||||
* 说明:创建合集;toolSlugs 为逗号分隔的工具 slug(按顺序作为策展排序)。
|
||||
*/
|
||||
export async function createCollection(
|
||||
input: z.infer<typeof localizedInput> & { introEn?: string; toolSlugs?: string }
|
||||
) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
const id = nanoid();
|
||||
await db.insert(collection).values({
|
||||
id,
|
||||
slug: slugify(input.nameEn) || id,
|
||||
nameEn: input.nameEn,
|
||||
nameZh: input.nameZh ?? null,
|
||||
introEn: input.introEn ?? null,
|
||||
icon: input.icon ?? null,
|
||||
});
|
||||
|
||||
await attachToolsToCollection(id, input.toolSlugs ?? "");
|
||||
revalidateDiscoveryPaths();
|
||||
return { success: true, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 attachToolsToCollection
|
||||
* 说明:按 slug 列表重建合集的工具关联。
|
||||
*/
|
||||
export async function attachToolsToCollection(
|
||||
collectionId: string,
|
||||
toolSlugs: string
|
||||
) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
|
||||
await db.delete(collectionTool).where(eq(collectionTool.collectionId, collectionId));
|
||||
|
||||
const slugs = toolSlugs
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (slugs.length === 0) return { success: true, attached: 0 };
|
||||
|
||||
const toolRows = await db
|
||||
.select({ id: tool.id, slug: tool.slug })
|
||||
.from(tool)
|
||||
.where(inArray(tool.slug, slugs));
|
||||
|
||||
const bySlug = new Map(toolRows.map((t) => [t.slug, t.id]));
|
||||
const values = slugs
|
||||
.map((slug, index) => ({ slug, id: bySlug.get(slug), order: index }))
|
||||
.filter((v): v is { slug: string; id: string; order: number } => !!v.id)
|
||||
.map((v) => ({
|
||||
collectionId,
|
||||
toolId: v.id,
|
||||
sortOrder: v.order,
|
||||
}));
|
||||
|
||||
if (values.length > 0) {
|
||||
await db.insert(collectionTool).values(values).onConflictDoNothing();
|
||||
}
|
||||
revalidateDiscoveryPaths();
|
||||
return { success: true, attached: values.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 deleteDiscoveryEntity
|
||||
* 说明:管理员删除 use case / audience / collection。
|
||||
*/
|
||||
export async function deleteDiscoveryEntity(
|
||||
kind: "useCase" | "audience" | "collection",
|
||||
id: string
|
||||
) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
if (kind === "useCase") await db.delete(useCase).where(eq(useCase.id, id));
|
||||
if (kind === "audience") await db.delete(audience).where(eq(audience.id, id));
|
||||
if (kind === "collection") await db.delete(collection).where(eq(collection.id, id));
|
||||
revalidateDiscoveryPaths();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function revalidateDiscoveryPaths() {
|
||||
revalidatePath("/[locale]/ai-tools-for", "page");
|
||||
revalidatePath("/[locale]/ai-tools-for/[slug]", "page");
|
||||
revalidatePath("/[locale]/best", "page");
|
||||
revalidatePath("/[locale]/best/[slug]", "page");
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EntityRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CollectionRow extends EntityRow {
|
||||
toolCount: number;
|
||||
}
|
||||
|
||||
interface DiscoveryManagerProps {
|
||||
useCases: EntityRow[];
|
||||
audiences: EntityRow[];
|
||||
collections: CollectionRow[];
|
||||
onCreateUseCase: (input: {
|
||||
nameEn: string;
|
||||
descriptionEn?: string;
|
||||
}) => Promise<{ success: boolean }>;
|
||||
onCreateAudience: (input: {
|
||||
nameEn: string;
|
||||
descriptionEn?: string;
|
||||
}) => Promise<{ success: boolean }>;
|
||||
onCreateCollection: (input: {
|
||||
nameEn: string;
|
||||
introEn?: string;
|
||||
toolSlugs?: string;
|
||||
}) => Promise<{ success: boolean }>;
|
||||
onDelete: (
|
||||
kind: "useCase" | "audience" | "collection",
|
||||
id: string
|
||||
) => Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
type Tab = "useCases" | "audiences" | "collections";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
|
||||
/**
|
||||
* # 组件 DiscoveryManager
|
||||
* 说明:管理员三维发现体系管理(Use Case / Audience / Collection 的创建与删除)。
|
||||
*/
|
||||
export function DiscoveryManager({
|
||||
useCases,
|
||||
audiences,
|
||||
collections,
|
||||
onCreateUseCase,
|
||||
onCreateAudience,
|
||||
onCreateCollection,
|
||||
onDelete,
|
||||
}: DiscoveryManagerProps) {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<Tab>("useCases");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Use case / audience 表单
|
||||
const [ucName, setUcName] = useState("");
|
||||
const [ucDesc, setUcDesc] = useState("");
|
||||
const [audName, setAudName] = useState("");
|
||||
const [audDesc, setAudDesc] = useState("");
|
||||
|
||||
// Collection 表单
|
||||
const [colName, setColName] = useState("");
|
||||
const [colIntro, setColIntro] = useState("");
|
||||
const [colTools, setColTools] = useState("");
|
||||
|
||||
const submitUseCase = () => {
|
||||
if (!ucName.trim()) return;
|
||||
startTransition(async () => {
|
||||
const result = await onCreateUseCase({
|
||||
nameEn: ucName.trim(),
|
||||
descriptionEn: ucDesc.trim() || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success("Use case created");
|
||||
setUcName("");
|
||||
setUcDesc("");
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submitAudience = () => {
|
||||
if (!audName.trim()) return;
|
||||
startTransition(async () => {
|
||||
const result = await onCreateAudience({
|
||||
nameEn: audName.trim(),
|
||||
descriptionEn: audDesc.trim() || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success("Audience created");
|
||||
setAudName("");
|
||||
setAudDesc("");
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submitCollection = () => {
|
||||
if (!colName.trim()) return;
|
||||
startTransition(async () => {
|
||||
const result = await onCreateCollection({
|
||||
nameEn: colName.trim(),
|
||||
introEn: colIntro.trim() || undefined,
|
||||
toolSlugs: colTools.trim() || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success("Collection created");
|
||||
setColName("");
|
||||
setColIntro("");
|
||||
setColTools("");
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (kind: "useCase" | "audience" | "collection", id: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await onDelete(kind, id);
|
||||
if (result.success) {
|
||||
toast.success("Deleted");
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const tabs: { id: Tab; label: string; count: number }[] = [
|
||||
{ id: "useCases", label: "Use Cases", count: useCases.length },
|
||||
{ id: "audiences", label: "Audiences", count: audiences.length },
|
||||
{ id: "collections", label: "Collections", count: collections.length },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2">
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setTab(item.id)}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
|
||||
tab === item.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
)}
|
||||
>
|
||||
{item.label} ({item.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "useCases" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2 rounded-xl border border-border p-4">
|
||||
<input
|
||||
className={cn(inputClass, "flex-1 min-w-[180px]")}
|
||||
placeholder="Use case name (e.g. Video Editing)"
|
||||
value={ucName}
|
||||
onChange={(e) => setUcName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={cn(inputClass, "flex-1 min-w-[220px]")}
|
||||
placeholder="Short description"
|
||||
value={ucDesc}
|
||||
onChange={(e) => setUcDesc(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitUseCase}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{useCases.map((uc) => (
|
||||
<div
|
||||
key={uc.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">{uc.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">/{uc.slug}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove("useCase", uc.id)}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground hover:text-red-500"
|
||||
aria-label={`Delete ${uc.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "audiences" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2 rounded-xl border border-border p-4">
|
||||
<input
|
||||
className={cn(inputClass, "flex-1 min-w-[180px]")}
|
||||
placeholder="Audience name (e.g. Developers)"
|
||||
value={audName}
|
||||
onChange={(e) => setAudName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={cn(inputClass, "flex-1 min-w-[220px]")}
|
||||
placeholder="Short description"
|
||||
value={audDesc}
|
||||
onChange={(e) => setAudDesc(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitAudience}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{audiences.map((aud) => (
|
||||
<div
|
||||
key={aud.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">{aud.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">/{aud.slug}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove("audience", aud.id)}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground hover:text-red-500"
|
||||
aria-label={`Delete ${aud.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "collections" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 rounded-xl border border-border p-4">
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder="Collection name (e.g. Best AI Writing Tools)"
|
||||
value={colName}
|
||||
onChange={(e) => setColName(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={inputClass}
|
||||
rows={2}
|
||||
placeholder="Intro paragraph"
|
||||
value={colIntro}
|
||||
onChange={(e) => setColIntro(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder="Tool slugs, comma separated (ordered): chatgpt, claude, gemini"
|
||||
value={colTools}
|
||||
onChange={(e) => setColTools(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitCollection}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create collection
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{collections.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">{col.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
/best/{col.slug} · {col.toolCount} tools
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove("collection", col.id)}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground hover:text-red-500"
|
||||
aria-label={`Delete ${col.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// ========== 本地化辅助(纯函数,非 Server Action) ==========
|
||||
|
||||
export interface LocalizedEntity {
|
||||
id: string;
|
||||
slug: string;
|
||||
icon: string | null;
|
||||
nameEn: string;
|
||||
nameZh: string | null;
|
||||
descriptionEn: string | null;
|
||||
descriptionZh: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 pickName
|
||||
* 说明:按语言取实体名称(zh 优先中文名,回退英文)。
|
||||
*/
|
||||
export function pickName(
|
||||
entity: { nameEn: string; nameZh: string | null },
|
||||
locale: string
|
||||
): string {
|
||||
return locale === "zh" && entity.nameZh ? entity.nameZh : entity.nameEn;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 pickDescription
|
||||
* 说明:按语言取实体描述。
|
||||
*/
|
||||
export function pickDescription(
|
||||
entity: { descriptionEn: string | null; descriptionZh: string | null },
|
||||
locale: string
|
||||
): string | null {
|
||||
return locale === "zh" && entity.descriptionZh
|
||||
? entity.descriptionZh
|
||||
: entity.descriptionEn;
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { review, reviewHelpful, tool, user as userTable } from "@/lib/db/schema";
|
||||
import { and, desc, eq, sql, inArray } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { nanoid } from "nanoid";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { isAdmin } from "@/lib/auth/admin";
|
||||
import { recordEvent } from "@/lib/analytics/record";
|
||||
import { z } from "zod";
|
||||
|
||||
// ========== 输入校验 ==========
|
||||
const reviewInputSchema = z.object({
|
||||
toolId: z.string().min(1),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
title: z.string().max(120).optional(),
|
||||
content: z.string().min(10, "REVIEW_TOO_SHORT").max(4000),
|
||||
pros: z.string().max(1000).optional(),
|
||||
cons: z.string().max(1000).optional(),
|
||||
useCase: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export type ReviewInput = z.infer<typeof reviewInputSchema>;
|
||||
|
||||
export type ReviewStatus = "published" | "hidden";
|
||||
|
||||
async function getSessionUser() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
return session?.user ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 recalcToolRating
|
||||
* 说明:重算工具的聚合评分(reviewCount / ratingAvg),写入 tool 冗余列。
|
||||
* 仅统计 status=published 的评论。
|
||||
* 参数:toolId 工具 ID
|
||||
* 返回值:{ count, avg }
|
||||
*/
|
||||
export async function recalcToolRating(toolId: string) {
|
||||
const [agg] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)`,
|
||||
avg: sql<number>`COALESCE(AVG(${review.rating}), 0)`,
|
||||
})
|
||||
.from(review)
|
||||
.where(and(eq(review.toolId, toolId), eq(review.status, "published")));
|
||||
|
||||
const count = Number(agg?.count ?? 0);
|
||||
const avg = Math.round(Number(agg?.avg ?? 0) * 100) / 100;
|
||||
|
||||
await db
|
||||
.update(tool)
|
||||
.set({ reviewCount: count, ratingAvg: avg })
|
||||
.where(eq(tool.id, toolId));
|
||||
|
||||
return { count, avg };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 submitReview
|
||||
* 说明:创建或更新当前用户对某工具的评论(每用户每工具一条)。
|
||||
* 写入后重算工具聚合评分并埋点。
|
||||
*/
|
||||
export async function submitReview(
|
||||
input: ReviewInput
|
||||
): Promise<
|
||||
| { success: true; reviewId: string }
|
||||
| { success: false; error: string; fieldErrors?: Record<string, string> }
|
||||
> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { success: false, error: "UNAUTHORIZED" };
|
||||
|
||||
const parsed = reviewInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
for (const issue of parsed.error.issues) {
|
||||
fieldErrors[issue.path.join(".")] = issue.message;
|
||||
}
|
||||
return { success: false, error: "VALIDATION", fieldErrors };
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const [toolRow] = await db
|
||||
.select({ id: tool.id })
|
||||
.from(tool)
|
||||
.where(and(eq(tool.id, data.toolId), eq(tool.status, "published")))
|
||||
.limit(1);
|
||||
if (!toolRow) return { success: false, error: "NOT_FOUND" };
|
||||
|
||||
const existing = await db
|
||||
.select({ id: review.id })
|
||||
.from(review)
|
||||
.where(and(eq(review.userId, user.id), eq(review.toolId, data.toolId)))
|
||||
.limit(1);
|
||||
|
||||
let reviewId: string;
|
||||
if (existing[0]) {
|
||||
reviewId = existing[0].id;
|
||||
await db
|
||||
.update(review)
|
||||
.set({
|
||||
rating: data.rating,
|
||||
title: data.title ?? null,
|
||||
content: data.content,
|
||||
pros: data.pros ?? null,
|
||||
cons: data.cons ?? null,
|
||||
useCase: data.useCase ?? null,
|
||||
status: "published",
|
||||
})
|
||||
.where(eq(review.id, reviewId));
|
||||
} else {
|
||||
reviewId = nanoid();
|
||||
await db.insert(review).values({
|
||||
id: reviewId,
|
||||
userId: user.id,
|
||||
toolId: data.toolId,
|
||||
rating: data.rating,
|
||||
title: data.title ?? null,
|
||||
content: data.content,
|
||||
pros: data.pros ?? null,
|
||||
cons: data.cons ?? null,
|
||||
useCase: data.useCase ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
await recalcToolRating(data.toolId);
|
||||
await recordEvent({
|
||||
event: "tool_review",
|
||||
userId: user.id,
|
||||
entityType: "tool",
|
||||
entityId: data.toolId,
|
||||
metadata: { rating: data.rating },
|
||||
});
|
||||
|
||||
revalidatePath("/[locale]/tools/[slug]", "page");
|
||||
return { success: true, reviewId };
|
||||
}
|
||||
|
||||
/** 评论列表条目(含作者信息) */
|
||||
export interface ReviewListItem {
|
||||
id: string;
|
||||
rating: number;
|
||||
title: string | null;
|
||||
content: string;
|
||||
pros: string | null;
|
||||
cons: string | null;
|
||||
useCase: string | null;
|
||||
helpfulCount: number;
|
||||
createdAt: Date;
|
||||
authorName: string;
|
||||
authorImage: string | null;
|
||||
authorEmailVerified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getToolReviews
|
||||
* 说明:获取某工具的已发布评论(分页,含作者昵称/头像/认证状态)。
|
||||
*/
|
||||
export async function getToolReviews(
|
||||
toolId: string,
|
||||
page = 1,
|
||||
pageSize = 10
|
||||
): Promise<{ reviews: ReviewListItem[]; total: number }> {
|
||||
const conditions = [eq(review.toolId, toolId), eq(review.status, "published")];
|
||||
|
||||
const [rows, countRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: review.id,
|
||||
rating: review.rating,
|
||||
title: review.title,
|
||||
content: review.content,
|
||||
pros: review.pros,
|
||||
cons: review.cons,
|
||||
useCase: review.useCase,
|
||||
helpfulCount: review.helpfulCount,
|
||||
createdAt: review.createdAt,
|
||||
authorName: sql<string>`COALESCE(${userTable.name}, 'Anonymous')`,
|
||||
authorImage: sql<string | null>`${userTable.image}`,
|
||||
authorEmailVerified: sql<boolean>`COALESCE(${userTable.emailVerified}, false)`,
|
||||
})
|
||||
.from(review)
|
||||
.innerJoin(userTable, eq(review.userId, userTable.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(review.helpfulCount), desc(review.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(review)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
return {
|
||||
reviews: rows as ReviewListItem[],
|
||||
total: Number(countRows[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getRatingSummary
|
||||
* 说明:评分分布与均值(详情页 Rating Summary 用)。
|
||||
*/
|
||||
export async function getRatingSummary(toolId: string) {
|
||||
const rows = await db
|
||||
.select({ rating: review.rating, cnt: sql<number>`count(*)` })
|
||||
.from(review)
|
||||
.where(and(eq(review.toolId, toolId), eq(review.status, "published")))
|
||||
.groupBy(review.rating);
|
||||
|
||||
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
||||
let total = 0;
|
||||
let sum = 0;
|
||||
for (const row of rows) {
|
||||
const c = Number(row.cnt);
|
||||
distribution[row.rating] = c;
|
||||
total += c;
|
||||
sum += c * row.rating;
|
||||
}
|
||||
return {
|
||||
distribution,
|
||||
total,
|
||||
avg: total > 0 ? Math.round((sum / total) * 100) / 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getUserReview
|
||||
* 说明:当前用户对某工具的已有评论(编辑表单回显)。
|
||||
*/
|
||||
export async function getUserReview(
|
||||
toolId: string
|
||||
): Promise<ReviewListItem | null> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return null;
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: review.id,
|
||||
rating: review.rating,
|
||||
title: review.title,
|
||||
content: review.content,
|
||||
pros: review.pros,
|
||||
cons: review.cons,
|
||||
useCase: review.useCase,
|
||||
helpfulCount: review.helpfulCount,
|
||||
createdAt: review.createdAt,
|
||||
})
|
||||
.from(review)
|
||||
.where(and(eq(review.userId, user.id), eq(review.toolId, toolId)))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
return { ...row, authorName: user.name ?? "Anonymous", authorImage: user.image ?? null, authorEmailVerified: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 toggleReviewHelpful
|
||||
* 说明:标记/取消"有用"。联合唯一约束防重复计数。
|
||||
*/
|
||||
export async function toggleReviewHelpful(
|
||||
reviewId: string
|
||||
): Promise<{ success: boolean; helpful: boolean; helpfulCount: number }> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { success: false, helpful: false, helpfulCount: 0 };
|
||||
|
||||
const inserted = await db
|
||||
.insert(reviewHelpful)
|
||||
.values({ userId: user.id, reviewId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ reviewId: reviewHelpful.reviewId });
|
||||
|
||||
let helpful: boolean;
|
||||
if (inserted.length > 0) {
|
||||
helpful = true;
|
||||
await db
|
||||
.update(review)
|
||||
.set({ helpfulCount: sql`${review.helpfulCount} + 1` })
|
||||
.where(eq(review.id, reviewId));
|
||||
} else {
|
||||
helpful = false;
|
||||
await db
|
||||
.delete(reviewHelpful)
|
||||
.where(
|
||||
and(eq(reviewHelpful.userId, user.id), eq(reviewHelpful.reviewId, reviewId))
|
||||
);
|
||||
await db
|
||||
.update(review)
|
||||
.set({ helpfulCount: sql`GREATEST(${review.helpfulCount} - 1, 0)` })
|
||||
.where(eq(review.id, reviewId));
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({ helpfulCount: review.helpfulCount })
|
||||
.from(review)
|
||||
.where(eq(review.id, reviewId))
|
||||
.limit(1);
|
||||
|
||||
return { success: true, helpful, helpfulCount: row?.helpfulCount ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 deleteMyReview
|
||||
* 说明:用户删除自己的评论。
|
||||
*/
|
||||
export async function deleteMyReview(toolId: string) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { success: false as const, error: "UNAUTHORIZED" };
|
||||
|
||||
await db
|
||||
.delete(review)
|
||||
.where(and(eq(review.userId, user.id), eq(review.toolId, toolId)));
|
||||
await recalcToolRating(toolId);
|
||||
revalidatePath("/[locale]/tools/[slug]", "page");
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
// ========== 管理员操作 ==========
|
||||
|
||||
/**
|
||||
* # 函数 setReviewStatus
|
||||
* 说明:管理员隐藏/恢复评论。
|
||||
*/
|
||||
export async function setReviewStatus(reviewId: string, status: ReviewStatus) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
|
||||
const [row] = await db
|
||||
.update(review)
|
||||
.set({ status })
|
||||
.where(eq(review.id, reviewId))
|
||||
.returning({ toolId: review.toolId });
|
||||
|
||||
if (row) {
|
||||
await recalcToolRating(row.toolId);
|
||||
revalidatePath("/[locale]/tools/[slug]", "page");
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getReviewsAdmin
|
||||
* 说明:管理员的评论审核列表(全部状态)。
|
||||
*/
|
||||
export async function getReviewsAdmin(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: ReviewStatus;
|
||||
} = {}) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
|
||||
const { page = 1, pageSize = 20, status } = params;
|
||||
const conditions = status ? [eq(review.status, status)] : [];
|
||||
|
||||
const whereClause = conditions.length ? and(...conditions) : undefined;
|
||||
|
||||
const [rows, countRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: review.id,
|
||||
rating: review.rating,
|
||||
title: review.title,
|
||||
content: review.content,
|
||||
status: review.status,
|
||||
createdAt: review.createdAt,
|
||||
toolName: sql<string>`COALESCE(${tool.nameEn}, '')`,
|
||||
toolSlug: sql<string>`COALESCE(${tool.slug}, '')`,
|
||||
authorName: sql<string>`COALESCE(${userTable.name}, 'Anonymous')`,
|
||||
})
|
||||
.from(review)
|
||||
.innerJoin(userTable, eq(review.userId, userTable.id))
|
||||
.innerJoin(tool, eq(review.toolId, tool.id))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(review.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(review)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
return {
|
||||
reviews: rows,
|
||||
total: Number(countRows[0]?.count ?? 0),
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { ThumbsUp, PenLine } from "lucide-react";
|
||||
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import {
|
||||
getToolReviews,
|
||||
getUserReview,
|
||||
submitReview,
|
||||
toggleReviewHelpful,
|
||||
deleteMyReview,
|
||||
type ReviewListItem,
|
||||
} from "@/features/reviews/actions";
|
||||
import { StarRating } from "./star-rating";
|
||||
import { Button } from "@/components/button";
|
||||
import { BadgeCheck, Trash2, Star } from "lucide-react";
|
||||
|
||||
/**
|
||||
* # 组件 ReviewsSection
|
||||
* 说明:工具详情页的评论区块(服务端数据由父组件传入首屏,
|
||||
* 客户端负责写操作与分页加载)。
|
||||
*/
|
||||
export function ReviewsSection({
|
||||
toolId,
|
||||
initialReviews,
|
||||
initialTotal,
|
||||
}: {
|
||||
toolId: string;
|
||||
initialReviews: ReviewListItem[];
|
||||
initialTotal: number;
|
||||
}) {
|
||||
const t = useTranslations("reviews");
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const isAuthenticated = !!session.data?.user;
|
||||
|
||||
const [reviews, setReviews] = useState(initialReviews);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ReviewListItem | null>(null);
|
||||
const [helpfulIds, setHelpfulIds] = useState<Set<string>>(new Set());
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
const openForm = async () => {
|
||||
if (!isAuthenticated) {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const mine = await getUserReview(toolId);
|
||||
setEditing(mine);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
setLoadingMore(true);
|
||||
const next = page + 1;
|
||||
const result = await getToolReviews(toolId, next, pageSize);
|
||||
setReviews((prev) => [...prev, ...result.reviews]);
|
||||
setTotal(result.total);
|
||||
setPage(next);
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
const onHelpful = async (reviewId: string) => {
|
||||
if (!isAuthenticated) {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await toggleReviewHelpful(reviewId);
|
||||
if (result.success) {
|
||||
setHelpfulIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (result.helpful) next.add(reviewId);
|
||||
else next.delete(reviewId);
|
||||
return next;
|
||||
});
|
||||
setReviews((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === reviewId ? { ...r, helpfulCount: result.helpfulCount } : r
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = (toolIdStr: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await deleteMyReview(toolIdStr);
|
||||
if (result.success) {
|
||||
toast.success(t("deleted"));
|
||||
setReviews((prev) => prev.filter((r) => r.id !== editing?.id));
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6" id="reviews">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{t("title")} ({total})
|
||||
</h2>
|
||||
<Button variant="simple" onClick={openForm} disabled={isPending}>
|
||||
<PenLine className="h-4 w-4" />
|
||||
{t("writeReview")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formOpen && (
|
||||
<ReviewForm
|
||||
toolId={toolId}
|
||||
existing={editing}
|
||||
onSaved={(saved) => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
router.refresh();
|
||||
if (saved) {
|
||||
setReviews((prev) => {
|
||||
const others = prev.filter((r) => r.id !== saved.id);
|
||||
return [saved, ...others];
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
onDeleted={() => onDelete(toolId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{reviews.length === 0 && !formOpen ? (
|
||||
<p className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
{t("empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reviews.map((review) => (
|
||||
<article
|
||||
key={review.id}
|
||||
className="rounded-xl border border-border p-5 space-y-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-foreground">
|
||||
{review.authorName.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{review.authorName}
|
||||
{review.authorEmailVerified && (
|
||||
<BadgeCheck className="h-3.5 w-3.5 text-primary" aria-label={t("verifiedUser")} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StarRating value={review.rating} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(review.createdAt).toLocaleDateString(
|
||||
locale === "zh" ? "zh-CN" : "en-US"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHelpful(review.id)}
|
||||
className={
|
||||
helpfulIds.has(review.id)
|
||||
? "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs text-primary"
|
||||
: "inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
}
|
||||
>
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
{t("helpful")} · {review.helpfulCount}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{review.title && (
|
||||
<h3 className="text-sm font-semibold text-foreground">{review.title}</h3>
|
||||
)}
|
||||
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">
|
||||
{review.content}
|
||||
</p>
|
||||
{(review.pros || review.cons) && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{review.pros && (
|
||||
<div className="rounded-lg bg-emerald-500/5 p-3 text-sm text-foreground">
|
||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
{t("pros")}
|
||||
</span>
|
||||
{review.pros}
|
||||
</div>
|
||||
)}
|
||||
{review.cons && (
|
||||
<div className="rounded-lg bg-red-500/5 p-3 text-sm text-foreground">
|
||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-red-600 dark:text-red-400">
|
||||
{t("cons")}
|
||||
</span>
|
||||
{review.cons}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reviews.length < total && (
|
||||
<div className="text-center">
|
||||
<Button variant="simple" onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? t("loading") : t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 ReviewForm
|
||||
* 说明:评论撰写/编辑表单(评分 + 标题 + 正文 + 优缺点 + 使用场景)。
|
||||
*/
|
||||
function ReviewForm({
|
||||
toolId,
|
||||
existing,
|
||||
onSaved,
|
||||
onCancel,
|
||||
onDeleted,
|
||||
}: {
|
||||
toolId: string;
|
||||
existing: ReviewListItem | null;
|
||||
onSaved: (review: ReviewListItem | null) => void;
|
||||
onCancel: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const t = useTranslations("reviews");
|
||||
const [rating, setRating] = useState(existing?.rating ?? 0);
|
||||
const [title, setTitle] = useState(existing?.title ?? "");
|
||||
const [content, setContent] = useState(existing?.content ?? "");
|
||||
const [pros, setPros] = useState(existing?.pros ?? "");
|
||||
const [cons, setCons] = useState(existing?.cons ?? "");
|
||||
const [useCase, setUseCase] = useState(existing?.useCase ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (rating < 1) {
|
||||
setError(t("ratingRequired"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
const result = await submitReview({
|
||||
toolId,
|
||||
rating,
|
||||
title: title || undefined,
|
||||
content,
|
||||
pros: pros || undefined,
|
||||
cons: cons || undefined,
|
||||
useCase: useCase || undefined,
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (result.success) {
|
||||
const mine = await getUserReview(toolId);
|
||||
onSaved(mine);
|
||||
} else {
|
||||
setError(result.error === "REVIEW_TOO_SHORT" ? t("tooShort") : t("error"));
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4 rounded-xl border border-border p-5"
|
||||
>
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("yourRating")}
|
||||
</span>
|
||||
<StarRating value={rating} onChange={setRating} size="lg" />
|
||||
</div>
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={t("titlePlaceholder")}
|
||||
value={title}
|
||||
maxLength={120}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={inputClass}
|
||||
rows={4}
|
||||
placeholder={t("contentPlaceholder")}
|
||||
value={content}
|
||||
maxLength={4000}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<textarea
|
||||
className={inputClass}
|
||||
rows={2}
|
||||
placeholder={t("prosPlaceholder")}
|
||||
value={pros}
|
||||
onChange={(e) => setPros(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={inputClass}
|
||||
rows={2}
|
||||
placeholder={t("consPlaceholder")}
|
||||
value={cons}
|
||||
onChange={(e) => setCons(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={t("useCasePlaceholder")}
|
||||
value={useCase}
|
||||
maxLength={200}
|
||||
onChange={(e) => setUseCase(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? t("saving") : existing ? t("update") : t("submit")}
|
||||
</Button>
|
||||
<Button type="button" variant="simple" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
{existing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleted}
|
||||
className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 RatingSummary
|
||||
* 说明:评分概览(均值 + 星级分布条)。
|
||||
*/
|
||||
export function RatingSummary({
|
||||
distribution,
|
||||
total,
|
||||
avg,
|
||||
}: {
|
||||
distribution: Record<number, number>;
|
||||
total: number;
|
||||
avg: number;
|
||||
}) {
|
||||
const t = useTranslations("reviews");
|
||||
return (
|
||||
<div className="rounded-xl border border-border p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-4xl font-bold text-foreground tabular-nums">
|
||||
{avg > 0 ? avg.toFixed(1) : "—"}
|
||||
</span>
|
||||
<div>
|
||||
<StarRating value={avg} size="md" />
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("basedOn", { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-1.5">
|
||||
{[5, 4, 3, 2, 1].map((star) => {
|
||||
const count = distribution[star] ?? 0;
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
<div key={star} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-6 text-muted-foreground tabular-nums">{star}</span>
|
||||
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-400"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Star } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StarRatingProps {
|
||||
/** 当前值(0-5,可为小数用于展示) */
|
||||
value: number;
|
||||
/** 受控模式下的变更回调(传入则为可交互) */
|
||||
onChange?: (value: number) => void;
|
||||
/** 尺寸 */
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 StarRating
|
||||
* 说明:星级展示 / 星级输入(表单模式)。图标为 Lucide Star,无 Unicode 字符。
|
||||
*/
|
||||
export function StarRating({ value, onChange, size = "sm", className }: StarRatingProps) {
|
||||
const [hoverValue, setHoverValue] = useState<number | null>(null);
|
||||
const sizeClass = size === "sm" ? "h-3.5 w-3.5" : size === "lg" ? "h-6 w-6" : "h-4.5 w-4.5";
|
||||
const interactive = !!onChange;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("inline-flex items-center gap-0.5", className)}
|
||||
role={interactive ? "radiogroup" : undefined}
|
||||
aria-label={interactive ? "rating" : undefined}
|
||||
onMouseLeave={() => interactive && setHoverValue(null)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const filled = (hoverValue ?? value) >= star - 0.25;
|
||||
const starEl = (
|
||||
<Star
|
||||
className={cn(
|
||||
sizeClass,
|
||||
"stroke-[1.8]",
|
||||
filled ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
if (!interactive) return <span key={star}>{starEl}</span>;
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
aria-label={`${star}`}
|
||||
onMouseEnter={() => setHoverValue(star)}
|
||||
onClick={() => onChange(star)}
|
||||
className="p-0.5 transition-transform hover:scale-110"
|
||||
>
|
||||
{starEl}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { submission, tool, toolCategory } from "@/lib/db/schema";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { nanoid } from "nanoid";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { isAdmin } from "@/lib/auth/admin";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
checkDuplicateTool,
|
||||
normalizeSubmissionDomain,
|
||||
} from "@/lib/tools/duplicate";
|
||||
import { slugify } from "@/lib/ranking/score";
|
||||
import { recordEvent } from "@/lib/analytics/record";
|
||||
|
||||
export type SubmissionStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
const submissionSchema = z.object({
|
||||
name: z.string().min(2).max(80),
|
||||
websiteUrl: z.string().url().max(500),
|
||||
tagline: z.string().max(200).optional(),
|
||||
description: z.string().min(20, "DESCRIPTION_TOO_SHORT").max(4000),
|
||||
logoUrl: z.string().url().optional().or(z.literal("")),
|
||||
categoryId: z.string().optional(),
|
||||
pricing: z
|
||||
.enum(["free", "freemium", "paid", "free_trial", "contact", "open_source"])
|
||||
.optional(),
|
||||
socialLinks: z.string().max(500).optional(),
|
||||
screenshots: z.string().max(500).optional(),
|
||||
founderName: z.string().max(80).optional(),
|
||||
contactEmail: z.string().email().optional().or(z.literal("")),
|
||||
note: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export type SubmissionInput = z.infer<typeof submissionSchema>;
|
||||
|
||||
async function getSessionUser() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
return session?.user ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 createSubmission
|
||||
* 说明:用户提交新工具。登录必填;写入前做重复检测;
|
||||
* domain 列写入归一化域名(pending 唯一索引兜底并发重复提交)。
|
||||
*/
|
||||
export async function createSubmission(
|
||||
input: SubmissionInput
|
||||
): Promise<
|
||||
| { success: true; submissionId: string }
|
||||
| { success: false; error: string; duplicates?: { slug: string; name: string }[] }
|
||||
> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { success: false, error: "UNAUTHORIZED" };
|
||||
|
||||
const parsed = submissionSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "VALIDATION" };
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
// 重复检测:同域名/同名工具直接拦截
|
||||
const dup = await checkDuplicateTool(data.websiteUrl, data.name);
|
||||
if (dup.duplicate) {
|
||||
return {
|
||||
success: false,
|
||||
error: "DUPLICATE",
|
||||
duplicates: dup.matches.map((m) => ({ slug: m.slug, name: m.name })),
|
||||
};
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
await db.insert(submission).values({
|
||||
id,
|
||||
userId: user.id,
|
||||
name: data.name.trim(),
|
||||
websiteUrl: data.websiteUrl,
|
||||
domain: normalizeSubmissionDomain(data.websiteUrl),
|
||||
tagline: data.tagline ?? null,
|
||||
description: data.description,
|
||||
logoUrl: data.logoUrl || null,
|
||||
categoryId: data.categoryId || null,
|
||||
pricing: data.pricing ?? null,
|
||||
socialLinks: data.socialLinks ?? null,
|
||||
screenshots: data.screenshots ?? null,
|
||||
founderName: data.founderName ?? null,
|
||||
contactEmail: data.contactEmail || null,
|
||||
note: data.note ?? null,
|
||||
});
|
||||
|
||||
await recordEvent({
|
||||
event: "submit_tool",
|
||||
userId: user.id,
|
||||
entityType: "submission",
|
||||
entityId: id,
|
||||
metadata: { name: data.name },
|
||||
});
|
||||
|
||||
revalidatePath("/[locale]/admin/submissions", "page");
|
||||
return { success: true, submissionId: id };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 checkSubmissionDuplicate
|
||||
* 说明:提交表单的即时重复检测(前端失焦时调用)。
|
||||
*/
|
||||
export async function checkSubmissionDuplicate(
|
||||
websiteUrl: string,
|
||||
name: string
|
||||
) {
|
||||
const dup = await checkDuplicateTool(websiteUrl, name);
|
||||
return {
|
||||
duplicate: dup.duplicate,
|
||||
duplicates: dup.matches.map((m) => ({ slug: m.slug, name: m.name })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getMySubmissions
|
||||
* 说明:当前用户的提交历史(仪表盘)。
|
||||
*/
|
||||
export async function getMySubmissions() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return [];
|
||||
return db
|
||||
.select()
|
||||
.from(submission)
|
||||
.where(eq(submission.userId, user.id))
|
||||
.orderBy(desc(submission.createdAt));
|
||||
}
|
||||
|
||||
// ========== 管理员操作 ==========
|
||||
|
||||
/**
|
||||
* # 函数 getSubmissionsAdmin
|
||||
* 说明:审核队列(管理员)。
|
||||
*/
|
||||
export async function getSubmissionsAdmin(
|
||||
params: { status?: SubmissionStatus; page?: number; pageSize?: number } = {}
|
||||
) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
const { status, page = 1, pageSize = 20 } = params;
|
||||
|
||||
const whereClause = status ? eq(submission.status, status) : undefined;
|
||||
|
||||
const [rows, countRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(submission)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(submission.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(submission)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
return {
|
||||
submissions: rows,
|
||||
total: Number(countRows[0]?.count ?? 0),
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 approveSubmission
|
||||
* 说明:审核通过 → 创建 published 工具(slug 自动生成并去重)→ 回写 approvedToolId。
|
||||
*/
|
||||
export async function approveSubmission(submissionId: string) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(submission)
|
||||
.where(eq(submission.id, submissionId))
|
||||
.limit(1);
|
||||
if (!sub || sub.status !== "pending") {
|
||||
return { success: false as const, error: "INVALID_STATE" };
|
||||
}
|
||||
|
||||
// slug 生成 + 去重
|
||||
let slug = slugify(sub.name) || nanoid(8).toLowerCase();
|
||||
const [existing] = await db
|
||||
.select({ id: tool.id })
|
||||
.from(tool)
|
||||
.where(eq(tool.slug, slug))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
slug = `${slug}-${nanoid(4).toLowerCase()}`;
|
||||
}
|
||||
|
||||
const toolId = nanoid();
|
||||
await db.insert(tool).values({
|
||||
id: toolId,
|
||||
slug,
|
||||
domain: sub.domain,
|
||||
websiteUrl: sub.websiteUrl,
|
||||
logoUrl: sub.logoUrl,
|
||||
nameEn: sub.name,
|
||||
descriptionEn: sub.description,
|
||||
taglineEn: sub.tagline,
|
||||
pricing: sub.pricing,
|
||||
status: "published",
|
||||
});
|
||||
|
||||
if (sub.categoryId) {
|
||||
await db
|
||||
.insert(toolCategory)
|
||||
.values({ toolId, categoryId: sub.categoryId })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
await db
|
||||
.update(submission)
|
||||
.set({ status: "approved", approvedToolId: toolId })
|
||||
.where(eq(submission.id, submissionId));
|
||||
|
||||
revalidateToolPaths();
|
||||
return { success: true as const, toolSlug: slug };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 rejectSubmission
|
||||
* 说明:审核拒绝(附理由)。
|
||||
*/
|
||||
export async function rejectSubmission(submissionId: string, adminNote: string) {
|
||||
if (!(await isAdmin())) throw new Error("Unauthorized");
|
||||
|
||||
await db
|
||||
.update(submission)
|
||||
.set({ status: "rejected", adminNote })
|
||||
.where(
|
||||
and(eq(submission.id, submissionId), eq(submission.status, "pending"))
|
||||
);
|
||||
|
||||
revalidatePath("/[locale]/admin/submissions", "page");
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
function revalidateToolPaths() {
|
||||
revalidatePath("/[locale]", "page");
|
||||
revalidatePath("/[locale]/tools", "page");
|
||||
revalidatePath("/[locale]/admin/submissions", "page");
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, X, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
approveSubmission,
|
||||
rejectSubmission,
|
||||
} from "@/features/submission/actions";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SubmissionRow {
|
||||
id: string;
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
domain: string | null;
|
||||
tagline: string | null;
|
||||
description: string | null;
|
||||
pricing: string | null;
|
||||
status: string;
|
||||
adminNote: string | null;
|
||||
createdAt: Date;
|
||||
founderName: string | null;
|
||||
contactEmail: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 SubmissionsTable
|
||||
* 说明:管理员审核队列。批准 → 自动创建 published 工具;拒绝 → 附理由。
|
||||
*/
|
||||
export function SubmissionsTable({ submissions }: { submissions: SubmissionRow[] }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [rejectingId, setRejectingId] = useState<string | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
|
||||
const approve = (id: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await approveSubmission(id);
|
||||
if (result.success) {
|
||||
toast.success("Approved and published");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("Failed to approve");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const reject = (id: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await rejectSubmission(id, rejectNote || "Does not meet quality guidelines");
|
||||
if (result.success) {
|
||||
toast.success("Rejected");
|
||||
setRejectingId(null);
|
||||
setRejectNote("");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("Failed to reject");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (submissions.length === 0) {
|
||||
return (
|
||||
<p className="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
||||
No submissions in the queue.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{submissions.map((sub) => (
|
||||
<div key={sub.id} className="rounded-xl border border-border p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-foreground">{sub.name}</h3>
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
||||
{sub.status}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={sub.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-0.5 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-primary"
|
||||
>
|
||||
{sub.domain || sub.websiteUrl}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
{sub.tagline && (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{sub.tagline}</p>
|
||||
)}
|
||||
{sub.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{sub.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{sub.founderName || "Unknown founder"}
|
||||
{sub.contactEmail ? ` · ${sub.contactEmail}` : ""}
|
||||
{sub.pricing ? ` · ${sub.pricing}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sub.status === "pending" && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => approve(sub.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => setRejectingId(sub.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-red-500 disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rejectingId === sub.id && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<textarea
|
||||
rows={2}
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.target.value)}
|
||||
placeholder="Reason for rejection (shown to submitter)..."
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reject(sub.id)}
|
||||
disabled={isPending}
|
||||
className="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Confirm reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRejectingId(null)}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, Send } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
createSubmission,
|
||||
checkSubmissionDuplicate,
|
||||
} from "@/features/submission/actions";
|
||||
|
||||
interface CategoryOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 SubmitToolForm
|
||||
* 说明:工具提交表单(登录用户)。名称/官网失焦即时重复检测;
|
||||
* 提交进入 Admin 审核队列。
|
||||
*/
|
||||
export function SubmitToolForm({ categories }: { categories: CategoryOption[] }) {
|
||||
const t = useTranslations("submission");
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [websiteUrl, setWebsiteUrl] = useState("");
|
||||
const [tagline, setTagline] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [pricing, setPricing] = useState("");
|
||||
const [founderName, setFounderName] = useState("");
|
||||
const [contactEmail, setContactEmail] = useState("");
|
||||
const [socialLinks, setSocialLinks] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [duplicates, setDuplicates] = useState<{ slug: string; name: string }[]>([]);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 即时重复检测(防抖由失焦触发承担)
|
||||
const runDuplicateCheck = async () => {
|
||||
if (!websiteUrl || !name) return;
|
||||
setChecking(true);
|
||||
const result = await checkSubmissionDuplicate(websiteUrl, name);
|
||||
setDuplicates(result.duplicates);
|
||||
setChecking(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
setError(t("duplicateBlocked"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
const result = await createSubmission({
|
||||
name,
|
||||
websiteUrl,
|
||||
tagline: tagline || undefined,
|
||||
description,
|
||||
categoryId: categoryId || undefined,
|
||||
pricing: (pricing || undefined) as undefined,
|
||||
founderName: founderName || undefined,
|
||||
contactEmail: contactEmail || undefined,
|
||||
socialLinks: socialLinks || undefined,
|
||||
note: note || undefined,
|
||||
});
|
||||
setSubmitting(false);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(t("submitted"));
|
||||
router.push(`/${locale}/dashboard`);
|
||||
} else {
|
||||
if (result.error === "DUPLICATE") {
|
||||
setDuplicates(result.duplicates ?? []);
|
||||
setError(t("duplicateBlocked"));
|
||||
} else if (result.error === "UNAUTHORIZED") {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent("/submit")}`
|
||||
);
|
||||
} else {
|
||||
setError(t("error"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{duplicates.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-300/60 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t("duplicateFound")}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{duplicates.map((dup) => (
|
||||
<button
|
||||
key={dup.slug}
|
||||
type="button"
|
||||
className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium underline-offset-2 hover:underline dark:bg-amber-900/50"
|
||||
onClick={() => router.push(`/${locale}/tools/${dup.slug}`)}
|
||||
>
|
||||
{dup.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("name")} *
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={runDuplicateCheck}
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={80}
|
||||
placeholder="ChatGPT"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("websiteUrl")} *
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="url"
|
||||
value={websiteUrl}
|
||||
onChange={(e) => setWebsiteUrl(e.target.value)}
|
||||
onBlur={runDuplicateCheck}
|
||||
required
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("tagline")}
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={tagline}
|
||||
maxLength={200}
|
||||
onChange={(e) => setTagline(e.target.value)}
|
||||
placeholder={t("taglinePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("description")} *
|
||||
</label>
|
||||
<textarea
|
||||
className={inputClass}
|
||||
rows={5}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
minLength={20}
|
||||
maxLength={4000}
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("category")}
|
||||
</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("selectPlaceholder")}</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("pricingLabel")}
|
||||
</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={pricing}
|
||||
onChange={(e) => setPricing(e.target.value)}
|
||||
>
|
||||
<option value="">{t("selectPlaceholder")}</option>
|
||||
<option value="free">{t("pricing.free")}</option>
|
||||
<option value="freemium">{t("pricing.freemium")}</option>
|
||||
<option value="paid">{t("pricing.paid")}</option>
|
||||
<option value="free_trial">{t("pricing.freeTrial")}</option>
|
||||
<option value="open_source">{t("pricing.openSource")}</option>
|
||||
<option value="contact">{t("pricing.contact")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("founderName")}
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={founderName}
|
||||
maxLength={80}
|
||||
onChange={(e) => setFounderName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("contactEmail")}
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("socialLinks")}
|
||||
</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={socialLinks}
|
||||
maxLength={500}
|
||||
onChange={(e) => setSocialLinks(e.target.value)}
|
||||
placeholder="https://x.com/..., https://github.com/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
{t("note")}
|
||||
</label>
|
||||
<textarea
|
||||
className={inputClass}
|
||||
rows={2}
|
||||
value={note}
|
||||
maxLength={1000}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("notePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={submitting || checking}>
|
||||
<Send className="h-4 w-4" />
|
||||
{submitting ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{t("reviewNote")}</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { tool, toolCategory, toolTag, category, tag } from "@/lib/db/schema";
|
||||
import {
|
||||
tool,
|
||||
toolCategory,
|
||||
toolTag,
|
||||
category,
|
||||
tag,
|
||||
useCase,
|
||||
audience,
|
||||
toolUseCase,
|
||||
toolAudience,
|
||||
} from "@/lib/db/schema";
|
||||
import { eq, desc, and, inArray, sql } from "drizzle-orm";
|
||||
import { isAdmin } from "@/lib/auth/admin";
|
||||
import { revalidatePath } from "next/cache";
|
||||
@@ -51,6 +61,12 @@ export interface ToolListParams {
|
||||
tagId?: string;
|
||||
search?: string;
|
||||
pricing?: ToolPricing;
|
||||
/** Use Case 维度筛选(slug) */
|
||||
useCaseSlug?: string;
|
||||
/** Audience 维度筛选(slug) */
|
||||
audienceSlug?: string;
|
||||
/** 排序:newest(默认)| trending(互动热度) */
|
||||
sort?: "newest" | "trending";
|
||||
}
|
||||
|
||||
// ========== 查询操作 ==========
|
||||
@@ -59,7 +75,17 @@ export interface ToolListParams {
|
||||
* 获取工具列表(前台)
|
||||
*/
|
||||
export async function getPublishedTools(params: ToolListParams = {}) {
|
||||
const { page = 1, pageSize = 12, categoryId, tagId, search, pricing } = params;
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 12,
|
||||
categoryId,
|
||||
tagId,
|
||||
search,
|
||||
pricing,
|
||||
useCaseSlug,
|
||||
audienceSlug,
|
||||
sort = "newest",
|
||||
} = params;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
let toolIds: string[] | undefined;
|
||||
@@ -95,6 +121,37 @@ export async function getPublishedTools(params: ToolListParams = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Use Case / Audience 维度筛选(三维发现体系)
|
||||
if (useCaseSlug) {
|
||||
const rows = await db
|
||||
.select({ toolId: toolUseCase.toolId })
|
||||
.from(toolUseCase)
|
||||
.innerJoin(useCase, eq(toolUseCase.useCaseId, useCase.id))
|
||||
.where(eq(useCase.slug, useCaseSlug));
|
||||
const ucToolIds = rows.map((r) => r.toolId);
|
||||
toolIds = toolIds
|
||||
? toolIds.filter((id) => ucToolIds.includes(id))
|
||||
: ucToolIds;
|
||||
if (toolIds.length === 0) {
|
||||
return { tools: [], total: 0, page, pageSize };
|
||||
}
|
||||
}
|
||||
|
||||
if (audienceSlug) {
|
||||
const rows = await db
|
||||
.select({ toolId: toolAudience.toolId })
|
||||
.from(toolAudience)
|
||||
.innerJoin(audience, eq(toolAudience.audienceId, audience.id))
|
||||
.where(eq(audience.slug, audienceSlug));
|
||||
const audToolIds = rows.map((r) => r.toolId);
|
||||
toolIds = toolIds
|
||||
? toolIds.filter((id) => audToolIds.includes(id))
|
||||
: audToolIds;
|
||||
if (toolIds.length === 0) {
|
||||
return { tools: [], total: 0, page, pageSize };
|
||||
}
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [eq(tool.status, "published")];
|
||||
|
||||
@@ -112,13 +169,24 @@ export async function getPublishedTools(params: ToolListParams = {}) {
|
||||
conditions.push(eq(tool.pricing, pricing));
|
||||
}
|
||||
|
||||
// 排序:newest 按时间;trending 按互动热度(Ranking 权重的列表页版本)
|
||||
const orderBy =
|
||||
sort === "trending"
|
||||
? [
|
||||
desc(
|
||||
sql`${tool.voteCount} * 2 + ${tool.favoriteCount} * 1.5 + ${tool.reviewCount} * 3 + ${tool.viewCount} * 0.1`
|
||||
),
|
||||
desc(tool.createdAt),
|
||||
]
|
||||
: [desc(tool.createdAt)];
|
||||
|
||||
// 查询工具
|
||||
const [tools, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(tool)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(tool.createdAt))
|
||||
.orderBy(...orderBy)
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
db
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { FavoriteButton } from "@/features/tools/components/favorite-button";
|
||||
import { VoteButton } from "@/features/voting/components/vote-button";
|
||||
import { ToolLogo } from "@/components/tool-logo";
|
||||
|
||||
interface ToolData {
|
||||
@@ -22,6 +23,8 @@ interface ToolData {
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
voteCount?: number;
|
||||
ratingAvg?: number;
|
||||
}
|
||||
|
||||
interface CategoryData {
|
||||
@@ -44,6 +47,14 @@ interface ToolsTranslations {
|
||||
pricingFree: string;
|
||||
pricingFreemium: string;
|
||||
pricingPaid: string;
|
||||
sortLatest: string;
|
||||
sortTrending: string;
|
||||
}
|
||||
|
||||
interface UseCaseOption {
|
||||
slug: string;
|
||||
name: string;
|
||||
toolCount: number;
|
||||
}
|
||||
|
||||
interface ToolsListProps {
|
||||
@@ -57,6 +68,10 @@ interface ToolsListProps {
|
||||
total: number;
|
||||
locale: string;
|
||||
translations: ToolsTranslations;
|
||||
/** Use Case 筛选项(三维发现体系) */
|
||||
useCases?: UseCaseOption[];
|
||||
/** 当前排序:newest | trending */
|
||||
currentSort?: string;
|
||||
}
|
||||
|
||||
export function ToolsList({
|
||||
@@ -70,6 +85,8 @@ export function ToolsList({
|
||||
total,
|
||||
locale,
|
||||
translations,
|
||||
useCases = [],
|
||||
currentSort = "newest",
|
||||
}: ToolsListProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -109,6 +126,30 @@ export function ToolsList({
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const currentUseCase = new URLSearchParams(searchParams.toString()).get("usecase") ?? undefined;
|
||||
|
||||
const handleUseCaseChange = (slug: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (currentUseCase === slug) {
|
||||
params.delete("usecase");
|
||||
} else {
|
||||
params.set("usecase", slug);
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSortChange = (sort: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (sort === "trending") {
|
||||
params.set("sort", sort);
|
||||
} else {
|
||||
params.delete("sort");
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", page.toString());
|
||||
@@ -215,6 +256,42 @@ export function ToolsList({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 第四行:排序 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Sort</span>
|
||||
{(["newest", "trending"] as const).map((sort) => (
|
||||
<button
|
||||
key={sort}
|
||||
onClick={() => handleSortChange(sort)}
|
||||
className={`px-3 py-1.5 rounded-full font-medium transition-colors ${
|
||||
currentSort === sort
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
}`}
|
||||
>
|
||||
{sort === "newest" ? translations.sortLatest : translations.sortTrending}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 第五行:Use Case 筛选(三维发现体系) */}
|
||||
{useCases.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{useCases.slice(0, 12).map((uc) => (
|
||||
<button
|
||||
key={uc.slug}
|
||||
onClick={() => handleUseCaseChange(uc.slug)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
currentUseCase === uc.slug
|
||||
? "bg-foreground text-background"
|
||||
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||
}`}
|
||||
>
|
||||
{uc.name} ({uc.toolCount})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 工具网格 */}
|
||||
@@ -237,7 +314,10 @@ export function ToolsList({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FavoriteButton toolId={tool.id} />
|
||||
<div className="flex items-center gap-1">
|
||||
<VoteButton toolId={tool.id} initialCount={tool.voteCount ?? 0} />
|
||||
<FavoriteButton toolId={tool.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getDescription(tool) && (
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { vote, tool } from "@/lib/db/schema";
|
||||
import { and, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { nanoid } from "nanoid";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { recordEvent } from "@/lib/analytics/record";
|
||||
|
||||
// ========== 防刷配置 ==========
|
||||
/** 单用户滑动窗口内最大投票操作次数 */
|
||||
const VOTE_RATE_LIMIT = 40;
|
||||
/** 滑动窗口长度(毫秒):10 分钟 */
|
||||
const VOTE_RATE_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
async function getSessionUser() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
return session?.user ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 toggleVote
|
||||
* 说明:投票/取消投票(登录用户)。原子 onConflict 语义防止并发双写;
|
||||
* 带滑动窗口速率限制(防刷)与事件埋点,并同步 tool.voteCount。
|
||||
* 参数:toolId 工具 ID;source 投票来源(web/api)
|
||||
* 返回值:{ success, voted, voteCount } 或 { success:false, error }
|
||||
*/
|
||||
export async function toggleVote(
|
||||
toolId: string,
|
||||
source: "web" | "api" = "web"
|
||||
): Promise<
|
||||
| { success: true; voted: boolean; voteCount: number }
|
||||
| { success: false; error: "UNAUTHORIZED" | "RATE_LIMITED" | "NOT_FOUND" }
|
||||
> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return { success: false as const, error: "UNAUTHORIZED" };
|
||||
}
|
||||
|
||||
// 滑动窗口限流:统计该用户最近窗口内的投票插入次数
|
||||
const [recent] = await db
|
||||
.select({ cnt: sql<number>`count(*)` })
|
||||
.from(vote)
|
||||
.where(
|
||||
and(
|
||||
eq(vote.userId, user.id),
|
||||
gte(vote.createdAt, new Date(Date.now() - VOTE_RATE_WINDOW_MS))
|
||||
)
|
||||
);
|
||||
if (Number(recent?.cnt ?? 0) >= VOTE_RATE_LIMIT) {
|
||||
return { success: false as const, error: "RATE_LIMITED" };
|
||||
}
|
||||
|
||||
const [toolRow] = await db
|
||||
.select({ id: tool.id })
|
||||
.from(tool)
|
||||
.where(and(eq(tool.id, toolId), eq(tool.status, "published")))
|
||||
.limit(1);
|
||||
if (!toolRow) {
|
||||
return { success: false as const, error: "NOT_FOUND" };
|
||||
}
|
||||
|
||||
// 原子 toggle:插入成功 = 新投票;冲突 = 已投过,转为取消
|
||||
const inserted = await db
|
||||
.insert(vote)
|
||||
.values({ id: nanoid(), userId: user.id, toolId, source })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: vote.id });
|
||||
|
||||
let voted: boolean;
|
||||
if (inserted.length > 0) {
|
||||
voted = true;
|
||||
await db
|
||||
.update(tool)
|
||||
.set({ voteCount: sql`${tool.voteCount} + 1` })
|
||||
.where(eq(tool.id, toolId));
|
||||
await recordEvent({
|
||||
event: "tool_vote",
|
||||
userId: user.id,
|
||||
entityType: "tool",
|
||||
entityId: toolId,
|
||||
});
|
||||
} else {
|
||||
voted = false;
|
||||
await db
|
||||
.delete(vote)
|
||||
.where(and(eq(vote.userId, user.id), eq(vote.toolId, toolId)));
|
||||
await db
|
||||
.update(tool)
|
||||
.set({ voteCount: sql`GREATEST(${tool.voteCount} - 1, 0)` })
|
||||
.where(eq(tool.id, toolId));
|
||||
await recordEvent({
|
||||
event: "tool_unvote",
|
||||
userId: user.id,
|
||||
entityType: "tool",
|
||||
entityId: toolId,
|
||||
});
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.select({ voteCount: tool.voteCount })
|
||||
.from(tool)
|
||||
.where(eq(tool.id, toolId))
|
||||
.limit(1);
|
||||
|
||||
revalidatePath("/[locale]/tools/[slug]", "page");
|
||||
revalidatePath("/[locale]/rankings", "page");
|
||||
|
||||
return { success: true as const, voted, voteCount: updated?.voteCount ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getVoteState
|
||||
* 说明:批量获取投票状态(客户端 Provider 启动时拉取)。
|
||||
* 参数:toolIds 工具 ID 列表
|
||||
* 返回值:{ voteCounts: Map, votedIds: Set }
|
||||
*/
|
||||
export async function getVoteState(
|
||||
toolIds: string[]
|
||||
): Promise<{ voteCounts: Record<string, number>; votedIds: string[] }> {
|
||||
if (toolIds.length === 0) {
|
||||
return { voteCounts: {}, votedIds: [] };
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
const [counts, voted] = await Promise.all([
|
||||
db
|
||||
.select({ toolId: vote.toolId, cnt: sql<number>`count(*)` })
|
||||
.from(vote)
|
||||
.where(inArray(vote.toolId, toolIds))
|
||||
.groupBy(vote.toolId),
|
||||
user
|
||||
? db
|
||||
.select({ toolId: vote.toolId })
|
||||
.from(vote)
|
||||
.where(and(eq(vote.userId, user.id), inArray(vote.toolId, toolIds)))
|
||||
: Promise.resolve([] as { toolId: string }[]),
|
||||
]);
|
||||
|
||||
const voteCounts: Record<string, number> = {};
|
||||
for (const row of counts) {
|
||||
voteCounts[row.toolId] = Number(row.cnt);
|
||||
}
|
||||
return { voteCounts, votedIds: voted.map((v) => v.toolId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getVotedToolIds
|
||||
* 说明:当前用户已投票的工具 ID 列表(未登录返回空)。客户端 Provider 用。
|
||||
*/
|
||||
export async function getVotedToolIds(): Promise<string[]> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return [];
|
||||
const rows = await db
|
||||
.select({ toolId: vote.toolId })
|
||||
.from(vote)
|
||||
.where(eq(vote.userId, user.id));
|
||||
return rows.map((r) => r.toolId);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getVoteStateForTool
|
||||
* 说明:单个工具的投票数(服务端渲染初始值)。
|
||||
*/
|
||||
export async function getVoteCount(toolId: string): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ voteCount: tool.voteCount })
|
||||
.from(tool)
|
||||
.where(eq(tool.id, toolId))
|
||||
.limit(1);
|
||||
return row?.voteCount ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { toggleVote, getVotedToolIds } from "@/features/voting/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VotesContextValue {
|
||||
/** 工具 → 客户端已知票数(乐观更新的真身) */
|
||||
voteCounts: Map<string, number>;
|
||||
votedIds: Set<string>;
|
||||
isAuthenticated: boolean;
|
||||
toggle: (toolId: string) => void;
|
||||
}
|
||||
|
||||
const VotesContext = createContext<VotesContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* # 组件 VotesProvider
|
||||
* 说明:全局投票状态 Provider。挂载后从服务端拉取票数与"我已投"集合;
|
||||
* 投票采用乐观更新并以服务端返回收敛(与 FavoritesProvider 同一套模式)。
|
||||
*/
|
||||
export function VotesProvider({
|
||||
children,
|
||||
initialCounts,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** 服务端渲染时的初始票数(可选,避免拉取闪烁) */
|
||||
initialCounts?: Record<string, number>;
|
||||
}) {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("voting");
|
||||
const isAuthenticated = !!session.data?.user;
|
||||
const [voteCounts, setVoteCounts] = useState<Map<string, number>>(
|
||||
() => new Map(Object.entries(initialCounts ?? {}))
|
||||
);
|
||||
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
||||
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
|
||||
const [fetched, setFetched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (session.isPending || fetched) return;
|
||||
setFetched(true);
|
||||
if (!isAuthenticated) return;
|
||||
getVotedToolIds().then((ids) => setVotedIds(new Set(ids)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.isPending, isAuthenticated, fetched]);
|
||||
|
||||
const toggle = useCallback(
|
||||
(toolId: string) => {
|
||||
if (!isAuthenticated) {
|
||||
// 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数)
|
||||
toast(t("loginRequired"));
|
||||
router.push(`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
return;
|
||||
}
|
||||
if (pendingIds.has(toolId)) return;
|
||||
setPendingIds((prev) => new Set(prev).add(toolId));
|
||||
|
||||
// 乐观更新
|
||||
const wasVoted = votedIds.has(toolId);
|
||||
setVotedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasVoted) next.delete(toolId);
|
||||
else next.add(toolId);
|
||||
return next;
|
||||
});
|
||||
setVoteCounts((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(toolId) ?? 0;
|
||||
next.set(toolId, Math.max(current + (wasVoted ? -1 : 1), 0));
|
||||
return next;
|
||||
});
|
||||
|
||||
toggleVote(toolId)
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
setVoteCounts((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(toolId, result.voteCount);
|
||||
return next;
|
||||
});
|
||||
setVotedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (result.voted) next.add(toolId);
|
||||
else next.delete(toolId);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// 失败回滚
|
||||
setVotedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasVoted) next.add(toolId);
|
||||
else next.delete(toolId);
|
||||
return next;
|
||||
});
|
||||
setVoteCounts((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(toolId) ?? 0;
|
||||
next.set(toolId, Math.max(current + (wasVoted ? 1 : -1), 0));
|
||||
return next;
|
||||
});
|
||||
if (result.error === "RATE_LIMITED") {
|
||||
toast.error(t("rateLimited"));
|
||||
} else {
|
||||
toast.error(t("error"));
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(toolId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
[isAuthenticated, locale, pendingIds, router, t, votedIds]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ voteCounts, votedIds, isAuthenticated, toggle }),
|
||||
[voteCounts, votedIds, isAuthenticated, toggle]
|
||||
);
|
||||
|
||||
return <VotesContext.Provider value={value}>{children}</VotesContext.Provider>;
|
||||
}
|
||||
|
||||
interface VoteButtonProps {
|
||||
toolId: string;
|
||||
/** card: Product Hunt 风格竖排按钮;button: 全宽按钮;icon: 行内图标 */
|
||||
variant?: "card" | "button" | "icon";
|
||||
/** 服务端初始票数 */
|
||||
initialCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 VoteButton
|
||||
* 说明:投票按钮。未登录点击 → 提示并跳转登录(带 redirect 回跳)。
|
||||
*/
|
||||
export function VoteButton({
|
||||
toolId,
|
||||
variant = "icon",
|
||||
initialCount,
|
||||
}: VoteButtonProps) {
|
||||
const ctx = useContext(VotesContext);
|
||||
const t = useTranslations("voting");
|
||||
|
||||
if (!ctx) {
|
||||
// 未包裹 Provider 时渲染静态计数(服务端渲染回退)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
{initialCount ?? 0}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const voted = ctx.votedIds.has(toolId);
|
||||
const count =
|
||||
ctx.voteCounts.get(toolId) ?? initialCount ?? 0;
|
||||
|
||||
if (variant === "card") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ctx.toggle(toolId);
|
||||
}}
|
||||
aria-pressed={voted}
|
||||
aria-label={voted ? t("unvote") : t("vote")}
|
||||
className={cn(
|
||||
"flex h-fit min-w-[4.5rem] flex-col items-center gap-0.5 rounded-xl border px-3 py-2.5 transition-colors",
|
||||
voted
|
||||
? "border-primary/60 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:border-primary/40 hover:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 stroke-[2]" />
|
||||
<span className="text-sm font-semibold tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "button") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ctx.toggle(toolId)}
|
||||
aria-pressed={voted}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors",
|
||||
voted
|
||||
? "border-primary/60 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-hover"
|
||||
)}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 stroke-[2]" />
|
||||
{voted ? t("voted") : t("vote")} · {count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={voted ? t("unvote") : t("vote")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ctx.toggle(toolId);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs transition-colors",
|
||||
voted
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<ChevronUp className={cn("h-3.5 w-3.5 stroke-[2]", voted && "fill-current")} />
|
||||
<span className="tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { event, tool } from "@/lib/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
/** 服务端埋点的事件名白名单(与设计文档 §38 的事件系统一致) */
|
||||
export type AnalyticsEventName =
|
||||
| "tool_view"
|
||||
| "tool_click"
|
||||
| "tool_vote"
|
||||
| "tool_unvote"
|
||||
| "tool_favorite"
|
||||
| "tool_review"
|
||||
| "tool_share"
|
||||
| "search"
|
||||
| "category_view"
|
||||
| "submit_tool"
|
||||
| "claim_tool";
|
||||
|
||||
export interface RecordEventInput {
|
||||
event: AnalyticsEventName;
|
||||
userId?: string | null;
|
||||
sessionId?: string | null;
|
||||
entityType?: string | null;
|
||||
entityId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 recordEvent
|
||||
* 说明:写入一条事件记录。埋点失败不阻塞主流程(吞掉异常仅打日志)。
|
||||
* 参数:input 事件输入
|
||||
* 返回值:void
|
||||
*/
|
||||
export async function recordEvent(input: RecordEventInput): Promise<void> {
|
||||
try {
|
||||
await db.insert(event).values({
|
||||
id: nanoid(),
|
||||
event: input.event,
|
||||
userId: input.userId ?? null,
|
||||
sessionId: input.sessionId ?? null,
|
||||
entityType: input.entityType ?? null,
|
||||
entityId: input.entityId ?? null,
|
||||
metadata: input.metadata ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("recordEvent failed:", input.event, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 recordToolView
|
||||
* 说明:记录工具详情页浏览,并同步冗余计数 tool.viewCount。
|
||||
* 参数:toolId 工具 ID
|
||||
* 返回值:void
|
||||
*/
|
||||
export async function recordToolView(toolId: string): Promise<void> {
|
||||
await recordEvent({
|
||||
event: "tool_view",
|
||||
entityType: "tool",
|
||||
entityId: toolId,
|
||||
});
|
||||
try {
|
||||
await db
|
||||
.update(tool)
|
||||
.set({ viewCount: sql`${tool.viewCount} + 1` })
|
||||
.where(eq(tool.id, toolId));
|
||||
} catch (error) {
|
||||
console.warn("recordToolView count failed:", error);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
/**
|
||||
* auth 客户端:浏览器端跟随当前站点 origin,
|
||||
* 使同一 Core 部署到多域名/多端口站点时 session 检查不跨域。
|
||||
*/
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||
baseURL:
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3001",
|
||||
});
|
||||
|
||||
export const { signIn, signUp, signOut, useSession } = authClient;
|
||||
export const { signIn, signUp, signOut, useSession } = authClient;
|
||||
|
||||
+18
-4
@@ -23,10 +23,24 @@ export const auth = betterAuth({
|
||||
},
|
||||
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.AUTH_GOOGLE_ID!,
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET!,
|
||||
},
|
||||
// Google(配置 env 后自动启用)
|
||||
...(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
|
||||
? {
|
||||
google: {
|
||||
clientId: process.env.AUTH_GOOGLE_ID,
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// GitHub(配置 env 后自动启用)
|
||||
...(process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET
|
||||
? {
|
||||
github: {
|
||||
clientId: process.env.AUTH_GITHUB_ID,
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
||||
trustedOrigins,
|
||||
|
||||
@@ -4,5 +4,14 @@ export * from "./schema/auth";
|
||||
// ========== 工具模块 ==========
|
||||
export * from "./schema/tool";
|
||||
|
||||
// ========== 社区模块(投票 / 评论 / 提交 / 认领) ==========
|
||||
export * from "./schema/community";
|
||||
|
||||
// ========== 发现模块(用例 / 受众 / 合集) ==========
|
||||
export * from "./schema/discovery";
|
||||
|
||||
// ========== 分析模块(事件流) ==========
|
||||
export * from "./schema/analytics";
|
||||
|
||||
// ========== 营销模块 (Newsletter / 联系表单) ==========
|
||||
export * from "./schema/marketing";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// ========== 事件表 ==========
|
||||
/**
|
||||
* # 数据库表
|
||||
* 统一事件流:tool_view / tool_click / tool_vote / tool_favorite /
|
||||
* tool_review / tool_share / search / category_view / submit_tool / claim_tool。
|
||||
* 支撑 Admin Analytics 与 Ranking 的行为信号。
|
||||
*/
|
||||
export const event = pgTable(
|
||||
"event",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
/** # 数据库字段 **/
|
||||
userId: text("user_id"),
|
||||
/** # 数据库字段 **/
|
||||
sessionId: text("session_id"),
|
||||
/** # 数据库字段 **/
|
||||
event: text("event").notNull(),
|
||||
/** # 数据库字段 **/
|
||||
entityType: text("entity_type"),
|
||||
/** # 数据库字段 **/
|
||||
entityId: text("entity_id"),
|
||||
/** # 数据库字段 **/
|
||||
metadata: jsonb("metadata"),
|
||||
/** # 数据库字段 **/
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("event_event_created_idx").on(table.event, table.createdAt),
|
||||
index("event_entity_idx").on(table.entityType, table.entityId),
|
||||
index("event_user_idx").on(table.userId),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { user } from "./auth";
|
||||
import { tool, category } from "./tool";
|
||||
|
||||
// ========== 投票表 ==========
|
||||
/**
|
||||
* # 数据库表
|
||||
* 用户对工具的投票,参考 Product Hunt 的 Upvote 机制。
|
||||
* value 预留扩展(like/upvote/downvote),当前固定为 1。
|
||||
* source 记录投票来源(web/api),用于投票审计与反作弊。
|
||||
*/
|
||||
export const vote = pgTable(
|
||||
"vote",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
/** # 数据库字段 **/
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
toolId: text("tool_id")
|
||||
.notNull()
|
||||
.references(() => tool.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
value: integer("value").notNull().default(1),
|
||||
/** # 数据库字段 **/
|
||||
source: text("source").notNull().default("web"),
|
||||
/** # 数据库字段 **/
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
// 每个用户对同一工具只能投一票
|
||||
uniqueIndex("vote_user_tool_unique").on(table.userId, table.toolId),
|
||||
index("vote_tool_id_idx").on(table.toolId),
|
||||
index("vote_created_at_idx").on(table.createdAt),
|
||||
]
|
||||
);
|
||||
|
||||
// ========== 评论表 ==========
|
||||
/**
|
||||
* # 数据库表
|
||||
* 用户对工具的评论(评分 + 优缺点 + 正文)。
|
||||
* status 支持管理员审核(published / hidden)。
|
||||
*/
|
||||
export const review = pgTable(
|
||||
"review",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
/** # 数据库字段 **/
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
toolId: text("tool_id")
|
||||
.notNull()
|
||||
.references(() => tool.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
rating: integer("rating").notNull(),
|
||||
/** # 数据库字段 **/
|
||||
title: text("title"),
|
||||
/** # 数据库字段 **/
|
||||
content: text("content").notNull(),
|
||||
/** # 数据库字段 **/
|
||||
pros: text("pros"),
|
||||
/** # 数据库字段 **/
|
||||
cons: text("cons"),
|
||||
/** # 数据库字段 **/
|
||||
useCase: text("use_case"),
|
||||
/** # 数据库字段 **/
|
||||
status: text("status").notNull().default("published"),
|
||||
/** # 数据库字段 **/
|
||||
helpfulCount: integer("helpful_count").notNull().default(0),
|
||||
/** # 数据库字段 **/
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
/** # 数据库字段 **/
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
// 每个用户对同一工具只能写一条评论
|
||||
uniqueIndex("review_user_tool_unique").on(table.userId, table.toolId),
|
||||
index("review_tool_status_idx").on(table.toolId, table.status),
|
||||
index("review_created_at_idx").on(table.createdAt),
|
||||
]
|
||||
);
|
||||
|
||||
// ========== 评论"有用"标记表 ==========
|
||||
/**
|
||||
* # 数据库表
|
||||
* 用户对评论的"有帮助"标记,联合唯一防止重复计数。
|
||||
*/
|
||||
export const reviewHelpful = pgTable(
|
||||
"review_helpful",
|
||||
{
|
||||
/** # 数据库字段 **/
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
reviewId: text("review_id")
|
||||
.notNull()
|
||||
.references(() => review.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("review_helpful_unique").on(table.userId, table.reviewId),
|
||||
index("review_helpful_review_idx").on(table.reviewId),
|
||||
]
|
||||
);
|
||||
|
||||
// ========== 工具提交表 ==========
|
||||
/**
|
||||
* # 数据库表
|
||||
* 用户提交的新工具,进入审核队列。
|
||||
* status: pending | approved | rejected
|
||||
* 审核通过后由管理员转化为 tool 记录,记录在 approvedToolId。
|
||||
*/
|
||||
export const submission = pgTable(
|
||||
"submission",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
/** # 数据库字段 **/
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||
/** # 数据库字段 **/
|
||||
name: text("name").notNull(),
|
||||
/** # 数据库字段 **/
|
||||
websiteUrl: text("website_url").notNull(),
|
||||
/** # 数据库字段 **/
|
||||
domain: text("domain"),
|
||||
/** # 数据库字段 **/
|
||||
tagline: text("tagline"),
|
||||
/** # 数据库字段 **/
|
||||
description: text("description"),
|
||||
/** # 数据库字段 **/
|
||||
logoUrl: text("logo_url"),
|
||||
/** # 数据库字段 **/
|
||||
categoryId: text("category_id").references(() => category.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** # 数据库字段 **/
|
||||
pricing: text("pricing"),
|
||||
/** # 数据库字段 **/
|
||||
socialLinks: text("social_links"),
|
||||
/** # 数据库字段 **/
|
||||
screenshots: text("screenshots"),
|
||||
/** # 数据库字段 **/
|
||||
founderName: text("founder_name"),
|
||||
/** # 数据库字段 **/
|
||||
contactEmail: text("contact_email"),
|
||||
/** # 数据库字段 **/
|
||||
note: text("note"),
|
||||
/** # 数据库字段 **/
|
||||
status: text("status").notNull().default("pending"),
|
||||
/** # 数据库字段 **/
|
||||
adminNote: text("admin_note"),
|
||||
/** # 数据库字段 **/
|
||||
approvedToolId: text("approved_tool_id"),
|
||||
/** # 数据库字段 **/
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
/** # 数据库字段 **/
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("submission_status_idx").on(table.status),
|
||||
index("submission_user_idx").on(table.userId),
|
||||
// 同一域名只允许一条待审提交,配合重复检测
|
||||
uniqueIndex("submission_pending_domain_unique")
|
||||
.on(table.domain)
|
||||
.where(sql`status = 'pending'`),
|
||||
]
|
||||
);
|
||||
|
||||
// ========== 工具认领表 ==========
|
||||
/**
|
||||
* # 数据库表
|
||||
* Tool Owner 认领已有工具的申请。
|
||||
* status: pending | approved | rejected
|
||||
* 审核通过后写入 tool.claimedByUserId。
|
||||
*/
|
||||
export const claim = pgTable(
|
||||
"claim",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
/** # 数据库字段 **/
|
||||
toolId: text("tool_id")
|
||||
.notNull()
|
||||
.references(() => tool.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
role: text("role"),
|
||||
/** # 数据库字段 **/
|
||||
message: text("message"),
|
||||
/** # 数据库字段 **/
|
||||
proofUrl: text("proof_url"),
|
||||
/** # 数据库字段 **/
|
||||
status: text("status").notNull().default("pending"),
|
||||
/** # 数据库字段 **/
|
||||
adminNote: text("admin_note"),
|
||||
/** # 数据库字段 **/
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
/** # 数据库字段 **/
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("claim_status_idx").on(table.status),
|
||||
// 同一用户对同一工具只允许一条待审认领
|
||||
uniqueIndex("claim_pending_unique")
|
||||
.on(table.userId, table.toolId)
|
||||
.where(sql`status = 'pending'`),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
primaryKey,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { tool } from "./tool";
|
||||
|
||||
// ========== 用例表 ==========
|
||||
/**
|
||||
* # 数据库表
|
||||
* Use Case 维度:描述用户"想完成什么任务"(如 写博客 / 制作 PPT)。
|
||||
* 与 Category(工具的技术类别)和 Audience(目标人群)构成三维发现体系。
|
||||
*/
|
||||
export const useCase = pgTable(
|
||||
"use_case",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
/** # 数据库字段 **/
|
||||
slug: text("slug").notNull().unique(),
|
||||
/** # 数据库字段 **/
|
||||
icon: text("icon"),
|
||||
/** # 数据库字段 **/
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
/** # 数据库字段 **/
|
||||
nameEn: text("name_en").notNull(),
|
||||
/** # 数据库字段 **/
|
||||
nameZh: text("name_zh"),
|
||||
/** # 数据库字段 **/
|
||||
descriptionEn: text("description_en"),
|
||||
/** # 数据库字段 **/
|
||||
descriptionZh: text("description_zh"),
|
||||
/** # 数据库字段 **/
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
/** # 数据库字段 **/
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("use_case_slug_idx").on(table.slug),
|
||||
index("use_case_sort_order_idx").on(table.sortOrder),
|
||||
]
|
||||
);
|
||||
|
||||
// ========== 受众表 ==========
|
||||
/**
|
||||
* # 数据库表
|
||||
* Audience 维度:工具面向的人群(Developers / Marketers / Students...)。
|
||||
*/
|
||||
export const audience = pgTable(
|
||||
"audience",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
/** # 数据库字段 **/
|
||||
slug: text("slug").notNull().unique(),
|
||||
/** # 数据库字段 **/
|
||||
icon: text("icon"),
|
||||
/** # 数据库字段 **/
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
/** # 数据库字段 **/
|
||||
nameEn: text("name_en").notNull(),
|
||||
/** # 数据库字段 **/
|
||||
nameZh: text("name_zh"),
|
||||
/** # 数据库字段 **/
|
||||
descriptionEn: text("description_en"),
|
||||
/** # 数据库字段 **/
|
||||
descriptionZh: text("description_zh"),
|
||||
/** # 数据库字段 **/
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
/** # 数据库字段 **/
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("audience_slug_idx").on(table.slug),
|
||||
index("audience_sort_order_idx").on(table.sortOrder),
|
||||
]
|
||||
);
|
||||
|
||||
// ========== 工具-用例关联表 ==========
|
||||
export const toolUseCase = pgTable(
|
||||
"tool_use_case",
|
||||
{
|
||||
/** # 数据库字段 **/
|
||||
toolId: text("tool_id")
|
||||
.notNull()
|
||||
.references(() => tool.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
useCaseId: text("use_case_id")
|
||||
.notNull()
|
||||
.references(() => useCase.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.toolId, table.useCaseId] }),
|
||||
index("tool_use_case_tool_idx").on(table.toolId),
|
||||
index("tool_use_case_use_case_idx").on(table.useCaseId),
|
||||
]
|
||||
);
|
||||
|
||||
// ========== 工具-受众关联表 ==========
|
||||
export const toolAudience = pgTable(
|
||||
"tool_audience",
|
||||
{
|
||||
/** # 数据库字段 **/
|
||||
toolId: text("tool_id")
|
||||
.notNull()
|
||||
.references(() => tool.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
audienceId: text("audience_id")
|
||||
.notNull()
|
||||
.references(() => audience.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.toolId, table.audienceId] }),
|
||||
index("tool_audience_tool_idx").on(table.toolId),
|
||||
index("tool_audience_audience_idx").on(table.audienceId),
|
||||
]
|
||||
);
|
||||
|
||||
// ========== 合集表 ==========
|
||||
/**
|
||||
* # 数据库表
|
||||
* 编辑策划的 Best List / Collection(如 Best AI Writing Tools)。
|
||||
* 是 Collection SEO 的载体。
|
||||
*/
|
||||
export const collection = pgTable(
|
||||
"collection",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
/** # 数据库字段 **/
|
||||
slug: text("slug").notNull().unique(),
|
||||
/** # 数据库字段 **/
|
||||
icon: text("icon"),
|
||||
/** # 数据库字段 **/
|
||||
coverImageUrl: text("cover_image_url"),
|
||||
/** # 数据库字段 **/
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
/** # 数据库字段 **/
|
||||
nameEn: text("name_en").notNull(),
|
||||
/** # 数据库字段 **/
|
||||
nameZh: text("name_zh"),
|
||||
/** # 数据库字段 **/
|
||||
introEn: text("intro_en"),
|
||||
/** # 数据库字段 **/
|
||||
introZh: text("intro_zh"),
|
||||
/** # 数据库字段 **/
|
||||
status: text("status").notNull().default("published"),
|
||||
/** # 数据库字段 **/
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
/** # 数据库字段 **/
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("collection_slug_idx").on(table.slug),
|
||||
index("collection_status_idx").on(table.status),
|
||||
]
|
||||
);
|
||||
|
||||
// ========== 合集-工具关联表 ==========
|
||||
export const collectionTool = pgTable(
|
||||
"collection_tool",
|
||||
{
|
||||
/** # 数据库字段 **/
|
||||
collectionId: text("collection_id")
|
||||
.notNull()
|
||||
.references(() => collection.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
toolId: text("tool_id")
|
||||
.notNull()
|
||||
.references(() => tool.id, { onDelete: "cascade" }),
|
||||
/** # 数据库字段 **/
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
/** # 数据库字段 **/
|
||||
note: text("note"),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.collectionId, table.toolId] }),
|
||||
index("collection_tool_collection_idx").on(table.collectionId),
|
||||
index("collection_tool_tool_idx").on(table.toolId),
|
||||
]
|
||||
);
|
||||
@@ -1,4 +1,13 @@
|
||||
import { pgTable, text, timestamp, integer, boolean, primaryKey, index } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
boolean,
|
||||
real,
|
||||
primaryKey,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { user } from "./auth";
|
||||
|
||||
// ========== 工具表 ==========
|
||||
@@ -18,16 +27,38 @@ export const tool = pgTable("tool", {
|
||||
nameZh: text("name_zh"),
|
||||
descriptionZh: text("description_zh"),
|
||||
|
||||
// 定价: free | freemium | paid
|
||||
// 一句话简介(Tool Detail / 卡片 / Launch 用)
|
||||
/** # 数据库字段:英文一句话简介 **/
|
||||
taglineEn: text("tagline_en"),
|
||||
/** # 数据库字段:中文一句话简介 **/
|
||||
taglineZh: text("tagline_zh"),
|
||||
|
||||
// 定价: free | freemium | paid | free_trial | contact | open_source
|
||||
pricing: text("pricing"),
|
||||
|
||||
// 首页精选标记
|
||||
// 首页精选标记(Editor's Choice)
|
||||
featured: boolean("featured").notNull().default(false),
|
||||
|
||||
// 浏览量
|
||||
// 浏览量(冗余计数,由事件系统同步)
|
||||
viewCount: integer("view_count").notNull().default(0),
|
||||
/** # 数据库字段:外链点击数(冗余计数) **/
|
||||
clickCount: integer("click_count").notNull().default(0),
|
||||
/** # 数据库字段:投票数(冗余计数) **/
|
||||
voteCount: integer("vote_count").notNull().default(0),
|
||||
/** # 数据库字段:收藏数(冗余计数) **/
|
||||
favoriteCount: integer("favorite_count").notNull().default(0),
|
||||
/** # 数据库字段:评论数(冗余计数,仅 published) **/
|
||||
reviewCount: integer("review_count").notNull().default(0),
|
||||
/** # 数据库字段:平均评分(0-5) **/
|
||||
ratingAvg: real("rating_avg").notNull().default(0),
|
||||
|
||||
// 状态: draft | published
|
||||
// 认领:tool owner
|
||||
/** # 数据库字段:认领该工具的用户 **/
|
||||
claimedByUserId: text("claimed_by_user_id").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
// 状态: draft | published | archived
|
||||
status: text("status").notNull().default("draft"),
|
||||
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
@@ -40,6 +71,10 @@ export const tool = pgTable("tool", {
|
||||
index("tool_status_idx").on(table.status),
|
||||
index("tool_featured_idx").on(table.featured),
|
||||
index("tool_pricing_idx").on(table.pricing),
|
||||
// Ranking 常用排序
|
||||
index("tool_vote_count_idx").on(table.voteCount),
|
||||
index("tool_rating_idx").on(table.ratingAvg),
|
||||
index("tool_domain_idx").on(table.domain),
|
||||
]);
|
||||
|
||||
// ========== 分类表 ==========
|
||||
|
||||
+9
-2
@@ -1,6 +1,13 @@
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
// 惰性初始化:构建/无 API key 场景下不抛错,真正发信时才构造
|
||||
let _resend: Resend | null = null;
|
||||
function getResend(): Resend {
|
||||
if (!_resend) {
|
||||
_resend = new Resend(process.env.RESEND_API_KEY || 're_placeholder_no_key');
|
||||
}
|
||||
return _resend;
|
||||
}
|
||||
|
||||
// 获取默认发件邮箱的函数
|
||||
const getDefaultFromEmail = () => {
|
||||
@@ -50,7 +57,7 @@ export async function sendEmail({
|
||||
replyTo,
|
||||
}: SendEmailOptions) {
|
||||
try {
|
||||
const data = await resend.emails.send({
|
||||
const data = await getResend().emails.send({
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RankingBoard } from "./score";
|
||||
|
||||
/**
|
||||
* # 函数 parseRankingBoard
|
||||
* 说明:容错解析 URL 中的榜单参数;非法值回退 trending。
|
||||
*/
|
||||
export function parseRankingBoard(value: string | undefined | null): RankingBoard {
|
||||
const valid: RankingBoard[] = [
|
||||
"trending",
|
||||
"popular",
|
||||
"most-voted",
|
||||
"most-saved",
|
||||
"highest-rated",
|
||||
"new",
|
||||
"editors-choice",
|
||||
];
|
||||
if (value && (valid as string[]).includes(value)) return value as RankingBoard;
|
||||
return "trending";
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
tool,
|
||||
vote,
|
||||
favorite,
|
||||
review,
|
||||
event,
|
||||
category,
|
||||
toolCategory,
|
||||
} from "@/lib/db/schema";
|
||||
import { and, desc, eq, gte, gt, sql, inArray } from "drizzle-orm";
|
||||
import {
|
||||
type RankingBoard,
|
||||
type ToolSignals,
|
||||
trendingScore,
|
||||
bayesianRating,
|
||||
popularScore,
|
||||
} from "./score";
|
||||
|
||||
/** 榜单条目的最小类型(完整 tool 行 + 评分上下文) */
|
||||
export type RankedTool = typeof tool.$inferSelect;
|
||||
|
||||
/** Trending 默认统计窗口(天) */
|
||||
export const TRENDING_WINDOW_DAYS = 7;
|
||||
|
||||
/** Trending 榜单最小展示数量 */
|
||||
const DEFAULT_LIMIT = 12;
|
||||
|
||||
/**
|
||||
* # 函数 getTrendingTools
|
||||
* 说明:Trending 榜 —— 窗口期互动加权 ÷ 新鲜度衰减(纯函数计算,便于测试与调参)。
|
||||
* 参数:limit 数量;windowDays 统计窗口
|
||||
* 返回值:按 trending 分数降序的工具列表
|
||||
*/
|
||||
export async function getTrendingTools(
|
||||
limit = DEFAULT_LIMIT,
|
||||
windowDays = TRENDING_WINDOW_DAYS
|
||||
): Promise<RankedTool[]> {
|
||||
const windowStart = new Date(Date.now() - windowDays * 24 * 3600 * 1000);
|
||||
|
||||
// 窗口期内的四类行为计数(并行查询)
|
||||
const [voteRows, favoriteRows, reviewRows, viewRows, candidates] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ toolId: vote.toolId, cnt: sql<number>`count(*)` })
|
||||
.from(vote)
|
||||
.where(gte(vote.createdAt, windowStart))
|
||||
.groupBy(vote.toolId),
|
||||
db
|
||||
.select({ toolId: favorite.toolId, cnt: sql<number>`count(*)` })
|
||||
.from(favorite)
|
||||
.where(gte(favorite.createdAt, windowStart))
|
||||
.groupBy(favorite.toolId),
|
||||
db
|
||||
.select({ toolId: review.toolId, cnt: sql<number>`count(*)` })
|
||||
.from(review)
|
||||
.where(
|
||||
and(gte(review.createdAt, windowStart), eq(review.status, "published"))
|
||||
)
|
||||
.groupBy(review.toolId),
|
||||
db
|
||||
.select({ entityId: event.entityId, cnt: sql<number>`count(*)` })
|
||||
.from(event)
|
||||
.where(
|
||||
and(
|
||||
eq(event.event, "tool_view"),
|
||||
gte(event.createdAt, windowStart),
|
||||
eq(event.entityType, "tool")
|
||||
)
|
||||
)
|
||||
.groupBy(event.entityId),
|
||||
db
|
||||
.select()
|
||||
.from(tool)
|
||||
.where(eq(tool.status, "published"))
|
||||
.limit(2000),
|
||||
]);
|
||||
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const toMap = (
|
||||
rows: { toolId?: string | null; entityId?: string | null; cnt: number }[]
|
||||
) => {
|
||||
const map = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const key = row.toolId ?? row.entityId;
|
||||
if (key) map.set(key, Number(row.cnt));
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
const voteMap = toMap(voteRows);
|
||||
const favoriteMap = toMap(favoriteRows);
|
||||
const reviewMap = toMap(reviewRows);
|
||||
const viewMap = toMap(viewRows);
|
||||
|
||||
const now = Date.now();
|
||||
const scored = candidates
|
||||
.map((t) => {
|
||||
const signals: ToolSignals = {
|
||||
votesInWindow: voteMap.get(t.id) ?? 0,
|
||||
favoritesInWindow: favoriteMap.get(t.id) ?? 0,
|
||||
reviewsInWindow: reviewMap.get(t.id) ?? 0,
|
||||
viewsInWindow: viewMap.get(t.id) ?? 0,
|
||||
ageHours: (now - new Date(t.createdAt).getTime()) / 3600000,
|
||||
};
|
||||
return { toolRow: t, score: trendingScore(signals) };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
|
||||
// 分数为 0 的工具不进入 Trending 榜(避免空榜时全量填充)
|
||||
return scored.filter((s) => s.score > 0).map((s) => s.toolRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getPopularTools
|
||||
* 说明:Popular 榜 —— 全量互动总分(投票/收藏/评论/浏览加权)。
|
||||
*/
|
||||
export async function getPopularTools(limit = DEFAULT_LIMIT): Promise<RankedTool[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(tool)
|
||||
.where(eq(tool.status, "published"))
|
||||
.orderBy(
|
||||
desc(
|
||||
sql`${tool.voteCount} * 2 + ${tool.favoriteCount} * 1.5 + ${tool.reviewCount} * 3 + ${tool.viewCount} * 0.1`
|
||||
),
|
||||
desc(tool.createdAt)
|
||||
)
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getMostVotedTools
|
||||
* 说明:Most Voted 榜 —— 按累计投票数排序。
|
||||
*/
|
||||
export async function getMostVotedTools(
|
||||
limit = DEFAULT_LIMIT
|
||||
): Promise<RankedTool[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(tool)
|
||||
.where(eq(tool.status, "published"))
|
||||
.orderBy(desc(tool.voteCount), desc(tool.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getMostSavedTools
|
||||
* 说明:Most Saved 榜 —— 按收藏数排序。
|
||||
*/
|
||||
export async function getMostSavedTools(
|
||||
limit = DEFAULT_LIMIT
|
||||
): Promise<RankedTool[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(tool)
|
||||
.where(eq(tool.status, "published"))
|
||||
.orderBy(desc(tool.favoriteCount), desc(tool.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getHighestRatedTools
|
||||
* 说明:Highest Rated 榜 —— 贝叶斯平均分排序,评论过少的工具被先验拉回。
|
||||
*/
|
||||
export async function getHighestRatedTools(
|
||||
limit = DEFAULT_LIMIT
|
||||
): Promise<RankedTool[]> {
|
||||
const [candidates, globalRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(tool)
|
||||
.where(and(eq(tool.status, "published"), gt(tool.reviewCount, 0)))
|
||||
.limit(1000),
|
||||
db
|
||||
.select({
|
||||
avg: sql<number>`COALESCE(AVG(${tool.ratingAvg}), 0)`,
|
||||
})
|
||||
.from(tool)
|
||||
.where(and(eq(tool.status, "published"), gt(tool.reviewCount, 0))),
|
||||
]);
|
||||
|
||||
const globalAvg = Number(globalRows[0]?.avg ?? 0);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
return candidates
|
||||
.map((t) => ({
|
||||
toolRow: t,
|
||||
score: bayesianRating(t.ratingAvg, t.reviewCount, globalAvg),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map((s) => s.toolRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getNewTools
|
||||
* 说明:New 榜 —— 最新发布。
|
||||
*/
|
||||
export async function getNewTools(limit = DEFAULT_LIMIT): Promise<RankedTool[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(tool)
|
||||
.where(eq(tool.status, "published"))
|
||||
.orderBy(desc(tool.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getEditorsChoiceTools
|
||||
* 说明:Editor's Choice 榜 —— 管理员 featured 标记。
|
||||
*/
|
||||
export async function getEditorsChoiceTools(
|
||||
limit = DEFAULT_LIMIT
|
||||
): Promise<RankedTool[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(tool)
|
||||
.where(and(eq(tool.status, "published"), eq(tool.featured, true)))
|
||||
.orderBy(desc(tool.ratingAvg), desc(tool.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getRankedTools
|
||||
* 说明:统一榜单入口(Ranking Engine 对外接口)。
|
||||
* 未来可在此挂接站点级 Ranking 策略(Product Hunt 式 / 编辑式等)。
|
||||
* 参数:board 榜单标识;limit 数量
|
||||
* 返回值:榜单工具列表
|
||||
*/
|
||||
export async function getRankedTools(
|
||||
board: RankingBoard,
|
||||
limit = DEFAULT_LIMIT
|
||||
): Promise<RankedTool[]> {
|
||||
switch (board) {
|
||||
case "trending":
|
||||
return getTrendingTools(limit);
|
||||
case "popular":
|
||||
return getPopularTools(limit);
|
||||
case "most-voted":
|
||||
return getMostVotedTools(limit);
|
||||
case "most-saved":
|
||||
return getMostSavedTools(limit);
|
||||
case "highest-rated":
|
||||
return getHighestRatedTools(limit);
|
||||
case "new":
|
||||
return getNewTools(limit);
|
||||
case "editors-choice":
|
||||
return getEditorsChoiceTools(limit);
|
||||
}
|
||||
}
|
||||
|
||||
/** 全站 Popular 分数排序键(SQL 片段,供列表页复用) */
|
||||
export function popularScoreSql() {
|
||||
return sql`${tool.voteCount} * 2 + ${tool.favoriteCount} * 1.5 + ${tool.reviewCount} * 3 + ${tool.viewCount} * 0.1`;
|
||||
}
|
||||
|
||||
// popularScore 在 JS 侧的引用(供测试对齐 SQL 权重)
|
||||
export const POPULAR_WEIGHTS = popularScore;
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* # Ranking 评分引擎(纯函数)
|
||||
*
|
||||
* 与数据库解耦,便于单元测试与跨站点复用。
|
||||
* 设计原则(见设计文档 §16/§17):
|
||||
* - Trending = 热度 × 新鲜度 × 速度 × 互动,避免老工具永久霸榜
|
||||
* - 评分采用贝叶斯先验,避免"1 条 5 星评论"排到"50 条 4.5 星"前面
|
||||
*/
|
||||
|
||||
/** 排名榜单标识 */
|
||||
export type RankingBoard =
|
||||
| "trending"
|
||||
| "popular"
|
||||
| "most-voted"
|
||||
| "most-saved"
|
||||
| "highest-rated"
|
||||
| "new"
|
||||
| "editors-choice";
|
||||
|
||||
/** 工具的行为信号输入 */
|
||||
export interface ToolSignals {
|
||||
/** 窗口期(默认 7 天)内的投票数 */
|
||||
votesInWindow: number;
|
||||
/** 窗口期内的收藏数 */
|
||||
favoritesInWindow: number;
|
||||
/** 窗口期内的评论数 */
|
||||
reviewsInWindow: number;
|
||||
/** 窗口期内的浏览数 */
|
||||
viewsInWindow: number;
|
||||
/** 工具创建至今的小时数 */
|
||||
ageHours: number;
|
||||
}
|
||||
|
||||
/** Trending 衰减参数:半衰期权重,越小对新工具越友好 */
|
||||
export const TRENDING_DECAY = 0.6;
|
||||
/** Trending 时间平滑项(小时):避免刚上线 0 小时的工具除零且分数爆炸 */
|
||||
export const TRENDING_AGE_SMOOTHING_HOURS = 6;
|
||||
|
||||
/**
|
||||
* # 函数 trendingScore
|
||||
* 说明:Trending 分数 = 加权互动 ÷ 年龄^衰减。
|
||||
* 互动权重:评论(4) > 投票(3) > 收藏(2) > 浏览(0.2),
|
||||
* 评论权重最高因为其生产成本最高(最强的社区信号)。
|
||||
* 参数:signals 工具行为信号
|
||||
* 返回值:>= 0 的分数,越大越热
|
||||
*/
|
||||
export function trendingScore(signals: ToolSignals): number {
|
||||
const engagement =
|
||||
signals.reviewsInWindow * 4 +
|
||||
signals.votesInWindow * 3 +
|
||||
signals.favoritesInWindow * 2 +
|
||||
signals.viewsInWindow * 0.2;
|
||||
|
||||
if (engagement <= 0) return 0;
|
||||
|
||||
const age = Math.max(signals.ageHours, 0) + TRENDING_AGE_SMOOTHING_HOURS;
|
||||
return engagement / Math.pow(age, TRENDING_DECAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 timeDecayFactor
|
||||
* 说明:通用时间衰减因子,可用于把任意"全量热度"折算为"近期热度"。
|
||||
* 参数:ageHours 距今小时数;halfLifeHours 半衰期
|
||||
* 返回值:(0, 1] 的衰减系数
|
||||
*/
|
||||
export function timeDecayFactor(ageHours: number, halfLifeHours = 168): number {
|
||||
if (ageHours <= 0) return 1;
|
||||
return Math.pow(0.5, ageHours / halfLifeHours);
|
||||
}
|
||||
|
||||
/** 贝叶斯评分参数:先验权重(等效 m 条平均分的"虚拟评论") */
|
||||
export const BAYES_PRIOR_WEIGHT = 5;
|
||||
|
||||
/**
|
||||
* # 函数 bayesianRating
|
||||
* 说明:贝叶斯平均评分 (avg*n + prior*m) / (n + m)。
|
||||
* 评论数少的新工具不会因为一条 5 星评论霸占 Highest Rated 榜。
|
||||
* 参数:avg 观测平均分;n 评论数;globalAvg 全站平均分;m 先验权重
|
||||
* 返回值:0-5 之间的折算评分
|
||||
*/
|
||||
export function bayesianRating(
|
||||
avg: number,
|
||||
n: number,
|
||||
globalAvg: number,
|
||||
m = BAYES_PRIOR_WEIGHT
|
||||
): number {
|
||||
if (n <= 0) return 0;
|
||||
return (avg * n + globalAvg * m) / (n + m);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 popularScore
|
||||
* 说明:全量互动总分(不衰减),用于 Popular 榜。
|
||||
* 参数:总投票/收藏/评论/浏览
|
||||
* 返回值:>= 0
|
||||
*/
|
||||
export function popularScore(
|
||||
votes: number,
|
||||
favorites: number,
|
||||
reviews: number,
|
||||
views: number
|
||||
): number {
|
||||
return votes * 2 + favorites * 1.5 + reviews * 3 + views * 0.1;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 parseToolSlugRoute
|
||||
* 说明:解析 /tools/[slug] 的复合路由:
|
||||
* - "chatgpt-alternatives" → { kind: "alternatives", base: "chatgpt" }
|
||||
* - "cursor-vs-windsurf" → { kind: "comparison", a: "cursor", b: "windsurf" }
|
||||
* - 普通工具 → { kind: "tool", slug }
|
||||
* 参数:slug 路由参数
|
||||
* 返回值:解析结果;无法识别时 kind = "tool"
|
||||
*/
|
||||
export type ParsedToolSlug =
|
||||
| { kind: "tool"; slug: string }
|
||||
| { kind: "alternatives"; base: string }
|
||||
| { kind: "comparison"; a: string; b: string };
|
||||
|
||||
export function parseToolSlugRoute(slug: string): ParsedToolSlug {
|
||||
const alternativesMatch = slug.match(/^(.+)-alternatives$/);
|
||||
if (alternativesMatch && alternativesMatch[1]) {
|
||||
return { kind: "alternatives", base: alternativesMatch[1] };
|
||||
}
|
||||
|
||||
const vsMatch = slug.match(/^(.+)-vs-(.+)$/);
|
||||
if (vsMatch && vsMatch[1] && vsMatch[2]) {
|
||||
return { kind: "comparison", a: vsMatch[1], b: vsMatch[2] };
|
||||
}
|
||||
|
||||
return { kind: "tool", slug };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 normalizeDomain
|
||||
* 说明:把任意网址/域名归一化为注册域比较键,用于重复工具检测。
|
||||
* "https://chat.openai.com/chatgpt?x=1" → "openai.com"
|
||||
* "www.Anthropic.com/" → "anthropic.com"
|
||||
* 参数:input URL 或域名
|
||||
* 返回值:小写、去 www、去路径的主机名;无法解析返回 null
|
||||
*/
|
||||
export function normalizeDomain(input: string): string | null {
|
||||
if (!input) return null;
|
||||
let host = input.trim().toLowerCase();
|
||||
if (!/^https?:\/\//.test(host)) {
|
||||
host = `https://${host}`;
|
||||
}
|
||||
try {
|
||||
const url = new URL(host);
|
||||
host = url.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
host = host.replace(/^(www\d*)\./, "");
|
||||
if (!host || !host.includes(".")) return null;
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 slugify
|
||||
* 说明:把工具名转为 URL slug;保留字母数字与连字符。
|
||||
* 参数:name 名称
|
||||
* 返回值:slug
|
||||
*/
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* # SEO Page Generator(Programmatic SEO 引擎)
|
||||
*
|
||||
* 输入实体(Category / Use Case / Audience / Collection / Tool),
|
||||
* 输出结构化页面内容:Title / H1 / Introduction / FAQ / Related Links / Schema。
|
||||
*
|
||||
* 设计约束(设计文档 §24):避免大量低质量重复模板页 ——
|
||||
* 所有文案均由实体真实数据(名称、定价分布、榜单位置)驱动,
|
||||
* 且页面内容包含真实工具列表而非空壳。
|
||||
*/
|
||||
|
||||
export interface SeoEntityInput {
|
||||
/** 实体名称(如 "Writing" / "Blogging" / "Students") */
|
||||
name: string;
|
||||
/** 实体类型 */
|
||||
kind: "category" | "use-case" | "audience" | "collection" | "pricing";
|
||||
/** 站点品牌名 */
|
||||
brandName: string;
|
||||
/** 关联工具数 */
|
||||
toolCount: number;
|
||||
/** 工具定价分布 */
|
||||
freeCount?: number;
|
||||
/** 实体描述(可空) */
|
||||
description?: string | null;
|
||||
/** 年份(标题 freshness) */
|
||||
year?: number;
|
||||
/** Top 工具名列表(用于 FAQ 与 intro 具体化) */
|
||||
topToolNames?: string[];
|
||||
/** 受众名(use case 页可交叉引用) */
|
||||
audienceName?: string | null;
|
||||
}
|
||||
|
||||
export interface GeneratedFaq {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface GeneratedSeoPage {
|
||||
/** <title> 文案 */
|
||||
title: string;
|
||||
/** meta description */
|
||||
description: string;
|
||||
/** 页面 H1 */
|
||||
h1: string;
|
||||
/** 页首介绍段落 */
|
||||
intro: string;
|
||||
/** FAQ 列表(用于 FAQPage schema + 页面渲染) */
|
||||
faqs: GeneratedFaq[];
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 generateSeoPage
|
||||
* 说明:由实体数据生成 SEO 页面文案。英文版(默认 locale)。
|
||||
* 参数:entity 实体输入
|
||||
* 返回值:title/description/h1/intro/faqs
|
||||
*/
|
||||
export function generateSeoPage(entity: SeoEntityInput): GeneratedSeoPage {
|
||||
const year = entity.year ?? new Date().getFullYear();
|
||||
const names = entity.topToolNames?.filter(Boolean).slice(0, 3) ?? [];
|
||||
const toolListPhrase = names.length
|
||||
? names.join(", ")
|
||||
: "our curated selection";
|
||||
const countPhrase = entity.toolCount > 0 ? `${entity.toolCount}` : "hand-picked";
|
||||
|
||||
switch (entity.kind) {
|
||||
case "category":
|
||||
case "use-case":
|
||||
case "audience":
|
||||
case "collection": {
|
||||
const kindLabel =
|
||||
entity.kind === "use-case"
|
||||
? "AI Tools"
|
||||
: entity.kind === "audience"
|
||||
? "AI Tools"
|
||||
: entity.kind === "collection"
|
||||
? "AI Tools"
|
||||
: "AI Tools";
|
||||
const title = `Best ${kindLabel} for ${entity.name} in ${year} | ${entity.brandName}`;
|
||||
const h1 =
|
||||
entity.kind === "collection"
|
||||
? entity.name
|
||||
: `Best ${kindLabel} for ${entity.name}`;
|
||||
const description = `Discover the ${countPhrase} best ${kindLabel.toLowerCase()} for ${entity.name.toLowerCase()}, ranked by community votes and reviews. Compare pricing, features and alternatives.`;
|
||||
const intro = `We ranked ${countPhrase} ${kindLabel.toLowerCase()} for ${entity.name.toLowerCase()} based on real community votes, reviews and engagement data${
|
||||
entity.description ? `. ${trimSentence(entity.description)}` : "."
|
||||
} The leaderboard updates continuously, so tools rise and fall based on how the community actually uses them.`;
|
||||
const faqs: GeneratedFaq[] = [
|
||||
{
|
||||
question: `What are the best ${kindLabel.toLowerCase()} for ${entity.name.toLowerCase()}?`,
|
||||
answer: `Based on community votes and reviews, the top options right now include ${toolListPhrase}. Rankings update continuously as users vote, review and save tools.`,
|
||||
},
|
||||
{
|
||||
question: `How do you rank ${kindLabel.toLowerCase()} for ${entity.name.toLowerCase()}?`,
|
||||
answer: `We combine multiple signals: community upvotes, verified user reviews, saves (bookmarks), views and freshness. No single metric decides the ranking, and sponsored placements are always labeled separately.`,
|
||||
},
|
||||
{
|
||||
question: `Are there free ${kindLabel.toLowerCase()} for ${entity.name.toLowerCase()}?`,
|
||||
answer:
|
||||
entity.freeCount && entity.freeCount > 0
|
||||
? `Yes — ${entity.freeCount} of the ${countPhrase} tools listed offer a free plan or free tier. Use the pricing filter to see only free options.`
|
||||
: `Many of the listed tools offer a free plan or free trial. Use the pricing filter on this page to find free options quickly.`,
|
||||
},
|
||||
];
|
||||
if (entity.kind === "use-case" && entity.audienceName) {
|
||||
faqs.push({
|
||||
question: `Which tool should a ${singular(entity.audienceName.toLowerCase())} start with?`,
|
||||
answer: `Start with ${toolListPhrase} — they consistently rank highest with ${entity.audienceName.toLowerCase()} on ${entity.brandName}. Check the pros and cons in each tool's reviews to match your workflow.`,
|
||||
});
|
||||
}
|
||||
return { title, description, h1, intro, faqs };
|
||||
}
|
||||
case "pricing": {
|
||||
const tier = entity.name;
|
||||
const title = `Free ${tier} AI Tools in ${year} | ${entity.brandName}`;
|
||||
const h1 = `Best Free ${tier} AI Tools`;
|
||||
const description = `Browse ${countPhrase} free ${tier.toLowerCase()} AI tools ranked by community votes and reviews on ${entity.brandName}.`;
|
||||
const intro = `Every tool on this page is free to start${
|
||||
entity.toolCount ? ` — ${entity.toolCount} tools matched` : ""
|
||||
}. Rankings come from community votes and reviews, not sponsored placement.`;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
h1,
|
||||
intro,
|
||||
faqs: [
|
||||
{
|
||||
question: `Are these ${tier.toLowerCase()} AI tools really free?`,
|
||||
answer: `Yes. Tools tagged "${tier}" either offer a permanent free plan or a genuinely usable free tier. We re-check pricing periodically and the community reports changes in reviews.`,
|
||||
},
|
||||
{
|
||||
question: `What is the most popular free AI tool?`,
|
||||
answer: `Right now the community favors ${toolListPhrase}. See vote counts on each card — they update in real time.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 generateToolFaqs
|
||||
* 说明:为 Tool Detail 页生成 FAQ(SoftwareApplication + FAQPage schema 共用)。
|
||||
* 全部由工具真实字段驱动(描述、定价、类别、替代品)。
|
||||
* 参数:tool 工具数据;alternatives 替代工具名列表
|
||||
* 返回值:FAQ 列表
|
||||
*/
|
||||
export function generateToolFaqs(
|
||||
input: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
pricing?: string | null;
|
||||
categoryName?: string | null;
|
||||
},
|
||||
alternatives: string[] = []
|
||||
): GeneratedFaq[] {
|
||||
const faqs: GeneratedFaq[] = [
|
||||
{
|
||||
question: `What is ${input.name}?`,
|
||||
answer:
|
||||
trimSentence(input.description || "") ||
|
||||
`${input.name} is an AI tool listed on our directory.`,
|
||||
},
|
||||
{
|
||||
question: `Is ${input.name} free to use?`,
|
||||
answer: pricingAnswer(input.pricing, input.name),
|
||||
},
|
||||
];
|
||||
if (alternatives.length > 0) {
|
||||
faqs.push({
|
||||
question: `What are alternatives to ${input.name}?`,
|
||||
answer: `Popular alternatives to ${input.name} include ${alternatives
|
||||
.slice(0, 4)
|
||||
.join(", ")}. Compare ratings, pricing and reviews to pick the right fit.`,
|
||||
});
|
||||
}
|
||||
if (input.categoryName) {
|
||||
faqs.push({
|
||||
question: `What category does ${input.name} belong to?`,
|
||||
answer: `${input.name} is listed under ${input.categoryName}. Browse the category to see how it stacks up against similar tools.`,
|
||||
});
|
||||
}
|
||||
return faqs;
|
||||
}
|
||||
|
||||
function pricingAnswer(pricing: string | null | undefined, name: string): string {
|
||||
switch (pricing) {
|
||||
case "free":
|
||||
return `Yes, ${name} is free to use.`;
|
||||
case "freemium":
|
||||
return `${name} uses a freemium model: a free plan is available, with paid upgrades for advanced features.`;
|
||||
case "free_trial":
|
||||
return `${name} offers a free trial. After the trial, a paid plan is required.`;
|
||||
case "open_source":
|
||||
return `${name} is open source — you can self-host it for free.`;
|
||||
case "contact":
|
||||
return `${name} does not publish pricing publicly; contact the vendor for a quote.`;
|
||||
case "paid":
|
||||
return `${name} is a paid tool. Check the vendor's website for current plans.`;
|
||||
default:
|
||||
return `Visit ${name}'s website for current pricing details.`;
|
||||
}
|
||||
}
|
||||
|
||||
function trimSentence(text: string): string {
|
||||
const t = text.trim();
|
||||
if (!t) return "";
|
||||
return t.endsWith(".") || t.endsWith("!") || t.endsWith("?") ? t : `${t}.`;
|
||||
}
|
||||
|
||||
function singular(word: string): string {
|
||||
if (word.endsWith("ies")) return `${word.slice(0, -3)}y`;
|
||||
if (word.endsWith("ses")) return word.slice(0, -2);
|
||||
if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
|
||||
return word;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { tool } from "@/lib/db/schema";
|
||||
import { eq, or, sql } from "drizzle-orm";
|
||||
import { normalizeDomain, slugify } from "@/lib/ranking/score";
|
||||
|
||||
export interface DuplicateCheckResult {
|
||||
/** 是否疑似重复 */
|
||||
duplicate: boolean;
|
||||
/** 命中的已有工具 */
|
||||
matches: { slug: string; name: string; domain: string | null }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 checkDuplicateTool
|
||||
* 说明:提交工具时的重复检测(设计文档 §35)。
|
||||
* 三层检测:归一化域名精确匹配 → slug 精确匹配 → 名称 LIKE 匹配。
|
||||
* 参数:websiteUrl 官网;name 工具名
|
||||
* 返回值:{ duplicate, matches }
|
||||
*/
|
||||
export async function checkDuplicateTool(
|
||||
websiteUrl: string,
|
||||
name: string
|
||||
): Promise<DuplicateCheckResult> {
|
||||
const domain = normalizeDomain(websiteUrl);
|
||||
const slug = slugify(name);
|
||||
|
||||
const conditions = [];
|
||||
if (domain) {
|
||||
conditions.push(eq(tool.domain, domain));
|
||||
conditions.push(
|
||||
sql`LOWER(REPLACE(REPLACE(${tool.websiteUrl}, 'https://', ''), 'http://', '')) LIKE ${`%${domain}%`}`
|
||||
);
|
||||
}
|
||||
if (slug) {
|
||||
conditions.push(eq(tool.slug, slug));
|
||||
}
|
||||
if (name.trim()) {
|
||||
conditions.push(sql`LOWER(${tool.nameEn}) = ${name.trim().toLowerCase()}`);
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return { duplicate: false, matches: [] };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
slug: tool.slug,
|
||||
name: tool.nameEn,
|
||||
domain: tool.domain,
|
||||
})
|
||||
.from(tool)
|
||||
.where(or(...conditions))
|
||||
.limit(5);
|
||||
|
||||
return { duplicate: rows.length > 0, matches: rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 normalizeSubmissionDomain
|
||||
* 说明:对外暴露域名归一化(提交时写入 domain 列,便于唯一索引拦截)。
|
||||
*/
|
||||
export function normalizeSubmissionDomain(websiteUrl: string): string | null {
|
||||
return normalizeDomain(websiteUrl);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { ThemeHeader } from "./shared/theme-header";
|
||||
import { ThemeFooter } from "./shared/theme-footer";
|
||||
import { getActiveTheme, themes, type ThemeId } from "./registry";
|
||||
|
||||
/** 每个主题的导航与页脚配置(Brand 层的一部分) */
|
||||
const chromeConfig: Record<
|
||||
ThemeId,
|
||||
{
|
||||
navItems: { label: string; href: string }[];
|
||||
footerGroups: { title: string; links: { label: string; href: string }[] }[];
|
||||
searchPlaceholder: string;
|
||||
}
|
||||
> = {
|
||||
classic: {
|
||||
navItems: [
|
||||
{ label: "Discover", href: "/tools" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Best Lists", href: "/best" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
],
|
||||
footerGroups: [
|
||||
{
|
||||
title: "Discover",
|
||||
links: [
|
||||
{ label: "All Tools", href: "/tools" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Best Lists", href: "/best" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Community",
|
||||
links: [
|
||||
{ label: "Submit a Tool", href: "/submit" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
links: [
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
],
|
||||
},
|
||||
],
|
||||
searchPlaceholder: "Search AI tools...",
|
||||
},
|
||||
product: {
|
||||
navItems: [
|
||||
{ label: "Today", href: "/" },
|
||||
{ label: "Leaderboard", href: "/rankings" },
|
||||
{ label: "Categories", href: "/tools" },
|
||||
{ label: "Collections", href: "/best" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
],
|
||||
footerGroups: [
|
||||
{
|
||||
title: "Community",
|
||||
links: [
|
||||
{ label: "Submit a Product", href: "/submit" },
|
||||
{ label: "Leaderboard", href: "/rankings" },
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Explore",
|
||||
links: [
|
||||
{ label: "All Products", href: "/tools" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Collections", href: "/best" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "About",
|
||||
links: [
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
],
|
||||
},
|
||||
],
|
||||
searchPlaceholder: "Search AI products...",
|
||||
},
|
||||
editorial: {
|
||||
navItems: [
|
||||
{ label: "Stories", href: "/blog" },
|
||||
{ label: "Guides", href: "/ai-tools-for" },
|
||||
{ label: "Best Picks", href: "/best" },
|
||||
{ label: "Directory", href: "/tools" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
],
|
||||
footerGroups: [
|
||||
{
|
||||
title: "Read",
|
||||
links: [
|
||||
{ label: "Latest Stories", href: "/blog" },
|
||||
{ label: "Buying Guides", href: "/best" },
|
||||
{ label: "Use Case Guides", href: "/ai-tools-for" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Directory",
|
||||
links: [
|
||||
{ label: "All Tools", href: "/tools" },
|
||||
{ label: "Community Rankings", href: "/rankings" },
|
||||
{ label: "Submit a Tool", href: "/submit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Company",
|
||||
links: [
|
||||
{ label: "Contact", href: "/contact" },
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
],
|
||||
},
|
||||
],
|
||||
searchPlaceholder: "Search the directory...",
|
||||
},
|
||||
niche: {
|
||||
navItems: [
|
||||
{ label: "Tools", href: "/tools" },
|
||||
{ label: "Workflows", href: "/best" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
],
|
||||
footerGroups: [
|
||||
{
|
||||
title: "Directory",
|
||||
links: [
|
||||
{ label: "All Tools", href: "/tools" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Build",
|
||||
links: [
|
||||
{ label: "Workflows", href: "/best" },
|
||||
{ label: "Submit a Tool", href: "/submit" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
links: [
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
],
|
||||
},
|
||||
],
|
||||
searchPlaceholder: "Search dev tools...",
|
||||
},
|
||||
minimal: {
|
||||
navItems: [
|
||||
{ label: "Search", href: "/tools" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
],
|
||||
footerGroups: [
|
||||
{
|
||||
title: "Search",
|
||||
links: [
|
||||
{ label: "All Tools", href: "/tools" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "About",
|
||||
links: [
|
||||
{ label: "Submit a Tool", href: "/submit" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
{ label: "Terms", href: "/terms" },
|
||||
],
|
||||
},
|
||||
],
|
||||
searchPlaceholder: "What do you need?",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* # 组件 ThemeChrome
|
||||
* 说明:站点外壳(Header + Footer + 主题色变量注入)。
|
||||
* 由 marketing layout 调用;读取 SITE_THEME 决定渲染哪个"站点"。
|
||||
*/
|
||||
export function ThemeChrome({ children }: { children: React.ReactNode }) {
|
||||
const theme = getActiveTheme();
|
||||
const config = chromeConfig[theme.id];
|
||||
|
||||
return (
|
||||
<div style={theme.cssVars as React.CSSProperties}>
|
||||
<ThemeHeader
|
||||
brand={theme.brand}
|
||||
navItems={config.navItems}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
/>
|
||||
{children}
|
||||
<ThemeFooter brand={theme.brand} groups={config.footerGroups} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { themes };
|
||||
@@ -0,0 +1,289 @@
|
||||
import { Search, TrendingUp, Sparkles, Clock, Award, LayoutGrid, MessageSquareQuote } from "lucide-react";
|
||||
import { ToolCard, ToolCardGrid } from "@/components/tool-card";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { SectionHeader } from "../shared/section";
|
||||
import { ThemeHeader } from "../shared/theme-header";
|
||||
import { ThemeFooter } from "../shared/theme-footer";
|
||||
import { getHomeDataSafe } from "../shared/home-data";
|
||||
import type { ThemeModule } from "../types";
|
||||
|
||||
/** Classic:专业 AI Directory(Clean / Dense / Professional) */
|
||||
const brand = {
|
||||
name: "AI Stack",
|
||||
tagline: "The professional AI tools directory — ranked by the community.",
|
||||
headline: "Find the best AI tools, ranked by real users",
|
||||
subheadline:
|
||||
"1,000+ AI tools across 14 categories. Compare votes, reviews and pricing to pick the right one.",
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ label: "Discover", href: "/tools" },
|
||||
{ label: "Categories", href: "/tools" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Best Lists", href: "/best" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
];
|
||||
|
||||
const footerGroups = [
|
||||
{
|
||||
title: "Discover",
|
||||
links: [
|
||||
{ label: "All Tools", href: "/tools" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Best Lists", href: "/best" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Community",
|
||||
links: [
|
||||
{ label: "Submit a Tool", href: "/submit" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
links: [
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function ClassicHome({ locale }: { locale: string }) {
|
||||
const data = await getHomeDataSafe(8);
|
||||
|
||||
return (
|
||||
<div className="bg-background">
|
||||
{/* Hero */}
|
||||
<section className="border-b border-border bg-gradient-to-b from-secondary/40 to-background">
|
||||
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 md:py-24">
|
||||
<h1 className="mx-auto max-w-3xl text-3xl font-bold tracking-tight text-foreground sm:text-4xl md:text-5xl">
|
||||
{brand.headline}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-sm text-muted-foreground sm:text-base">
|
||||
{brand.subheadline}
|
||||
</p>
|
||||
<form action={`/${locale}/tools`} className="mx-auto mt-8 max-w-xl">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
name="search"
|
||||
placeholder="Search 1,000+ AI tools..."
|
||||
className="h-12 w-full rounded-full border border-border bg-background pl-11 pr-28 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
aria-label="Search AI tools"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-1.5 top-1.5 h-9 rounded-full bg-primary px-5 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Popular:</span>
|
||||
{["writing", "video", "coding", "image"].map((q) => (
|
||||
<LocaleLink
|
||||
key={q}
|
||||
href={`/tools?search=${q}`}
|
||||
className="rounded-full bg-secondary px-2.5 py-1 transition-colors hover:bg-hover"
|
||||
>
|
||||
{q}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mx-auto max-w-7xl space-y-12 px-4 py-12 sm:px-6">
|
||||
{/* Trending */}
|
||||
{data.trending.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Trending this week"
|
||||
subtitle="Ranked by votes, reviews and engagement over the last 7 days"
|
||||
viewAllHref="/rankings?board=trending"
|
||||
viewAllLabel="All rankings"
|
||||
/>
|
||||
<ToolCardGrid tools={data.trending} locale={locale} ranked />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
{data.categories.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Browse by category"
|
||||
viewAllHref="/tools"
|
||||
viewAllLabel="All tools"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{data.categories.slice(0, 10).map((category) => (
|
||||
<LocaleLink
|
||||
key={category.id}
|
||||
href={`/category/${category.slug}`}
|
||||
className="group rounded-xl border border-border p-4 transition-all hover:border-primary/30 hover:shadow-sm"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 text-primary" />
|
||||
<h3 className="mt-2 truncate text-sm font-medium text-foreground group-hover:text-primary">
|
||||
{locale === "zh" && category.nameZh
|
||||
? category.nameZh
|
||||
: category.nameEn}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums">
|
||||
{category.toolCount} tools
|
||||
</p>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* New + Editor's Choice 两列 */}
|
||||
<div className="grid gap-12 lg:grid-cols-2">
|
||||
{data.newest.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="New AI tools"
|
||||
viewAllHref="/rankings?board=new"
|
||||
/>
|
||||
<ToolCardGrid tools={data.newest.slice(0, 4)} locale={locale} />
|
||||
</section>
|
||||
)}
|
||||
{data.editorsChoice.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Editor's Choice"
|
||||
viewAllHref="/rankings?board=editors-choice"
|
||||
/>
|
||||
<ToolCardGrid tools={data.editorsChoice.slice(0, 4)} locale={locale} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use Cases */}
|
||||
{data.useCases.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Tools by use case"
|
||||
subtitle="What do you want to get done?"
|
||||
viewAllHref="/ai-tools-for"
|
||||
viewAllLabel="All use cases"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.useCases.slice(0, 12).map((uc) => (
|
||||
<LocaleLink
|
||||
key={uc.id}
|
||||
href={`/ai-tools-for/${uc.slug}`}
|
||||
className="rounded-full border border-border px-3.5 py-1.5 text-xs text-foreground transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
{locale === "zh" && uc.nameZh ? uc.nameZh : uc.nameEn}
|
||||
<span className="ml-1.5 text-muted-foreground tabular-nums">
|
||||
{uc.toolCount}
|
||||
</span>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Best Lists */}
|
||||
{data.collections.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Best AI tool lists"
|
||||
subtitle="Curated rankings by our editors"
|
||||
viewAllHref="/best"
|
||||
viewAllLabel="All lists"
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.collections.slice(0, 3).map((col) => (
|
||||
<LocaleLink
|
||||
key={col.id}
|
||||
href={`/best/${col.slug}`}
|
||||
className="group rounded-xl border border-border p-5 transition-all hover:border-primary/30 hover:shadow-sm"
|
||||
>
|
||||
<Award className="h-4 w-4 text-primary" />
|
||||
<h3 className="mt-2 text-sm font-semibold text-foreground group-hover:text-primary">
|
||||
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
|
||||
</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{locale === "zh" && col.introZh ? col.introZh : col.introEn}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums">
|
||||
{col.toolCount} tools
|
||||
</p>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Latest Reviews */}
|
||||
{data.latestReviews.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader title="Latest community reviews" />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{data.latestReviews.map((review, i) => (
|
||||
<LocaleLink
|
||||
key={i}
|
||||
href={`/tools/${review.toolSlug}#reviews`}
|
||||
className="rounded-xl border border-border p-4 transition-colors hover:border-primary/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquareQuote className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{review.authorName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
on {review.toolName}
|
||||
</span>
|
||||
<span className="ml-auto text-xs font-semibold text-amber-500 tabular-nums">
|
||||
{review.rating}/5
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{review.content}
|
||||
</p>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Submit CTA */}
|
||||
<section className="rounded-2xl border border-border bg-secondary/30 p-8 text-center">
|
||||
<Sparkles className="mx-auto h-5 w-5 text-primary" />
|
||||
<h2 className="mt-3 text-lg font-semibold text-foreground">
|
||||
Built an AI tool?
|
||||
</h2>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
|
||||
Submit it to {brand.name} and get discovered by thousands of users
|
||||
looking for tools like yours.
|
||||
</p>
|
||||
<LocaleLink
|
||||
href="/submit"
|
||||
className="mt-4 inline-flex items-center gap-1.5 rounded-full bg-primary px-5 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Submit your tool
|
||||
</LocaleLink>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const classicTheme: ThemeModule = {
|
||||
id: "classic",
|
||||
brand,
|
||||
HomePage: ClassicHome,
|
||||
};
|
||||
|
||||
// SectionHeader 内部组件透传(供需要时使用)
|
||||
export { ThemeHeader, ThemeFooter, navItems as classicNavItems, footerGroups as classicFooterGroups };
|
||||
@@ -0,0 +1,233 @@
|
||||
import { BookOpen, PenTool, ArrowRight, Star } from "lucide-react";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { SectionHeader } from "../shared/section";
|
||||
import { getHomeDataSafe } from "../shared/home-data";
|
||||
import type { ThemeModule } from "../types";
|
||||
|
||||
/** Editorial:AI 媒体 + 工具目录(Serif / 内容优先) */
|
||||
const brand = {
|
||||
name: "The AI Ledger",
|
||||
tagline: "Independent journalism on AI tools, with a community-ranked directory.",
|
||||
headline: "AI tools, reviewed and ranked",
|
||||
subheadline:
|
||||
"Guides, comparisons and a community-powered directory of the best AI tools.",
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ label: "Stories", href: "/blog" },
|
||||
{ label: "Guides", href: "/ai-tools-for" },
|
||||
{ label: "Best Picks", href: "/best" },
|
||||
{ label: "Directory", href: "/tools" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
];
|
||||
|
||||
const footerGroups = [
|
||||
{
|
||||
title: "Read",
|
||||
links: [
|
||||
{ label: "Latest Stories", href: "/blog" },
|
||||
{ label: "Buying Guides", href: "/best" },
|
||||
{ label: "Use Case Guides", href: "/ai-tools-for" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Directory",
|
||||
links: [
|
||||
{ label: "All Tools", href: "/tools" },
|
||||
{ label: "Community Rankings", href: "/rankings" },
|
||||
{ label: "Submit a Tool", href: "/submit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Company",
|
||||
links: [
|
||||
{ label: "Contact", href: "/contact" },
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function EditorialHome({ locale }: { locale: string }) {
|
||||
const data = await getHomeDataSafe(6);
|
||||
const featured = data.articles[0];
|
||||
const editorPicks = data.editorsChoice.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Masthead */}
|
||||
<section className="border-b border-border">
|
||||
<div className="mx-auto max-w-4xl px-4 py-16 text-center sm:px-6 md:py-20">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.3em] text-muted-foreground">
|
||||
Independent AI Coverage
|
||||
</p>
|
||||
<h1 className="mt-4 text-4xl font-bold tracking-tight text-foreground md:text-5xl">
|
||||
{brand.headline}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-muted-foreground">
|
||||
{brand.subheadline}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mx-auto max-w-4xl space-y-14 px-4 py-12 sm:px-6">
|
||||
{/* Featured Story */}
|
||||
{featured && (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
Featured story
|
||||
</div>
|
||||
<LocaleLink href={`/blog/${featured.slug}`} className="group block">
|
||||
<h2 className="text-2xl font-bold leading-snug text-foreground group-hover:underline md:text-3xl">
|
||||
{featured.title}
|
||||
</h2>
|
||||
{featured.description && (
|
||||
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
|
||||
{featured.description}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary">
|
||||
Read the story
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</LocaleLink>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Editor's Picks */}
|
||||
{editorPicks.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-primary">
|
||||
<PenTool className="h-3.5 w-3.5" />
|
||||
Editor's picks
|
||||
</div>
|
||||
<div className="divide-y divide-border border-y border-border">
|
||||
{editorPicks.map((tool, i) => (
|
||||
<LocaleLink
|
||||
key={tool.id}
|
||||
href={`/tools/${tool.slug}`}
|
||||
className="group flex items-baseline gap-4 py-4"
|
||||
>
|
||||
<span className="text-sm font-bold text-muted-foreground tabular-nums">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-foreground group-hover:underline">
|
||||
{locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn}
|
||||
</h3>
|
||||
<p className="mt-0.5 line-clamp-1 text-sm text-muted-foreground">
|
||||
{tool.taglineEn || tool.descriptionEn}
|
||||
</p>
|
||||
</div>
|
||||
{(tool.ratingAvg ?? 0) > 0 && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-sm font-semibold text-foreground">
|
||||
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
|
||||
{(tool.ratingAvg ?? 0).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Best Lists(编辑内容型榜单) */}
|
||||
{data.collections.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Buying guides"
|
||||
subtitle="Deep dives that help you choose"
|
||||
viewAllHref="/best"
|
||||
viewAllLabel="All guides"
|
||||
/>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{data.collections.slice(0, 4).map((col) => (
|
||||
<LocaleLink
|
||||
key={col.id}
|
||||
href={`/best/${col.slug}`}
|
||||
className="group border-t-2 border-foreground/80 pt-4"
|
||||
>
|
||||
<h3 className="text-lg font-bold leading-snug text-foreground group-hover:underline">
|
||||
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
|
||||
</h3>
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{locale === "zh" && col.introZh ? col.introZh : col.introEn}
|
||||
</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{col.toolCount} tools compared
|
||||
</p>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Latest articles */}
|
||||
{data.articles.length > 1 && (
|
||||
<section>
|
||||
<SectionHeader title="Latest stories" viewAllHref="/blog" />
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{data.articles.slice(1).map((article) => (
|
||||
<LocaleLink key={article.slug} href={`/blog/${article.slug}`} className="group">
|
||||
<h3 className="text-base font-semibold leading-snug text-foreground group-hover:underline">
|
||||
{article.title}
|
||||
</h3>
|
||||
{article.description && (
|
||||
<p className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">
|
||||
{article.description}
|
||||
</p>
|
||||
)}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Community signal */}
|
||||
{data.latestReviews.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="From the community"
|
||||
viewAllHref="/tools"
|
||||
viewAllLabel="Browse directory"
|
||||
/>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{data.latestReviews.slice(0, 4).map((review, i) => (
|
||||
<blockquote
|
||||
key={i}
|
||||
className="border-l-2 border-primary/40 pl-4"
|
||||
>
|
||||
<p className="text-sm italic leading-relaxed text-foreground">
|
||||
"{review.content.slice(0, 140)}
|
||||
{review.content.length > 140 ? "..." : ""}"
|
||||
</p>
|
||||
<footer className="mt-2 text-xs text-muted-foreground">
|
||||
{review.authorName} on{" "}
|
||||
<LocaleLink
|
||||
href={`/tools/${review.toolSlug}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{review.toolName}
|
||||
</LocaleLink>
|
||||
{" · "}
|
||||
{review.rating}/5
|
||||
</footer>
|
||||
</blockquote>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const editorialTheme: ThemeModule = {
|
||||
id: "editorial",
|
||||
brand,
|
||||
HomePage: EditorialHome,
|
||||
};
|
||||
|
||||
export { navItems as editorialNavItems, footerGroups as editorialFooterGroups };
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { ToolCardGrid } from "@/components/tool-card";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { getHomeDataSafe } from "../shared/home-data";
|
||||
import type { ThemeModule } from "../types";
|
||||
|
||||
/** Minimal:极简 AI 搜索引擎(一个框,一个任务) */
|
||||
const brand = {
|
||||
name: "findai",
|
||||
tagline: "The minimal AI tool search engine.",
|
||||
headline: "What are you trying to do?",
|
||||
subheadline: "",
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ label: "Search", href: "/tools" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
];
|
||||
|
||||
const footerGroups = [
|
||||
{
|
||||
title: "Search",
|
||||
links: [
|
||||
{ label: "All Tools", href: "/tools" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "About",
|
||||
links: [
|
||||
{ label: "Submit a Tool", href: "/submit" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
{ label: "Terms", href: "/terms" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const popularSearches = [
|
||||
"write a blog post",
|
||||
"generate video",
|
||||
"code assistant",
|
||||
"make a logo",
|
||||
"edit photos",
|
||||
"transcribe meetings",
|
||||
];
|
||||
|
||||
async function MinimalHome({ locale }: { locale: string }) {
|
||||
const data = await getHomeDataSafe(6);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
{/* 极简搜索首屏 */}
|
||||
<section className="flex flex-1 flex-col items-center justify-center px-4 pb-16 pt-24 text-center">
|
||||
<LocaleLink href="/" className="text-3xl font-bold tracking-tight text-foreground">
|
||||
{brand.name}
|
||||
</LocaleLink>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.35em] text-muted-foreground">
|
||||
AI tool search
|
||||
</p>
|
||||
|
||||
<form action={`/${locale}/tools`} className="mt-10 w-full max-w-xl">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-5 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
name="search"
|
||||
autoFocus
|
||||
placeholder="I need to create marketing videos..."
|
||||
className="h-14 w-full rounded-full border border-border bg-background pl-14 pr-6 text-base text-foreground shadow-lg shadow-black/[0.03] placeholder:text-muted-foreground focus:border-primary/50 focus:outline-none focus:ring-4 focus:ring-primary/10"
|
||||
aria-label="Search AI tools"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
{popularSearches.map((q) => (
|
||||
<LocaleLink
|
||||
key={q}
|
||||
href={`/tools?search=${encodeURIComponent(q)}`}
|
||||
className="rounded-full px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
{q}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 搜索之下:Trending */}
|
||||
{data.trending.length > 0 && (
|
||||
<section className="mx-auto w-full max-w-3xl px-4 pb-20 sm:px-6">
|
||||
<h2 className="mb-4 text-center text-xs font-medium uppercase tracking-[0.25em] text-muted-foreground">
|
||||
Trending now
|
||||
</h2>
|
||||
<ToolCardGrid tools={data.trending.slice(0, 4)} locale={locale} />
|
||||
<div className="mt-6 text-center">
|
||||
<LocaleLink
|
||||
href="/rankings"
|
||||
className="text-xs font-medium text-muted-foreground hover:text-primary"
|
||||
>
|
||||
See full rankings
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const minimalTheme: ThemeModule = {
|
||||
id: "minimal",
|
||||
brand,
|
||||
HomePage: MinimalHome,
|
||||
};
|
||||
|
||||
export { navItems as minimalNavItems, footerGroups as minimalFooterGroups };
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Code2, Terminal, Workflow, Layers } from "lucide-react";
|
||||
import { ToolCard } from "@/components/tool-card";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { SectionHeader } from "../shared/section";
|
||||
import { getHomeDataSafe } from "../shared/home-data";
|
||||
import type { ThemeModule } from "../types";
|
||||
|
||||
/** Niche:垂直行业目录(AI for Developers) */
|
||||
const brand = {
|
||||
name: "DevAI.tools",
|
||||
tagline: "AI tools for developers — curated, tested, ranked by the community.",
|
||||
headline: "AI tools for developers",
|
||||
subheadline:
|
||||
"Code assistants, DevOps automation, testing copilots and everything else you ship with.",
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ label: "Tools", href: "/tools" },
|
||||
{ label: "Workflows", href: "/best" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
];
|
||||
|
||||
const footerGroups = [
|
||||
{
|
||||
title: "Directory",
|
||||
links: [
|
||||
{ label: "All Tools", href: "/tools" },
|
||||
{ label: "Rankings", href: "/rankings" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Build",
|
||||
links: [
|
||||
{ label: "Workflows", href: "/best" },
|
||||
{ label: "Submit a Tool", href: "/submit" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
links: [
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function NicheHome({ locale }: { locale: string }) {
|
||||
const data = await getHomeDataSafe(6);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Terminal 风格 Hero */}
|
||||
<section className="border-b border-border bg-[hsl(var(--secondary))]/40">
|
||||
<div className="mx-auto max-w-5xl px-4 py-14 sm:px-6 md:py-20">
|
||||
<div className="mx-auto max-w-2xl overflow-hidden rounded-xl border border-border bg-background shadow-sm">
|
||||
<div className="flex items-center gap-1.5 border-b border-border bg-secondary/50 px-4 py-2.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-amber-400" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />
|
||||
<span className="ml-2 text-xs text-muted-foreground">devai — zsh</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 p-5 font-mono text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">$</span>{" "}
|
||||
find-ai-tools --for developer
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-foreground">
|
||||
<Terminal className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
{brand.headline}
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{brand.subheadline}
|
||||
</p>
|
||||
<p className="pt-2">
|
||||
<LocaleLink
|
||||
href="/tools"
|
||||
className="inline-block rounded-md bg-primary px-4 py-1.5 font-sans text-xs font-semibold text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Browse tools
|
||||
</LocaleLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mx-auto max-w-5xl space-y-12 px-4 py-12 sm:px-6">
|
||||
{/* Use Cases as workflows */}
|
||||
{data.useCases.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Ship faster with AI"
|
||||
subtitle="Pick your workflow — we ranked the tools for it"
|
||||
viewAllHref="/ai-tools-for"
|
||||
viewAllLabel="All use cases"
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.useCases.slice(0, 6).map((uc) => (
|
||||
<LocaleLink
|
||||
key={uc.id}
|
||||
href={`/ai-tools-for/${uc.slug}`}
|
||||
className="group rounded-xl border border-border p-4 transition-all hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Workflow className="h-4 w-4 text-primary" />
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{uc.toolCount} tools
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-sm font-semibold text-foreground group-hover:text-primary">
|
||||
{locale === "zh" && uc.nameZh ? uc.nameZh : uc.nameEn}
|
||||
</h3>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Trending tools */}
|
||||
{data.trending.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Most upvoted this week"
|
||||
viewAllHref="/rankings?board=trending"
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{data.trending.slice(0, 6).map((tool, i) => (
|
||||
<ToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
locale={locale}
|
||||
rank={i + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Workflows(collections) */}
|
||||
{data.collections.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Workflows & stacks"
|
||||
subtitle="Batteries-included tool stacks for common dev tasks"
|
||||
viewAllHref="/best"
|
||||
viewAllLabel="All workflows"
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.collections.slice(0, 3).map((col) => (
|
||||
<LocaleLink
|
||||
key={col.id}
|
||||
href={`/best/${col.slug}`}
|
||||
className="group rounded-xl border border-border p-4 transition-all hover:border-primary/40"
|
||||
>
|
||||
<Layers className="h-4 w-4 text-primary" />
|
||||
<h3 className="mt-2 text-sm font-semibold text-foreground group-hover:text-primary">
|
||||
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
|
||||
</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{locale === "zh" && col.introZh ? col.introZh : col.introEn}
|
||||
</p>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
{data.categories.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader title="Browse by category" viewAllHref="/tools" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.categories.slice(0, 14).map((cat) => (
|
||||
<LocaleLink
|
||||
key={cat.id}
|
||||
href={`/category/${cat.slug}`}
|
||||
className="rounded-lg border border-border px-3 py-1.5 font-mono text-xs text-foreground transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
{locale === "zh" && cat.nameZh ? cat.nameZh : cat.nameEn}
|
||||
<span className="ml-1.5 text-muted-foreground">
|
||||
{cat.toolCount}
|
||||
</span>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const nicheTheme: ThemeModule = {
|
||||
id: "niche",
|
||||
brand,
|
||||
HomePage: NicheHome,
|
||||
};
|
||||
|
||||
export { navItems as nicheNavItems, footerGroups as nicheFooterGroups };
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Flame, Trophy, Rocket, ChevronRight, MessageSquare } from "lucide-react";
|
||||
import { VoteButton } from "@/features/voting/components/vote-button";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { ToolLogo } from "@/components/tool-logo";
|
||||
import { SectionHeader } from "../shared/section";
|
||||
import { getHomeDataSafe } from "../shared/home-data";
|
||||
import type { ThemeModule } from "../types";
|
||||
|
||||
/** Product:AI 产品社区(Daily Launch / Upvote / Maker) */
|
||||
const brand = {
|
||||
name: "LaunchAI",
|
||||
tagline: "Discover and upvote today's AI products.",
|
||||
headline: "Today's AI product launches",
|
||||
subheadline: "Fresh AI products, every day. Upvote the best ones to the top.",
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ label: "Today", href: "/" },
|
||||
{ label: "Leaderboard", href: "/rankings" },
|
||||
{ label: "Categories", href: "/tools" },
|
||||
{ label: "Collections", href: "/best" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Submit", href: "/submit" },
|
||||
];
|
||||
|
||||
const footerGroups = [
|
||||
{
|
||||
title: "Community",
|
||||
links: [
|
||||
{ label: "Submit a Product", href: "/submit" },
|
||||
{ label: "Leaderboard", href: "/rankings" },
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Explore",
|
||||
links: [
|
||||
{ label: "All Products", href: "/tools" },
|
||||
{ label: "Use Cases", href: "/ai-tools-for" },
|
||||
{ label: "Collections", href: "/best" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "About",
|
||||
links: [
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 单条 Launch 卡片:PH 风格(大投票块 + 产品信息 + 互动) */
|
||||
function LaunchRow({
|
||||
tool,
|
||||
locale,
|
||||
rank,
|
||||
}: {
|
||||
tool: {
|
||||
id: string;
|
||||
slug: string;
|
||||
nameEn: string;
|
||||
nameZh: string | null;
|
||||
taglineEn: string | null;
|
||||
taglineZh: string | null;
|
||||
descriptionEn: string | null;
|
||||
descriptionZh: string | null;
|
||||
logoUrl: string | null;
|
||||
voteCount: number;
|
||||
reviewCount: number;
|
||||
};
|
||||
locale: string;
|
||||
rank?: number;
|
||||
}) {
|
||||
const name = locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
|
||||
const tagline =
|
||||
locale === "zh"
|
||||
? tool.taglineZh || tool.descriptionZh || tool.taglineEn
|
||||
: tool.taglineEn || tool.descriptionEn || tool.taglineZh;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-4 border-b border-border px-4 py-5 transition-colors last:border-b-0 hover:bg-secondary/30 sm:px-6">
|
||||
{typeof rank === "number" && (
|
||||
<span className="mt-3 hidden w-5 text-right text-sm font-semibold text-muted-foreground tabular-nums sm:block">
|
||||
{rank}
|
||||
</span>
|
||||
)}
|
||||
<VoteButton toolId={tool.id} variant="card" initialCount={tool.voteCount} />
|
||||
<LocaleLink href={`/tools/${tool.slug}`} className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ToolLogo src={tool.logoUrl} alt={name} size={28} className="rounded-md sm:hidden" />
|
||||
<h3 className="truncate text-sm font-bold text-foreground group-hover:text-primary sm:text-base">
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{tagline}</p>
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{tool.reviewCount} reviews
|
||||
</span>
|
||||
</div>
|
||||
</LocaleLink>
|
||||
<ToolLogo src={tool.logoUrl} alt={name} size={44} className="hidden rounded-xl sm:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function ProductHome({ locale }: { locale: string }) {
|
||||
const data = await getHomeDataSafe(10);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Hero:今日发布 */}
|
||||
<section className="border-b border-border bg-gradient-to-b from-primary/5 to-background">
|
||||
<div className="mx-auto max-w-5xl px-4 py-12 text-center sm:px-6 md:py-16">
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-foreground sm:text-4xl">
|
||||
{brand.headline}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground sm:text-base">
|
||||
{brand.subheadline}
|
||||
</p>
|
||||
<LocaleLink
|
||||
href="/submit"
|
||||
className="mt-6 inline-flex items-center gap-1.5 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-primary-foreground shadow transition-all hover:opacity-90"
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
Launch your product
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mx-auto grid max-w-5xl gap-8 px-4 py-10 sm:px-6 lg:grid-cols-[1fr_280px]">
|
||||
{/* 产品 Feed */}
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-background">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-secondary/30 px-4 py-3 sm:px-6">
|
||||
<Flame className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-bold text-foreground">Today's feed</h2>
|
||||
</div>
|
||||
{data.trending.length > 0 ? (
|
||||
data.trending.map((tool, i) => (
|
||||
<LaunchRow key={tool.id} tool={tool} locale={locale} rank={i + 1} />
|
||||
))
|
||||
) : (
|
||||
<p className="px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
No launches yet — be the first to submit.
|
||||
</p>
|
||||
)}
|
||||
<LocaleLink
|
||||
href="/tools"
|
||||
className="flex items-center justify-center gap-1 border-t border-border py-3 text-xs font-medium text-muted-foreground hover:text-primary"
|
||||
>
|
||||
Browse all products
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</LocaleLink>
|
||||
</div>
|
||||
|
||||
{/* New */}
|
||||
{data.newest.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<SectionHeader title="Fresh launches" viewAllHref="/rankings?board=new" />
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-background">
|
||||
{data.newest.slice(0, 5).map((tool) => (
|
||||
<LaunchRow key={tool.id} tool={tool} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 侧边栏榜单 */}
|
||||
<aside className="space-y-6">
|
||||
{data.popular.length > 0 && (
|
||||
<div className="rounded-2xl border border-border bg-background p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-bold text-foreground">Leaderboard</h3>
|
||||
</div>
|
||||
<ol className="mt-4 space-y-3">
|
||||
{data.popular.slice(0, 5).map((tool, i) => (
|
||||
<li key={tool.id}>
|
||||
<LocaleLink
|
||||
href={`/tools/${tool.slug}`}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<span className="w-4 text-xs font-bold text-muted-foreground tabular-nums">
|
||||
{i + 1}
|
||||
</span>
|
||||
<ToolLogo src={tool.logoUrl} alt={tool.nameEn} size={28} className="rounded-md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-semibold text-foreground group-hover:text-primary">
|
||||
{locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-primary tabular-nums">
|
||||
{tool.voteCount}
|
||||
</span>
|
||||
</LocaleLink>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<LocaleLink
|
||||
href="/rankings"
|
||||
className="mt-4 block rounded-lg border border-border py-2 text-center text-xs font-medium text-muted-foreground hover:border-primary/40 hover:text-primary"
|
||||
>
|
||||
Full leaderboard
|
||||
</LocaleLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.editorsChoice.length > 0 && (
|
||||
<div className="rounded-2xl border border-border bg-background p-5">
|
||||
<h3 className="text-sm font-bold text-foreground">Editor's picks</h3>
|
||||
<ol className="mt-4 space-y-3">
|
||||
{data.editorsChoice.slice(0, 4).map((tool) => (
|
||||
<li key={tool.id}>
|
||||
<LocaleLink href={`/tools/${tool.slug}`} className="group flex items-center gap-3">
|
||||
<ToolLogo src={tool.logoUrl} alt={tool.nameEn} size={28} className="rounded-md" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold text-foreground group-hover:text-primary">
|
||||
{locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-muted-foreground">
|
||||
{(tool.ratingAvg ?? 0) > 0
|
||||
? `${(tool.ratingAvg ?? 0).toFixed(1)} stars`
|
||||
: "Editor selected"}
|
||||
</p>
|
||||
</div>
|
||||
</LocaleLink>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.collections.length > 0 && (
|
||||
<div className="rounded-2xl border border-border bg-background p-5">
|
||||
<h3 className="text-sm font-bold text-foreground">Collections</h3>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{data.collections.slice(0, 4).map((col) => (
|
||||
<li key={col.id}>
|
||||
<LocaleLink
|
||||
href={`/best/${col.slug}`}
|
||||
className="text-xs text-muted-foreground hover:text-primary"
|
||||
>
|
||||
{locale === "zh" && col.nameZh ? col.nameZh : col.nameEn}
|
||||
</LocaleLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const productTheme: ThemeModule = {
|
||||
id: "product",
|
||||
brand,
|
||||
HomePage: ProductHome,
|
||||
};
|
||||
|
||||
export { navItems as productNavItems, footerGroups as productFooterGroups };
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ThemeId, ThemeModule } from "./types";
|
||||
import { classicTheme } from "./classic";
|
||||
import { productTheme } from "./product";
|
||||
import { editorialTheme } from "./editorial";
|
||||
import { nicheTheme } from "./niche";
|
||||
import { minimalTheme } from "./minimal";
|
||||
|
||||
/** 全部主题(五套 UI 验证,设计文档 §41) */
|
||||
export const themes: Record<ThemeId, ThemeModule> = {
|
||||
classic: classicTheme,
|
||||
product: productTheme,
|
||||
editorial: editorialTheme,
|
||||
niche: nicheTheme,
|
||||
minimal: minimalTheme,
|
||||
};
|
||||
|
||||
export const themeIds = Object.keys(themes) as ThemeId[];
|
||||
|
||||
export type { ThemeId, ThemeModule };
|
||||
|
||||
/**
|
||||
* # 函数 parseThemeId
|
||||
* 说明:容错解析主题标识;未知值回退 classic。
|
||||
*/
|
||||
export function parseThemeId(value: string | undefined | null): ThemeId {
|
||||
if (value && value in themes) return value as ThemeId;
|
||||
return "classic";
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getActiveTheme
|
||||
* 说明:读取当前站点主题(SITE_THEME 环境变量,服务端组件可用)。
|
||||
* 一个部署 = 一个站点;换站点 = 换环境变量,不改 Core 代码。
|
||||
*/
|
||||
export function getActiveTheme(): ThemeModule {
|
||||
return themes[parseThemeId(process.env.SITE_THEME ?? process.env.NEXT_PUBLIC_SITE_THEME)];
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { getRankedTools } from "@/lib/ranking/queries";
|
||||
import {
|
||||
getUseCases,
|
||||
getAudiences,
|
||||
getCollections,
|
||||
} from "@/features/discovery/actions";
|
||||
import { getCategoriesWithCount } from "@/features/tools/actions";
|
||||
import { getAllBlogs } from "@/lib/blog";
|
||||
import { review, tool, user as userTable } from "@/lib/db/schema";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
|
||||
/** 主题首页的数据包(一次取齐,供五个主题按需取用) */
|
||||
export interface HomeData {
|
||||
trending: Awaited<ReturnType<typeof getRankedTools>>;
|
||||
popular: Awaited<ReturnType<typeof getRankedTools>>;
|
||||
newest: Awaited<ReturnType<typeof getRankedTools>>;
|
||||
editorsChoice: Awaited<ReturnType<typeof getRankedTools>>;
|
||||
highestRated: Awaited<ReturnType<typeof getRankedTools>>;
|
||||
categories: Awaited<ReturnType<typeof getCategoriesWithCount>>;
|
||||
useCases: Awaited<ReturnType<typeof getUseCases>>;
|
||||
audiences: Awaited<ReturnType<typeof getAudiences>>;
|
||||
collections: Awaited<ReturnType<typeof getCollections>>;
|
||||
latestReviews: { toolName: string; toolSlug: string; rating: number; title: string | null; content: string; authorName: string }[];
|
||||
articles: { slug: string; title: string; description?: string }[];
|
||||
}
|
||||
|
||||
/** 安全取数:构建/无数据时返回空骨架 */
|
||||
export async function getHomeData(limit = 6): Promise<HomeData> {
|
||||
const [
|
||||
trending,
|
||||
popular,
|
||||
newest,
|
||||
editorsChoice,
|
||||
highestRated,
|
||||
categories,
|
||||
useCases,
|
||||
audiences,
|
||||
collections,
|
||||
latestReviews,
|
||||
articles,
|
||||
] = await Promise.all([
|
||||
getRankedTools("trending", limit),
|
||||
getRankedTools("popular", limit),
|
||||
getRankedTools("new", limit),
|
||||
getRankedTools("editors-choice", limit),
|
||||
getRankedTools("highest-rated", limit),
|
||||
getCategoriesWithCount(),
|
||||
getUseCases(),
|
||||
getAudiences(),
|
||||
getCollections(),
|
||||
getLatestReviews(4),
|
||||
getLatestArticles(4),
|
||||
]);
|
||||
|
||||
return {
|
||||
trending,
|
||||
popular,
|
||||
newest,
|
||||
editorsChoice,
|
||||
highestRated,
|
||||
categories,
|
||||
useCases,
|
||||
audiences,
|
||||
collections,
|
||||
latestReviews,
|
||||
articles,
|
||||
};
|
||||
}
|
||||
|
||||
/** 空数据包(构建期数据库不可用的回退) */
|
||||
export async function getHomeDataSafe(limit = 6): Promise<HomeData> {
|
||||
try {
|
||||
return await getHomeData(limit);
|
||||
} catch {
|
||||
return {
|
||||
trending: [],
|
||||
popular: [],
|
||||
newest: [],
|
||||
editorsChoice: [],
|
||||
highestRated: [],
|
||||
categories: [],
|
||||
useCases: [],
|
||||
audiences: [],
|
||||
collections: [],
|
||||
latestReviews: [],
|
||||
articles: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getLatestReviews
|
||||
* 说明:最新社区评论(首页 Latest Reviews 区块)。
|
||||
*/
|
||||
export async function getLatestReviews(limit = 4) {
|
||||
return db
|
||||
.select({
|
||||
toolName: tool.nameEn,
|
||||
toolSlug: tool.slug,
|
||||
rating: review.rating,
|
||||
title: review.title,
|
||||
content: review.content,
|
||||
authorName: sql<string>`COALESCE(${userTable.name}, 'Anonymous')`,
|
||||
})
|
||||
.from(review)
|
||||
.innerJoin(tool, eq(review.toolId, tool.id))
|
||||
.innerJoin(userTable, eq(review.userId, userTable.id))
|
||||
.where(and(eq(review.status, "published"), eq(tool.status, "published")))
|
||||
.orderBy(desc(review.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* # 函数 getLatestArticles
|
||||
* 说明:最新博客文章(首页内容区块)。
|
||||
*/
|
||||
export async function getLatestArticles(limit = 4) {
|
||||
try {
|
||||
const blogs = await getAllBlogs();
|
||||
return blogs.slice(0, limit).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
description: b.description,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
/**
|
||||
* # 组件 SectionHeader
|
||||
* 说明:主题首页通用区块标题(标题 + 可选 View all 内链)。
|
||||
*/
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
viewAllHref,
|
||||
viewAllLabel = "View all",
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
viewAllHref?: string;
|
||||
viewAllLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`mb-5 flex items-end justify-between gap-4 ${className ?? ""}`}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{viewAllHref && (
|
||||
<LocaleLink
|
||||
href={viewAllHref}
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
{viewAllLabel}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</LocaleLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { NewsletterForm } from "@/features/marketing/components/newsletter-form";
|
||||
import type { ThemeBrand } from "../types";
|
||||
|
||||
export interface ThemeFooterProps {
|
||||
brand: ThemeBrand;
|
||||
/** 分组链接:{ title, links: [{label, href}] } */
|
||||
groups: { title: string; links: { label: string; href: string }[] }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 ThemeFooter
|
||||
* 说明:主题化页脚(品牌 + 分组内链 + Newsletter),内链服务 SEO Graph。
|
||||
*/
|
||||
export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
|
||||
return (
|
||||
<footer className="border-t border-border bg-background">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6">
|
||||
<div className="grid gap-10 md:grid-cols-4">
|
||||
<div className="md:col-span-1">
|
||||
<LocaleLink href="/" className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
|
||||
{brand.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-foreground">{brand.name}</span>
|
||||
</LocaleLink>
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">
|
||||
{brand.tagline}
|
||||
</p>
|
||||
<div className="mt-4 max-w-xs">
|
||||
<NewsletterForm />
|
||||
</div>
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-foreground">
|
||||
{group.title}
|
||||
</h3>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{group.links.map((link) => (
|
||||
<li key={link.href + link.label}>
|
||||
<LocaleLink
|
||||
href={link.href}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
</LocaleLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-10 border-t border-border pt-6 text-center text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} {brand.name}. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Menu, Search, X } from "lucide-react";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { UserMenu } from "@/features/navigation/components/user-menu";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useLocale } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ThemeBrand } from "../types";
|
||||
|
||||
export interface ThemeHeaderProps {
|
||||
brand: ThemeBrand;
|
||||
/** 导航项(href 不带 locale 前缀) */
|
||||
navItems: { label: string; href: string }[];
|
||||
/** 主题额外的 header 视觉 class */
|
||||
className?: string;
|
||||
/** 是否显示搜索框 */
|
||||
showSearch?: boolean;
|
||||
/** 搜索框占位文本 */
|
||||
searchPlaceholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* # 组件 ThemeHeader
|
||||
* 说明:主题化站点导航(Desktop + Mobile),Core 复用、Brand 可配置。
|
||||
* Desktop:Logo + 导航 + 搜索 + 语言/主题/用户。
|
||||
* Mobile:Logo + 搜索图标 + 汉堡菜单(文档 §31)。
|
||||
*/
|
||||
export function ThemeHeader({
|
||||
brand,
|
||||
navItems,
|
||||
className,
|
||||
showSearch = true,
|
||||
searchPlaceholder = "Search AI tools...",
|
||||
}: ThemeHeaderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const pathname = usePathname();
|
||||
|
||||
const submitSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
router.push(`/${locale}/tools?search=${encodeURIComponent(query.trim())}`);
|
||||
};
|
||||
|
||||
const isActive = (href: string) => {
|
||||
const full = `/${locale}${href === "/" ? "" : href}`;
|
||||
return pathname === full || (href !== "/" && pathname.startsWith(full));
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur-md",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
|
||||
{/* Logo */}
|
||||
<LocaleLink href="/" className="flex shrink-0 items-center gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
|
||||
{brand.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="text-sm font-bold tracking-tight text-foreground">
|
||||
{brand.name}
|
||||
</span>
|
||||
</LocaleLink>
|
||||
|
||||
{/* Desktop 导航 */}
|
||||
<nav className="hidden items-center gap-1 lg:flex" aria-label="Main">
|
||||
{navItems.map((item) => (
|
||||
<LocaleLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-sm transition-colors",
|
||||
isActive(item.href)
|
||||
? "bg-secondary font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</LocaleLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Desktop 搜索 */}
|
||||
{showSearch && (
|
||||
<form onSubmit={submitSearch} className="hidden md:block">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-8 w-44 rounded-full border border-border bg-secondary/50 pl-8 pr-3 text-xs text-foreground placeholder:text-muted-foreground focus:w-60 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
<ModeToggle />
|
||||
<UserMenu />
|
||||
|
||||
{/* Mobile 汉堡 */}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-2 text-muted-foreground hover:text-foreground lg:hidden"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile 抽屉 */}
|
||||
{open && (
|
||||
<nav className="border-t border-border bg-background px-4 py-3 lg:hidden" aria-label="Mobile">
|
||||
{navItems.map((item) => (
|
||||
<LocaleLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"block rounded-lg px-3 py-2.5 text-sm",
|
||||
isActive(item.href)
|
||||
? "bg-secondary font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</LocaleLink>
|
||||
))}
|
||||
{showSearch && (
|
||||
<form onSubmit={submitSearch} className="mt-2 px-1 pb-2 md:hidden">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-10 w-full rounded-lg border border-border bg-secondary/50 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
/** 主题标识(与 SITE_THEME 环境变量对应) */
|
||||
export type ThemeId =
|
||||
| "classic"
|
||||
| "product"
|
||||
| "editorial"
|
||||
| "niche"
|
||||
| "minimal";
|
||||
|
||||
/** 站点品牌配置(每个主题即一个"站点"的 Brand 层) */
|
||||
export interface ThemeBrand {
|
||||
name: string;
|
||||
/** 导航/页脚用的短标语 */
|
||||
tagline: string;
|
||||
/** 首页主标题(hero headline) */
|
||||
headline: string;
|
||||
/** 首页副标题 */
|
||||
subheadline: string;
|
||||
}
|
||||
|
||||
/** 主题模块接口 —— Core 与 Site Theme 分离(设计文档 §75 原则 1) */
|
||||
export interface ThemeModule {
|
||||
id: ThemeId;
|
||||
brand: ThemeBrand;
|
||||
/**
|
||||
* 主题级 CSS 变量覆盖(HSL 三元组,注入到页面根包装元素)。
|
||||
* 例:{ "--primary": "16 85% 55%" }
|
||||
*/
|
||||
cssVars?: Record<string, string>;
|
||||
/** 首页(必需)—— 每个主题最核心的差异化 */
|
||||
HomePage: ComponentType<{ locale: string }>;
|
||||
/** 页面主色 class(用于主题化局部点缀,可选) */
|
||||
accentClass?: string;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
trendingScore,
|
||||
bayesianRating,
|
||||
timeDecayFactor,
|
||||
popularScore,
|
||||
parseToolSlugRoute,
|
||||
normalizeDomain,
|
||||
slugify,
|
||||
type ToolSignals,
|
||||
} from "../src/lib/ranking/score";
|
||||
|
||||
// ========== Trending 评分 ==========
|
||||
|
||||
describe("trendingScore", () => {
|
||||
const baseSignals: ToolSignals = {
|
||||
votesInWindow: 10,
|
||||
favoritesInWindow: 5,
|
||||
reviewsInWindow: 2,
|
||||
viewsInWindow: 100,
|
||||
ageHours: 24,
|
||||
};
|
||||
|
||||
test("互动越多分数越高", () => {
|
||||
const more: ToolSignals = { ...baseSignals, votesInWindow: 50 };
|
||||
expect(trendingScore(more)).toBeGreaterThan(trendingScore(baseSignals));
|
||||
});
|
||||
|
||||
test("无互动分数为 0(避免空榜填充)", () => {
|
||||
const empty: ToolSignals = {
|
||||
votesInWindow: 0,
|
||||
favoritesInWindow: 0,
|
||||
reviewsInWindow: 0,
|
||||
viewsInWindow: 0,
|
||||
ageHours: 100,
|
||||
};
|
||||
expect(trendingScore(empty)).toBe(0);
|
||||
});
|
||||
|
||||
test("新工具衰减慢:相同互动下,新工具 > 老工具(防止老工具永久霸榜)", () => {
|
||||
const fresh = trendingScore({ ...baseSignals, ageHours: 24 });
|
||||
const old = trendingScore({ ...baseSignals, ageHours: 24 * 365 });
|
||||
expect(fresh).toBeGreaterThan(old);
|
||||
});
|
||||
|
||||
test("评论权重高于投票", () => {
|
||||
const reviewHeavy = trendingScore({ ...baseSignals, reviewsInWindow: 3, votesInWindow: 0 });
|
||||
const voteHeavy = trendingScore({ ...baseSignals, reviewsInWindow: 0, votesInWindow: 3 });
|
||||
expect(reviewHeavy).toBeGreaterThan(voteHeavy);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 贝叶斯评分 ==========
|
||||
|
||||
describe("bayesianRating", () => {
|
||||
test("评论极少时被先验拉低", () => {
|
||||
const oneFiveStar = bayesianRating(5, 1, 4.0);
|
||||
expect(oneFiveStar).toBeLessThan(5);
|
||||
});
|
||||
|
||||
test("评论很多时接近观测均值", () => {
|
||||
const manyReviews = bayesianRating(4.8, 200, 4.0);
|
||||
expect(manyReviews).toBeGreaterThan(4.7);
|
||||
expect(manyReviews).toBeLessThanOrEqual(4.8);
|
||||
});
|
||||
|
||||
test("样本相同的两工具,均值高者胜", () => {
|
||||
expect(bayesianRating(4.8, 10, 4.0)).toBeGreaterThan(bayesianRating(4.2, 10, 4.0));
|
||||
});
|
||||
|
||||
test("无评论返回 0", () => {
|
||||
expect(bayesianRating(5, 0, 4)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 时间衰减 / Popular ==========
|
||||
|
||||
describe("timeDecayFactor", () => {
|
||||
test("未衰减为 1,随时间单调递减", () => {
|
||||
expect(timeDecayFactor(0)).toBe(1);
|
||||
expect(timeDecayFactor(100)).toBeLessThan(1);
|
||||
expect(timeDecayFactor(200)).toBeLessThan(timeDecayFactor(100));
|
||||
});
|
||||
|
||||
test("半衰期处恰好为 0.5", () => {
|
||||
expect(timeDecayFactor(168, 168)).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("popularScore", () => {
|
||||
test("全量互动加权求和", () => {
|
||||
expect(popularScore(10, 10, 10, 1000)).toBe(10 * 2 + 10 * 1.5 + 10 * 3 + 1000 * 0.1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 复合路由解析(Alternatives / Comparison SEO) ==========
|
||||
|
||||
describe("parseToolSlugRoute", () => {
|
||||
test("普通工具", () => {
|
||||
expect(parseToolSlugRoute("chatgpt")).toEqual({ kind: "tool", slug: "chatgpt" });
|
||||
});
|
||||
|
||||
test("alternatives 页", () => {
|
||||
expect(parseToolSlugRoute("chatgpt-alternatives")).toEqual({
|
||||
kind: "alternatives",
|
||||
base: "chatgpt",
|
||||
});
|
||||
});
|
||||
|
||||
test("vs 对比页", () => {
|
||||
expect(parseToolSlugRoute("cursor-vs-windsurf")).toEqual({
|
||||
kind: "comparison",
|
||||
a: "cursor",
|
||||
b: "windsurf",
|
||||
});
|
||||
});
|
||||
|
||||
test("名称中带连字符的对比页", () => {
|
||||
expect(parseToolSlugRoute("github-copilot-vs-tabnine")).toEqual({
|
||||
kind: "comparison",
|
||||
a: "github-copilot",
|
||||
b: "tabnine",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 域名归一化(重复工具检测) ==========
|
||||
|
||||
describe("normalizeDomain", () => {
|
||||
test("https URL 提取主机", () => {
|
||||
expect(normalizeDomain("https://chatgpt.com/")).toBe("chatgpt.com");
|
||||
});
|
||||
|
||||
test("去 www", () => {
|
||||
expect(normalizeDomain("www.anthropic.com")).toBe("anthropic.com");
|
||||
});
|
||||
|
||||
test("带路径与查询参数", () => {
|
||||
expect(normalizeDomain("https://openai.com/chatgpt?ref=x")).toBe("openai.com");
|
||||
});
|
||||
|
||||
test("子域保留(重复检测按主机比较)", () => {
|
||||
expect(normalizeDomain("http://chat.openai.com")).toBe("chat.openai.com");
|
||||
});
|
||||
|
||||
test("非法输入返回 null", () => {
|
||||
expect(normalizeDomain("")).toBeNull();
|
||||
expect(normalizeDomain("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== slug 生成 ==========
|
||||
|
||||
describe("slugify", () => {
|
||||
test("空格转连字符 + 小写", () => {
|
||||
expect(slugify("Best AI Writing Tools")).toBe("best-ai-writing-tools");
|
||||
});
|
||||
|
||||
test("去除特殊字符", () => {
|
||||
expect(slugify("DALL·E 3!")).toBe("dalle-3");
|
||||
});
|
||||
|
||||
test("合并连续连字符", () => {
|
||||
expect(slugify("a -- b")).toBe("a-b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { generateSeoPage, generateToolFaqs } from "../src/lib/seo/page-generator";
|
||||
|
||||
describe("generateSeoPage", () => {
|
||||
const base = {
|
||||
name: "Blogging",
|
||||
kind: "use-case" as const,
|
||||
brandName: "AI Stack",
|
||||
toolCount: 12,
|
||||
freeCount: 4,
|
||||
topToolNames: ["ChatGPT", "Claude", "Grammarly"],
|
||||
year: 2026,
|
||||
};
|
||||
|
||||
test("生成 title / h1 / intro / faqs", () => {
|
||||
const page = generateSeoPage(base);
|
||||
expect(page.title).toContain("Best AI Tools for Blogging in 2026");
|
||||
expect(page.h1).toBe("Best AI Tools for Blogging");
|
||||
expect(page.intro).toContain("12");
|
||||
expect(page.faqs.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test("description 含工具数与排序说明", () => {
|
||||
const page = generateSeoPage(base);
|
||||
expect(page.description).toContain("12");
|
||||
expect(page.description).toContain("votes");
|
||||
});
|
||||
|
||||
test("FAQ 具体化:引用真实工具名", () => {
|
||||
const page = generateSeoPage(base);
|
||||
const firstAnswer = page.faqs[0].answer;
|
||||
expect(firstAnswer).toContain("ChatGPT");
|
||||
});
|
||||
|
||||
test("free 数量反映到 FAQ", () => {
|
||||
const page = generateSeoPage(base);
|
||||
const freeFaq = page.faqs.find((f) => f.question.startsWith("Are there free"));
|
||||
expect(freeFaq?.answer).toContain("4");
|
||||
});
|
||||
|
||||
test("use case + audience 生成交叉引用 FAQ", () => {
|
||||
const page = generateSeoPage({ ...base, audienceName: "Marketers" });
|
||||
const crossFaq = page.faqs.find((f) => f.question.toLowerCase().includes("marketer"));
|
||||
expect(crossFaq).toBeDefined();
|
||||
});
|
||||
|
||||
test("零数据实体也能安全生成(无 NaN/undefined)", () => {
|
||||
const page = generateSeoPage({
|
||||
name: "Research",
|
||||
kind: "audience",
|
||||
brandName: "X",
|
||||
toolCount: 0,
|
||||
});
|
||||
expect(page.intro).not.toContain("undefined");
|
||||
expect(page.title).not.toContain("NaN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateToolFaqs", () => {
|
||||
test("由定价生成免费问答", () => {
|
||||
const faqs = generateToolFaqs({ name: "ChatGPT", pricing: "freemium" });
|
||||
const freeFaq = faqs.find((f) => f.question.includes("free"));
|
||||
expect(freeFaq?.answer).toContain("freemium");
|
||||
});
|
||||
|
||||
test("含替代品问答", () => {
|
||||
const faqs = generateToolFaqs(
|
||||
{ name: "Cursor", pricing: "freemium" },
|
||||
["Windsurf", "Copilot"]
|
||||
);
|
||||
const altFaq = faqs.find((f) => f.question.includes("alternatives"));
|
||||
expect(altFaq?.answer).toContain("Windsurf");
|
||||
});
|
||||
|
||||
test("描述自动补句号", () => {
|
||||
const faqs = generateToolFaqs({
|
||||
name: "X",
|
||||
description: "It does things",
|
||||
});
|
||||
expect(faqs[0].answer).toBe("It does things.");
|
||||
});
|
||||
});
|
||||
+9
-1
@@ -33,7 +33,15 @@
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
".next/dev/types/**/*.ts",
|
||||
".next-product/types/**/*.ts",
|
||||
".next-product/dev/types/**/*.ts",
|
||||
".next-editorial/types/**/*.ts",
|
||||
".next-editorial/dev/types/**/*.ts",
|
||||
".next-niche/types/**/*.ts",
|
||||
".next-niche/dev/types/**/*.ts",
|
||||
".next-minimal/types/**/*.ts",
|
||||
".next-minimal/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
Reference in New Issue
Block a user