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