全面审查优化: 43项修复(安全/性能/可靠性)+ 文档更新

🔴 P0 安全: .env入gitignore, CSP/HSTS, Logo安全(5MB限制+SVG消毒+SHA256), middleware权限收紧(VIP不能访问admin), 发布去重, GitHub认证, 删除危险API, skill-discoverer绕过审核修复, prisma默认连接修复
🔴 P0 可靠性: deepseek/github超时重试, 脚本层DeepSeek保护, 脚本启动验证, OAuth竞态修复
🔴 P0 性能: skill-discoverer N+1优化(↓94%), 后台API分页限制, getUserStats聚合优化
🟡 P1: 分类动态加载, 星级字段统一, fetch-logos并发Bug修复, 批量发布冲突不删除, check-tools重试, 前端空指针修复(13处), logo-fetcher日志, rate-limit清理, 公开API缓存
This commit is contained in:
ZhuiGuangAI Dev
2026-05-26 00:30:36 +08:00
parent 9bf3e7574a
commit e263e28c81
47 changed files with 1212 additions and 788 deletions
+19
View File
@@ -0,0 +1,19 @@
# ============================================
# 追光AI 环境变量配置模板 (.env.example)
# ============================================
# 复制此文件为 .env 并填入真实值
# WARNING: .env 已被加入 .gitignore,请勿提交真实密钥
# 数据库连接 (必填)
DATABASE_URL="mysql://user:password@host:3306/zhuiguang_ai?charset=utf8mb4"
# NextAuth 配置 (必填)
NEXTAUTH_URL="http://localhost:8301"
NEXTAUTH_SECRET="请生成一个随机高强度密钥(至少32位)"
# GitHub API Token (可选,用于技能发现和星数更新)
# 如不设置,每分钟只能调用60次GitHub API
GITHUB_TOKEN=""
# DeepSeek AI API Key (必填,用于技能评测和新闻摘要)
DEEPSEEK_API_KEY=""
+2
View File
@@ -27,6 +27,8 @@ yarn-error.log*
# local env files # local env files
.env*.local .env*.local
.env
.env.production
# vercel # vercel
.vercel .vercel
+81
View File
@@ -1,5 +1,86 @@
# 追光AI 变更日志 # 追光AI 变更日志
## 2026-05-25 全面审查优化(三轮扫描 + 43项修复)
### 🔴 P0 安全修复
- **.env入.gitignore**: `.env` 和 `.env.production` 加入忽略列表,创建 `.env.example` 模板文件
- **CSP/HSTS安全头**: next.config.mjs 添加 Content-Security-Policy、Strict-Transport-Security、Permissions-Policy
- **Logo安全加固**: 5MB大小限制 + SVG script/iframe/on-event 标签消毒 + MD5→SHA256
- **middleware权限收紧**: VIP用户不再能访问 /admin/* 和 /api/admin/*,仅admin角色可访问
- **单条发布去重**: pending-skills/[id]/publish 添加 name/sourceUrl/slug 三重去重检查,冲突返回409
- **Web更新星级GitHub认证**: update-stars API 添加 GITHUB_TOKEN 环境变量认证(限流60→5000次/小时)
- **删除危险API**: pending-skills/route.ts 的 DELETE 方法(原可无条件清空全表)已移除
- **skill-discoverer绕过审核**: Web自动发现统一改为写 pendingSkill 表(status:pending),不再跳过审核
- **prisma默认连接修复**: DATABASE_URL未设置时抛出明确错误,移除硬编码默认连接串
### 🔴 P0 可靠性修复
- **deepseek.ts超时重试**: OpenAI客户端添加 timeout:120000 + maxRetries:2,模型名改为 DEEPSEEK_MODEL 环境变量
- **github.ts超时重试**: Octokit 添加 timeout:30000,新增 withRetry 指数退避重试,403/404/422 不重试
- **脚本层DeepSeek保护**: 4个定时脚本(daily-discover/daily-news/task1/task6)添加 AbortSignal.timeout(120000) + withRetry 重试
- **脚本启动验证**: 定时脚本启动时验证 DEEPSEEK_API_KEY + DATABASE_URL 存在性
- **auth.ts OAuth竞态修复**: GitHub OAuth 并发创建用户时 P2002 唯一约束冲突自动回退 findFirst
### 🔴 P0 性能修复
- **skill-discoverer N+1优化**: 批量预加载 sourceUrl/slug/分类到内存 Set,DB查询从 ~360次 降至 ~22次(↓94%)
- **后台API分页限制**: 6个管理后台 API findMany 添加 take:500/1000 防全表扫描
- **getUserStats优化**: 改用 _count 聚合替代加载全量关联记录取 .length
### 🟡 P1 修复
- **分类动态加载**: discover-tools 页面分类列表从硬编码改为 /api/categories 动态加载
- **星级字段统一**: update-stars API 同时写入 rating + githubStars,统一提取 starsToRating() 函数
- **fetch-logos并发Bug修复**: processWithConcurrency 改用 Set<Promise> + then(cleanup) 模式
- **批量发布冲突不删除**: 冲突工具改为跳过标记,不再静默物理删除
- **check-tools API重试**: 添加指数退避重试(最多2次)
- **前端空指针修复**: 13处 email?.split / overallAvg?.toFixed / createdAt?.toLocaleDateString 可选链
- **logo-fetcher日志**: 9处空白 catch 块添加 console.warn 日志
- **rate-limit清理**: 每5分钟自动清理过期IP记录,防止内存无限增长
- **公开API缓存**: 5个公开API添加 Cache-Control 响应头(30s~300s分级缓存)
### 🔴 已修复的前台Bug
- **/reviews 评测列表不显示**: ReviewsList.tsx 添加 useEffect 在挂载时触发初始数据加载
### 📁 变更统计
| 类型 | 数量 |
|------|------|
| 修改文件 | ~55个 |
| 新增文件 | 1个(.env.example) |
| 修复问题 | 43项 |
| TypeScript检查 | 零错误通过 |
---
## 2026-05-25 页面重构 + HeroBanner五色方案
### 🎨 页面重构为三栏布局
- **统一布局**: `grid-cols-[208px_1fr_280px]` (左侧栏208px + 内容区 + 右侧栏280px)
- **/reviews**: HeroBanner + 左侧(外部热度排行+评测分值排行) + 右侧(五维模型+评测说明)
- **/community**: HeroBanner + 左侧(本月活跃用户+积分排行) + 右侧(社区指南)
- **/daily**: 保持原有三栏结构
### 🌈 HeroBanner 五色方案
- **重构组件**: 从三元表达式改为 `SCHEMES` 配置表,代码量从560行减至410行
- **5种配色**:
| 页面 | 配色 | 背景渐变色 | 高亮色 |
|------|------|-----------|--------|
| 首页 `/` | 🔵 cosmos 星空蓝 | `#1e3a5f → #1d4ed8` | `#93c5fd` |
| 技能 `/skills` | 🩷 crimson 玫红 | `#881337 → #e11d48` | `#fda4af` |
| 日报 `/daily` | 🟠 dawn 黎明金 | `#78350f → #a16207` | `#fcd34d` |
| 评测 `/reviews` | 🟣 aurora 极光紫 | `#4c1d95 → #6d28d9` | `#c4b5fd` |
| 社区 `/community` | 🟢 forest 森林绿 | `#064e3b → #047857` | `#6ee7b7` |
- **接口变更**: 旧 `highlightColor + starStyle` → 新 `colorScheme`,一个参数搞定
### 📁 新增/修改
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/components/common/HeroBanner.tsx` | 重写 | 5色方案+配置表 |
| `src/app/reviews/page.tsx` | 重写 | 三栏布局+左侧排行 |
| `src/app/community/page.tsx` | 重写 | 三栏布局+左侧排行 |
| `src/app/community/CommunitySidebar.tsx` | 新增 | 活跃用户+积分排行 |
| `src/app/api/user/rankings/route.ts` | 新增 | 用户排行API |
| `src/components/layout/Header.tsx` | 修改 | 社区导航图标更换 |
---
## 2026-05-25 等级体系升级 + 论坛二级分类 + 会员空间 ## 2026-05-25 等级体系升级 + 论坛二级分类 + 会员空间
### 🔥 20级成长体系 (类似QQ) ### 🔥 20级成长体系 (类似QQ)
+22 -3
View File
@@ -8,7 +8,7 @@
## 服务器与基础设施 ## 服务器与基础设施
- **域名**: www.zhuig.com (HTTPS,SSL证书自动续期) - **域名**: www.zhuig.com (HTTPS,SSL证书自动续期)
- **云服务器**: 119.45.242.239 (登录名: ubuntu, 密钥: E:\ZG_Dev\pem\zg.pem) - **云服务器**: 119.45.242.239 (登录名: **ubuntu**, 密钥: `Pem/zg.pem`)
- **数据库**: 阿里云RDS MySQL — rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com:3306 - **数据库**: 阿里云RDS MySQL — rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com:3306
- **Redis**: 云服务器上已有(Docker容器redis-cache:6379),与其他项目共用,有密码认证 - **Redis**: 云服务器上已有(Docker容器redis-cache:6379),与其他项目共用,有密码认证
- **本地Git同步目录**: K:\git-repos - **本地Git同步目录**: K:\git-repos
@@ -16,6 +16,9 @@
- **部署目录**: /home/ubuntu/zhuiguang-ai - **部署目录**: /home/ubuntu/zhuiguang-ai
- **访问地址**: https://www.zhuig.com (HTTP自动跳转HTTPS) - **访问地址**: https://www.zhuig.com (HTTP自动跳转HTTPS)
- **注意事项**: 服务器有其他应用运行,部署时不可占用非8301-8310端口,不可影响其他服务 - **注意事项**: 服务器有其他应用运行,部署时不可占用非8301-8310端口,不可影响其他服务
- **SSH连接示例**: `ssh -i "Pem/zg.pem" ubuntu@119.45.242.239`
- **PM2管理**: `pm2 list` / `pm2 restart zhuiguang-ai` / `pm2 save`
- **其他应用**: agent-system, nacos, zhuiguang-quant (部署时请勿影响)
## 技术栈 ## 技术栈
- Next.js 14 (App Router) + TypeScript - Next.js 14 (App Router) + TypeScript
@@ -60,8 +63,8 @@
- 批量操作: body中传 `ids` 数组 - 批量操作: body中传 `ids` 数组
## 页面路由 ## 页面路由
- 前台: `/`, `/tools`, `/tools/[slug]`, `/categories/[slug]`, `/skills`, `/skills/[slug]`, `/login`, `/user`, `/user/favorites` - 前台: `/`, `/tools`, `/tools/[slug]`, `/categories/[slug]`, `/skills`, `/skills/[slug]`, `/login`, `/user`, `/user/favorites`, `/user/[id]`(会员空间), `/user/collections`, `/daily`, `/reviews`, `/community`, `/community/[category]`, `/community/topic/[id]`
- 后台: `/admin/login`, `/admin/pending`, `/admin/pending-skills`, `/admin/tools`, `/admin/skills`, `/admin/categories`, `/admin/skill-categories`, `/admin/discover-tools`, `/admin/check-tools`, `/admin/discover-skills`, `/admin/update-skills`, `/admin/discover-news`, `/admin/system-config` - 后台: `/admin/login`, `/admin/pending`, `/admin/pending-skills`, `/admin/tools`, `/admin/skills`, `/admin/categories`, `/admin/skill-categories`, `/admin/discover-tools`, `/admin/check-tools`, `/admin/discover-skills`, `/admin/update-skills`, `/admin/discover-news`, `/admin/system-config`, `/admin/tasks`, `/admin/tasks/logs`, `/admin/tasks/[taskKey]`, `/admin/reviews`
## 五大操作指令 ## 五大操作指令
@@ -169,3 +172,19 @@
- Chart.js组件需用dynamic import + ssr: false,不支持服务端渲染 - Chart.js组件需用dynamic import + ssr: false,不支持服务端渲染
- Prisma 7.8需通过PrismaMariaDb适配器初始化,不能直接new PrismaClient() - Prisma 7.8需通过PrismaMariaDb适配器初始化,不能直接new PrismaClient()
- 服务器环境SSL证书问题: 运行脚本需设置$env:NODE_TLS_REJECT_UNAUTHORIZED="0" - 服务器环境SSL证书问题: 运行脚本需设置$env:NODE_TLS_REJECT_UNAUTHORIZED="0"
### 6. 安全原则
- 敏感信息(密钥、密码、Token)必须使用环境变量,禁止硬编码
- `.env` 文件已被加入 `.gitignore`,仅 `.env.example` 模板可提交
- HTTP 响应必须包含安全头:CSP、HSTS、X-Content-Type-Options、X-Frame-Options
- 文件上传/下载必须限制大小(最大 5MB)和类型,SVG 内容需消毒 script/iframe/on-event 标签
- Logo 文件名使用 SHA256 哈希(不使用 MD5)
- VIP 用户不能访问管理后台(/admin/* 和 /api/admin/*)
### 7. 可靠性原则
- 所有外部 API 调用(GitHub、DeepSeek 等)必须设置超时和重试
- OpenAI/DeepSeek 客户端配置:`timeout: 120000`, `maxRetries: 2`
- Octokit 配置:`request.timeout: 30000`,配合 withRetry 指数退避重试
- 定时脚本启动时必须验证必需环境变量(DEEPSEEK_API_KEY、DATABASE_URL)
- DeepSeek 模型名使用环境变量 `DEEPSEEK_MODEL`(默认 `deepseek-v4-pro`)
- OAuth 用户创建需处理 P2002 唯一约束冲突竞态
+8
View File
@@ -47,6 +47,14 @@ const nextConfig = {
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
], ],
}, },
{
source: "/:path*",
headers: [
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self'; connect-src 'self' https:; frame-ancestors 'none'" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
{ {
source: "/logos/:path*", source: "/logos/:path*",
headers: [ headers: [
+5 -2
View File
@@ -188,8 +188,8 @@ async function genReview(repoName, desc, stars, license, readme) {
.replace("{readmeSummary}", readme.substring(0, 1500)); .replace("{readmeSummary}", readme.substring(0, 1500));
const { choices } = await withRetry(async () => { const { choices } = await withRetry(async () => {
return await openai.chat.completions.create({ return await openai.chat.completions.create({
model: "deepseek-v4-pro", messages: [{ role: "user", content: prompt }], temperature: 0.3, model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro", messages: [{ role: "user", content: prompt }], temperature: 0.3,
}); }, { signal: AbortSignal.timeout(120000) });
}, { maxRetries: 3, label: "DeepSeek Review API" }); }, { maxRetries: 3, label: "DeepSeek Review API" });
const content = choices[0]?.message?.content; const content = choices[0]?.message?.content;
if (!content) throw new Error("Empty response"); if (!content) throw new Error("Empty response");
@@ -206,6 +206,9 @@ async function genReview(repoName, desc, stars, license, readme) {
} }
async function main() { async function main() {
if (!process.env.DEEPSEEK_API_KEY) throw new Error("缺少环境变量: DEEPSEEK_API_KEY");
if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL");
const startTime = new Date(); const startTime = new Date();
console.log(`🚀 [Task4] ${new Date().toISOString().split("T")[0]} 发现新AI技能...\n`); console.log(`🚀 [Task4] ${new Date().toISOString().split("T")[0]} 发现新AI技能...\n`);
const DAILY_LIMIT = 30; const DAILY_LIMIT = 30;
+11 -5
View File
@@ -2,6 +2,7 @@ import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import OpenAI from "openai"; import OpenAI from "openai";
import "dotenv/config"; import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const sep = base.includes("?") ? "&" : "?"; const sep = base.includes("?") ? "&" : "?";
@@ -280,11 +281,13 @@ async function generateDailyNews() {
.replace(/\{newsData\}/g, newsDataText) .replace(/\{newsData\}/g, newsDataText)
.replace(/\{date\}/g, dateStr); .replace(/\{date\}/g, dateStr);
const completion = await openai.chat.completions.create({ const completion = await withRetry(async () => {
model: "deepseek-v4-pro", return await openai.chat.completions.create({
messages: [{ role: "user", content: prompt }], model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro",
temperature: 0.3, messages: [{ role: "user", content: prompt }],
}); temperature: 0.3,
}, { signal: AbortSignal.timeout(120000) });
}, { maxRetries: 3, label: "DeepSeek News API" });
const content = completion.choices[0]?.message?.content; const content = completion.choices[0]?.message?.content;
if (!content) { if (!content) {
@@ -347,6 +350,9 @@ async function generateDailyNews() {
} }
async function main() { async function main() {
if (!process.env.DEEPSEEK_API_KEY) throw new Error("缺少环境变量: DEEPSEEK_API_KEY");
if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL");
const startTime = new Date(); const startTime = new Date();
try { try {
const report = await generateDailyNews(); const report = await generateDailyNews();
+5 -2
View File
@@ -79,6 +79,9 @@ async function checkLogo(logoUrl) {
} }
async function main() { async function main() {
if (!process.env.DEEPSEEK_API_KEY) throw new Error("缺少环境变量: DEEPSEEK_API_KEY");
if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL");
const startTime = new Date(); const startTime = new Date();
console.log(`🔍 [Task1] ${startTime.toISOString().split("T")[0]} 发现新AI工具...`); console.log(`🔍 [Task1] ${startTime.toISOString().split("T")[0]} 发现新AI工具...`);
@@ -131,10 +134,10 @@ async function main() {
try { try {
const completion = await withRetry(async () => { const completion = await withRetry(async () => {
return await openai.chat.completions.create({ return await openai.chat.completions.create({
model: "deepseek-v4-pro", model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro",
messages: [{ role: "user", content: EXTRACT_PROMPT.replace("{content}", text.substring(0, 1500)) }], messages: [{ role: "user", content: EXTRACT_PROMPT.replace("{content}", text.substring(0, 1500)) }],
temperature: 0.2, temperature: 0.2,
}); }, { signal: AbortSignal.timeout(120000) });
}, { maxRetries: 3, label: "DeepSeek API" }); }, { maxRetries: 3, label: "DeepSeek API" });
const content = completion.choices[0]?.message?.content; const content = completion.choices[0]?.message?.content;
if (!content) continue; if (!content) continue;
+11 -5
View File
@@ -3,6 +3,7 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { Octokit } from "octokit"; import { Octokit } from "octokit";
import OpenAI from "openai"; import OpenAI from "openai";
import "dotenv/config"; import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const sep = base.includes("?") ? "&" : "?"; const sep = base.includes("?") ? "&" : "?";
@@ -72,6 +73,9 @@ async function getReadme(owner, repo) {
} }
async function main() { async function main() {
if (!process.env.DEEPSEEK_API_KEY) throw new Error("缺少环境变量: DEEPSEEK_API_KEY");
if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL");
const startTime = new Date(); const startTime = new Date();
const now = new Date(); const now = new Date();
const cnOffset = 8 * 60 * 60 * 1000; const cnOffset = 8 * 60 * 60 * 1000;
@@ -136,11 +140,13 @@ async function main() {
.replace("{readme}", readme.substring(0, 2000)); .replace("{readme}", readme.substring(0, 2000));
try { try {
const completion = await openai.chat.completions.create({ const completion = await withRetry(async () => {
model: "deepseek-v4-pro", return await openai.chat.completions.create({
messages: [{ role: "user", content: prompt }], model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro",
temperature: 0.3, messages: [{ role: "user", content: prompt }],
}); temperature: 0.3,
}, { signal: AbortSignal.timeout(120000) });
}, { maxRetries: 3, label: "DeepSeek Review API" });
const content = completion.choices[0]?.message?.content; const content = completion.choices[0]?.message?.content;
if (!content) throw new Error("DeepSeek返回为空"); if (!content) throw new Error("DeepSeek返回为空");
+27 -24
View File
@@ -3,28 +3,13 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const TOOL_CATEGORIES = [ interface CategoryItem {
{ id: 165, name: "文本生成" }, id: number;
{ id: 174, name: "图像生成" }, name: string;
{ id: 183, name: "视频生成" }, slug: string;
{ id: 191, name: "音频与语音" }, icon: string | null;
{ id: 199, name: "代码开发" }, children: CategoryItem[];
{ id: 208, name: "AI Agent" }, }
{ id: 216, name: "办公效率" },
{ id: 224, name: "设计与创意" },
{ id: 231, name: "营销与SEO" },
{ id: 238, name: "数据分析" },
{ id: 245, name: "教育与学习" },
{ id: 252, name: "搜索与信息" },
{ id: 258, name: "安全与合规" },
{ id: 264, name: "医疗健康" },
{ id: 270, name: "金融与法律" },
{ id: 276, name: "电商与零售" },
{ id: 282, name: "3D与游戏" },
{ id: 288, name: "科学研究" },
{ id: 294, name: "模型与框架" },
{ id: 302, name: "聊天机器人" },
];
const TAG_POOL = [ const TAG_POOL = [
"文本生成", "图像生成", "视频生成", "音频处理", "代码生成", "文本生成", "图像生成", "视频生成", "音频处理", "代码生成",
@@ -33,7 +18,19 @@ const TAG_POOL = [
"企业级", "多模态", "大语言模型", "语音合成", "智能客服", "企业级", "多模态", "大语言模型", "语音合成", "智能客服",
]; ];
function flattenCategories(nodes: CategoryItem[]): { id: number; name: string }[] {
const result: { id: number; name: string }[] = [];
for (const node of nodes) {
result.push({ id: node.id, name: node.name });
if (node.children && node.children.length > 0) {
result.push(...flattenCategories(node.children));
}
}
return result;
}
export default function DiscoverToolsPage() { export default function DiscoverToolsPage() {
const [toolCategories, setToolCategories] = useState<{ id: number; name: string }[]>([]);
const [excludedToolNames, setExcludedToolNames] = useState<string[]>([]); const [excludedToolNames, setExcludedToolNames] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<{ id: number; name: string } | null>(null); const [selectedCategory, setSelectedCategory] = useState<{ id: number; name: string } | null>(null);
@@ -89,7 +86,13 @@ export default function DiscoverToolsPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchExcludedNames().finally(() => setLoading(false)); Promise.all([
fetchExcludedNames(),
fetch("/api/categories")
.then((r) => r.json())
.then((data: CategoryItem[]) => setToolCategories(flattenCategories(data)))
.catch(() => {}),
]).finally(() => setLoading(false));
}, [fetchExcludedNames]); }, [fetchExcludedNames]);
const getPrompt = () => { const getPrompt = () => {
@@ -192,7 +195,7 @@ export default function DiscoverToolsPage() {
<div className="mb-8"> <div className="mb-8">
<h2 className="text-lg font-semibold mb-3">选择分类</h2> <h2 className="text-lg font-semibold mb-3">选择分类</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
{TOOL_CATEGORIES.map((cat) => { {toolCategories.map((cat) => {
const isSelected = selectedCategory?.id === cat.id; const isSelected = selectedCategory?.id === cat.id;
return ( return (
<button <button
+1 -1
View File
@@ -97,7 +97,7 @@ export default function ReviewsPage() {
total={stats.total} total={stats.total}
published={stats.published} published={stats.published}
draft={stats.draft} draft={stats.draft}
average={stats.overallAvg.toFixed(1)} average={stats.overallAvg?.toFixed(1) ?? "0.0"}
currentFilter={statusFilter} currentFilter={statusFilter}
onFilterChange={(f) => { onFilterChange={(f) => {
setStatusFilter(f); setStatusFilter(f);
+1 -2
View File
@@ -244,8 +244,7 @@ export default function HomePageClient({
<HeroBanner <HeroBanner
title="追光" title="追光"
highlight="AI" highlight="AI"
highlightColor="blue" colorScheme="cosmos"
starStyle="cosmos"
stats={[ stats={[
{ label: "AI工具", value: totalTools, unit: "个" }, { label: "AI工具", value: totalTools, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" }, { label: "分类", value: categories.length, unit: "个" },
+31 -14
View File
@@ -15,17 +15,12 @@ interface CheckResult {
logoUrl: string; logoUrl: string;
} }
async function checkSingleTool(tool: { async function checkWebsite(url: string, maxRetries = 2): Promise<{ accessible: boolean; html: string | null }> {
id: number; let lastError: unknown;
name: string; for (let attempt = 0; attempt <= maxRetries; attempt++) {
websiteUrl: string | null; if (attempt > 0) {
logoUrl: string | null; await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
}, type: "published" | "pending"): Promise<CheckResult> { }
const url = tool.websiteUrl;
let accessible = false;
let html: string | null = null;
if (url) {
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000); const timeout = setTimeout(() => controller.abort(), 8000);
@@ -41,7 +36,8 @@ async function checkSingleTool(tool: {
}); });
clearTimeout(timeout); clearTimeout(timeout);
accessible = res.status >= 200 && res.status < 400; const accessible = res.status >= 200 && res.status < 400;
let html: string | null = null;
if (accessible) { if (accessible) {
const ct = res.headers.get("content-type") || ""; const ct = res.headers.get("content-type") || "";
@@ -49,10 +45,29 @@ async function checkSingleTool(tool: {
html = await res.text(); html = await res.text();
} }
} }
} catch { return { accessible, html };
accessible = false; } catch (e) {
lastError = e;
} }
} }
return { accessible: false, html: null };
}
async function checkSingleTool(tool: {
id: number;
name: string;
websiteUrl: string | null;
logoUrl: string | null;
}, type: "published" | "pending"): Promise<CheckResult> {
const url = tool.websiteUrl;
let accessible = false;
let html: string | null = null;
if (url) {
const result = await checkWebsite(url);
accessible = result.accessible;
html = result.html;
}
let logoFetched = false; let logoFetched = false;
let logoMethod = ""; let logoMethod = "";
@@ -124,9 +139,11 @@ export async function POST(request: Request) {
type === "published" type === "published"
? await prisma.tool.findMany({ ? await prisma.tool.findMany({
select: { id: true, name: true, websiteUrl: true, logoUrl: true }, select: { id: true, name: true, websiteUrl: true, logoUrl: true },
take: 500,
}) })
: await prisma.pendingTool.findMany({ : await prisma.pendingTool.findMany({
select: { id: true, name: true, websiteUrl: true, logoUrl: true }, select: { id: true, name: true, websiteUrl: true, logoUrl: true },
take: 500,
}); });
const CONCURRENCY = 10; const CONCURRENCY = 10;
+2 -2
View File
@@ -17,8 +17,8 @@ export async function POST(request: Request) {
const { skills, categoryId } = parsed.data; const { skills, categoryId } = parsed.data;
const [existingPending, existingPublished] = await Promise.all([ const [existingPending, existingPublished] = await Promise.all([
prisma.pendingSkill.findMany({ select: { name: true, sourceUrl: true } }), prisma.pendingSkill.findMany({ select: { name: true, sourceUrl: true }, take: 1000 }),
prisma.skill.findMany({ select: { name: true, sourceUrl: true } }), prisma.skill.findMany({ select: { name: true, sourceUrl: true }, take: 1000 }),
]); ]);
const allExisting = [...existingPending, ...existingPublished]; const allExisting = [...existingPending, ...existingPublished];
+2 -2
View File
@@ -17,8 +17,8 @@ export async function POST(request: Request) {
const { tools, categoryId } = parsed.data; const { tools, categoryId } = parsed.data;
const [existingPending, existingPublished] = await Promise.all([ const [existingPending, existingPublished] = await Promise.all([
prisma.pendingTool.findMany({ select: { name: true, websiteUrl: true } }), prisma.pendingTool.findMany({ select: { name: true, websiteUrl: true }, take: 1000 }),
prisma.tool.findMany({ select: { name: true, websiteUrl: true } }), prisma.tool.findMany({ select: { name: true, websiteUrl: true }, take: 1000 }),
]); ]);
const allExisting = [...existingPending, ...existingPublished]; const allExisting = [...existingPending, ...existingPublished];
@@ -15,6 +15,26 @@ export async function POST(request: Request, { params }: { params: { id: string
return NextResponse.json({ error: "该记录已被处理" }, { status: 409 }); return NextResponse.json({ error: "该记录已被处理" }, { status: 409 });
} }
const [nameConflict, urlConflict, slugConflict] = await Promise.all([
prisma.skill.findFirst({ where: { name: pending.name }, select: { id: true } }),
pending.sourceUrl
? prisma.skill.findFirst({ where: { sourceUrl: pending.sourceUrl }, select: { id: true } })
: Promise.resolve(null),
pending.slug
? prisma.skill.findUnique({ where: { slug: pending.slug }, select: { id: true } })
: Promise.resolve(null),
]);
if (nameConflict) {
return NextResponse.json({ error: `技能名"${pending.name}"已存在,无法发布` }, { status: 409 });
}
if (urlConflict) {
return NextResponse.json({ error: `来源地址"${pending.sourceUrl}"已被已发布技能使用` }, { status: 409 });
}
if (slugConflict) {
return NextResponse.json({ error: `slug"${pending.slug}"已存在,请编辑后再发布` }, { status: 409 });
}
const slug = pending.slug || pending.name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, "").substring(0, 100); const slug = pending.slug || pending.name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, "").substring(0, 100);
const features = Array.isArray(pending.features) ? pending.features as any[] : []; const features = Array.isArray(pending.features) ? pending.features as any[] : [];
@@ -33,6 +53,7 @@ export async function POST(request: Request, { params }: { params: { id: string
sourceType: pending.sourceType || "github", sourceType: pending.sourceType || "github",
features, features,
tags, tags,
rating: pending.rating || 0,
status: "published", status: "published",
}, },
}); });
-13
View File
@@ -43,16 +43,3 @@ export async function GET(request: Request) {
return NextResponse.json({ error: err.message }, { status: 500 }); return NextResponse.json({ error: err.message }, { status: 500 });
} }
} }
export async function DELETE() {
const authError = await requireAdmin();
if (authError) return authError;
try {
const result = await prisma.pendingSkill.deleteMany({});
return NextResponse.json({ success: true, deleted: result.count });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
@@ -57,10 +57,6 @@ export async function POST(request: Request) {
toPublish.push(pending); toPublish.push(pending);
} }
if (skipIds.length > 0) {
await prisma.pendingTool.deleteMany({ where: { id: { in: skipIds } } });
}
if (toPublish.length === 0) { if (toPublish.length === 0) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
+8 -10
View File
@@ -12,23 +12,19 @@ async function processWithConcurrency<T, R>(
fn: (item: T) => Promise<R> fn: (item: T) => Promise<R>
): Promise<R[]> { ): Promise<R[]> {
const results: R[] = []; const results: R[] = [];
const executing: Promise<void>[] = []; const executing: Set<Promise<void>> = new Set();
for (const item of items) { for (const item of items) {
const p = fn(item).then((result) => { const p = fn(item).then((result) => {
results.push(result); results.push(result);
}); });
executing.push(p); executing.add(p);
if (executing.length >= limit) { const cleanup = () => executing.delete(p);
p.then(cleanup, cleanup);
if (executing.size >= limit) {
await Promise.race(executing); await Promise.race(executing);
for (let i = executing.length - 1; i >= 0; i--) {
const settled = await Promise.race([
executing[i].then(() => true, () => true),
Promise.resolve(false),
]);
if (settled) executing.splice(i, 1);
}
} }
} }
@@ -62,6 +58,7 @@ export async function POST(request: Request) {
const publishedTools = await prisma.tool.findMany({ const publishedTools = await prisma.tool.findMany({
where: { ...logoFilter, ...(toolIds ? { id: { in: toolIds } } : {}) }, where: { ...logoFilter, ...(toolIds ? { id: { in: toolIds } } : {}) },
select: { id: true, name: true, websiteUrl: true, logoUrl: true }, select: { id: true, name: true, websiteUrl: true, logoUrl: true },
take: 500,
}); });
tools.push(...publishedTools.map((t) => ({ ...t, model: "tool" }))); tools.push(...publishedTools.map((t) => ({ ...t, model: "tool" })));
} }
@@ -70,6 +67,7 @@ export async function POST(request: Request) {
const pendingTools = await prisma.pendingTool.findMany({ const pendingTools = await prisma.pendingTool.findMany({
where: { websiteUrl: { not: null }, ...logoFilter, ...(toolIds ? { id: { in: toolIds } } : {}) }, where: { websiteUrl: { not: null }, ...logoFilter, ...(toolIds ? { id: { in: toolIds } } : {}) },
select: { id: true, name: true, websiteUrl: true, logoUrl: true }, select: { id: true, name: true, websiteUrl: true, logoUrl: true },
take: 500,
}); });
tools.push(...pendingTools.map((t) => ({ ...t, model: "pendingTool" }))); tools.push(...pendingTools.map((t) => ({ ...t, model: "pendingTool" })));
} }
+58 -43
View File
@@ -10,72 +10,73 @@ interface UpdateResult {
newRating: number; newRating: number;
} }
async function fetchGiteeRating(owner: string, repo: string): Promise<number> { interface StarResult {
rating: number;
stars: number;
}
async function fetchGiteeRating(owner: string, repo: string): Promise<StarResult> {
try { try {
const res = await fetch(`https://gitee.com/api/v5/repos/${owner}/${repo}`, { const res = await fetch(`https://gitee.com/api/v5/repos/${owner}/${repo}`, {
headers: { "User-Agent": "ZhuiGuangAI-Bot" }, headers: { "User-Agent": "ZhuiGuangAI-Bot" },
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
}); });
if (!res.ok) return 0; if (!res.ok) return { rating: 0, stars: 0 };
const data = await res.json(); const data = await res.json();
const stars = data.stargazers_count || data.forks_count || 0; const stars = data.stargazers_count || data.forks_count || 0;
if (stars >= 5000) return 5.0; return { stars, rating: starsToRating(stars) };
if (stars >= 2000) return 4.5;
if (stars >= 500) return 4.0;
if (stars >= 100) return 3.5;
if (stars >= 30) return 3.0;
if (stars >= 10) return 2.5;
return 2.0;
} catch { } catch {
return 0; return { rating: 0, stars: 0 };
} }
} }
async function fetchGitCodeRating(owner: string, repo: string): Promise<number> { async function fetchGitCodeRating(owner: string, repo: string): Promise<StarResult> {
try { try {
const res = await fetch(`https://api.gitcode.com/api/v5/repos/${owner}/${repo}`, { const res = await fetch(`https://api.gitcode.com/api/v5/repos/${owner}/${repo}`, {
headers: { "User-Agent": "ZhuiGuangAI-Bot" }, headers: { "User-Agent": "ZhuiGuangAI-Bot" },
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
}); });
if (!res.ok) return 0; if (!res.ok) return { rating: 0, stars: 0 };
const data = await res.json(); const data = await res.json();
const stars = data.stargazers_count || data.forks_count || 0; const stars = data.stargazers_count || data.forks_count || 0;
if (stars >= 5000) return 5.0; return { stars, rating: starsToRating(stars) };
if (stars >= 2000) return 4.5;
if (stars >= 500) return 4.0;
if (stars >= 100) return 3.5;
if (stars >= 30) return 3.0;
if (stars >= 10) return 2.5;
return 2.0;
} catch { } catch {
return 0; return { rating: 0, stars: 0 };
} }
} }
async function fetchGitHubRating(owner: string, repo: string): Promise<number> { async function fetchGitHubRating(owner: string, repo: string): Promise<StarResult> {
try { try {
const headers: Record<string, string> = {
"User-Agent": "ZhuiGuangAI-Bot",
Accept: "application/vnd.github.v3+json",
};
if (process.env.GITHUB_TOKEN) {
headers["Authorization"] = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { headers,
"User-Agent": "ZhuiGuangAI-Bot",
Accept: "application/vnd.github.v3+json",
},
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
}); });
if (!res.ok) return 0; if (!res.ok) return { rating: 0, stars: 0 };
const data = await res.json(); const data = await res.json();
const stars = (data as { stargazers_count?: number }).stargazers_count ?? 0; const stars = (data as { stargazers_count?: number }).stargazers_count ?? 0;
if (stars >= 10000) return 5.0; return { stars, rating: starsToRating(stars) };
if (stars >= 5000) return 4.5;
if (stars >= 1000) return 4.0;
if (stars >= 200) return 3.5;
if (stars >= 50) return 3.0;
if (stars >= 10) return 2.5;
return 2.0;
} catch { } catch {
return 0; return { rating: 0, stars: 0 };
} }
} }
function starsToRating(stars: number): number {
if (stars >= 10000) return 5.0;
if (stars >= 5000) return 4.5;
if (stars >= 1000) return 4.0;
if (stars >= 200) return 3.5;
if (stars >= 50) return 3.0;
if (stars >= 10) return 2.5;
return 2.0;
}
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requireAdmin(); const authError = await requireAdmin();
if (authError) return authError; if (authError) return authError;
@@ -95,11 +96,13 @@ export async function POST(request: Request) {
type === "published" type === "published"
? await prisma.skill.findMany({ ? await prisma.skill.findMany({
where: { sourceUrl: { not: "" } }, where: { sourceUrl: { not: "" } },
select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true }, select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true, githubStars: true },
take: 500,
}) })
: await prisma.pendingSkill.findMany({ : await prisma.pendingSkill.findMany({
where: { sourceUrl: { not: "" } }, where: { sourceUrl: { not: "" } },
select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true }, select: { id: true, name: true, sourceUrl: true, sourceType: true, rating: true },
take: 500,
}); });
const results: UpdateResult[] = []; const results: UpdateResult[] = [];
@@ -108,28 +111,40 @@ export async function POST(request: Request) {
if (!skill.sourceUrl) continue; if (!skill.sourceUrl) continue;
try { try {
let newRating = 0; let result: StarResult = { rating: 0, stars: 0 };
const url = skill.sourceUrl; const url = skill.sourceUrl;
if (url.includes("gitee.com")) { if (url.includes("gitee.com")) {
const match = url.match(/gitee\.com\/([^/]+)\/([^/]+)/); const match = url.match(/gitee\.com\/([^/]+)\/([^/]+)/);
if (match) newRating = await fetchGiteeRating(match[1], match[2].replace(/\.git$/, "")); if (match) result = await fetchGiteeRating(match[1], match[2].replace(/\.git$/, ""));
} else if (url.includes("gitcode.com")) { } else if (url.includes("gitcode.com")) {
const match = url.match(/gitcode\.com\/([^/]+)\/([^/]+)/); const match = url.match(/gitcode\.com\/([^/]+)\/([^/]+)/);
if (match) newRating = await fetchGitCodeRating(match[1], match[2].replace(/\.git$/, "")); if (match) result = await fetchGitCodeRating(match[1], match[2].replace(/\.git$/, ""));
} else if (url.includes("github.com")) { } else if (url.includes("github.com")) {
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/); const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
if (match) newRating = await fetchGitHubRating(match[1], match[2].replace(/\.git$/, "")); if (match) result = await fetchGitHubRating(match[1], match[2].replace(/\.git$/, ""));
} }
if (newRating > 0) { if (result.rating > 0) {
const oldRating = skill.rating; const oldRating = skill.rating;
if (type === "published") { if (type === "published") {
await prisma.skill.update({ where: { id: skill.id }, data: { rating: newRating } }); await prisma.skill.update({
where: { id: skill.id },
data: {
rating: result.rating,
githubStars: result.stars > 0 ? result.stars : undefined,
},
});
} else { } else {
await prisma.pendingSkill.update({ where: { id: skill.id }, data: { rating: newRating } }); await prisma.pendingSkill.update({ where: { id: skill.id }, data: { rating: result.rating } });
} }
results.push({ id: skill.id, name: skill.name, sourceType: skill.sourceType || "unknown", oldRating, newRating }); results.push({
id: skill.id,
name: skill.name,
sourceType: skill.sourceType || "unknown",
oldRating,
newRating: result.rating,
});
} }
} catch (err) { } catch (err) {
console.error(`Failed to update rating for skill ${skill.id}:`, err); console.error(`Failed to update rating for skill ${skill.id}:`, err);
+3 -1
View File
@@ -85,7 +85,9 @@ export async function GET(req: Request) {
})), })),
})); }));
return NextResponse.json({ comments: formatted, totalCount, page, pageSize }); const res = NextResponse.json({ comments: formatted, totalCount, page, pageSize });
res.headers.set("Cache-Control", "public, s-maxage=30, stale-while-revalidate=120");
return res;
} }
export async function POST(req: Request) { export async function POST(req: Request) {
+3 -1
View File
@@ -12,5 +12,7 @@ export async function GET() {
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
}); });
return NextResponse.json({ categories }); const res = NextResponse.json({ categories });
res.headers.set("Cache-Control", "public, s-maxage=300, stale-while-revalidate=120");
return res;
} }
+3 -1
View File
@@ -38,7 +38,9 @@ export async function GET(req: Request, { params }: { params: { id: string } })
}, },
})); }));
return NextResponse.json({ posts: formatted, totalCount, page, pageSize }); const res = NextResponse.json({ posts: formatted, totalCount, page, pageSize });
res.headers.set("Cache-Control", "public, s-maxage=30, stale-while-revalidate=120");
return res;
} }
export async function POST(req: Request, { params }: { params: { id: string } }) { export async function POST(req: Request, { params }: { params: { id: string } }) {
+3 -1
View File
@@ -60,7 +60,9 @@ export async function GET(req: Request) {
}, },
})); }));
return NextResponse.json({ topics: formatted, totalCount, page, pageSize }); const res = NextResponse.json({ topics: formatted, totalCount, page, pageSize });
res.headers.set("Cache-Control", "public, s-maxage=30, stale-while-revalidate=120");
return res;
} }
export async function POST(req: Request) { export async function POST(req: Request) {
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const type = searchParams.get("type") || "points";
const limit = Math.min(parseInt(searchParams.get("limit") || "10"), 50);
if (type === "active") {
const users = await prisma.user.findMany({
where: {
OR: [
{ forumTopics: { some: {} } },
{ forumPosts: { some: {} } },
],
},
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
level: true,
points: true,
},
orderBy: { points: "desc" },
take: limit,
});
const res = NextResponse.json({ users });
res.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=120");
return res;
}
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
level: true,
points: true,
},
orderBy: { points: "desc" },
take: limit,
});
const res = NextResponse.json({ users });
res.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=120");
return res;
} catch {
const res = NextResponse.json({ users: [] }, { status: 500 });
res.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=120");
return res;
}
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getLevelInfo } from "@/lib/level-config";
interface UserRanking {
id: number;
name: string | null;
email: string;
avatarUrl: string | null;
level: string;
points: number;
bio?: string | null;
}
export function CommunitySidebar() {
const [monthlyActive, setMonthlyActive] = useState<UserRanking[]>([]);
const [topByPoints, setTopByPoints] = useState<UserRanking[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/user/rankings?type=active&limit=10")
.then((r) => r.json())
.then((data) => {
setMonthlyActive(data.users || []);
})
.catch(() => {})
.finally(() => {});
fetch("/api/user/rankings?type=points&limit=10")
.then((r) => r.json())
.then((data) => {
setTopByPoints(data.users || []);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, []);
const renderSkeleton = () => (
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 animate-pulse">
<div className="h-5 w-5 rounded bg-muted/40" />
<div className="h-4 w-4 rounded-full bg-muted/40" />
<div className="h-3 flex-1 rounded bg-muted/40" />
</div>
))
);
const renderUserRow = (user: UserRanking, i: number) => {
const initial = user.name?.charAt(0).toUpperCase() || user.email.charAt(0).toUpperCase() || "?";
const levelInfo = getLevelInfo(user.points);
return (
<Link
key={user.id}
href={`/user/${user.id}`}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
>
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-sky-100 text-sky-700" : "bg-muted/30 text-muted-foreground"}`}>{i + 1}</span>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-4 w-4 rounded-full object-cover shrink-0" />
) : (
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-sky-100 text-[8px] font-bold text-sky-600 shrink-0">
{initial}
</div>
)}
<span className="flex-1 truncate text-left text-[12px]">{user.name || user.email?.split("@")[0]}</span>
{levelInfo && (
<span className="text-[9px] text-foreground/30 shrink-0">{levelInfo.label}</span>
)}
</Link>
);
};
return (
<div className="sidebar-bg rounded-xl p-3 sticky top-[68px] max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin space-y-4">
{/* Monthly Active Users */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-sky-500 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">本月活跃用户</span>
</div>
<div className="space-y-0.5">
{loading ? renderSkeleton() : monthlyActive.map(renderUserRow)}
</div>
</div>
<div className="h-px bg-border/40" />
{/* Total User Points Ranking */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-500 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">积分总排行</span>
</div>
<div className="space-y-0.5">
{loading ? renderSkeleton() : topByPoints.map(renderUserRow)}
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -99,8 +99,8 @@ export default async function CategoryPage({ params, searchParams }: Props) {
</h3> </h3>
</div> </div>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50"> <div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>{topic.user.name || topic.user.email.split("@")[0]}</span> <span>{topic.user.name || topic.user.email?.split("@")[0]}</span>
<span>{topic.createdAt.toLocaleDateString("zh-CN")}</span> <span>{topic.createdAt?.toLocaleDateString("zh-CN")}</span>
<span>{topic._count.posts}回复</span> <span>{topic._count.posts}回复</span>
<span>{topic.viewCount}浏览</span> <span>{topic.viewCount}浏览</span>
</div> </div>
+129 -110
View File
@@ -1,11 +1,26 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth"; import Link from "next/link";
import { authOptions } from "@/lib/auth"; import { HeroBanner } from "@/components/common/HeroBanner";
import { CommunitySidebar } from "./CommunitySidebar";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
async function getHotTopics() { export default async function CommunityPage() {
return prisma.forumTopic.findMany({ const [categories, topicsCount, userCount] = await Promise.all([
prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
}),
prisma.forumTopic.count(),
prisma.user.count(),
]);
const hotTopics = await prisma.forumTopic.findMany({
where: {}, where: {},
include: { include: {
category: { select: { name: true, slug: true, icon: true } }, category: { select: { name: true, slug: true, icon: true } },
@@ -15,92 +30,87 @@ async function getHotTopics() {
orderBy: [{ isPinned: "desc" }, { createdAt: "desc" }], orderBy: [{ isPinned: "desc" }, { createdAt: "desc" }],
take: 5, take: 5,
}); });
}
async function getActiveUsers() {
return prisma.user.findMany({
where: {
OR: [
{ forumTopics: { some: {} } },
{ forumPosts: { some: {} } },
],
},
select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true },
take: 5,
});
}
export default async function CommunityPage() {
const categories = await prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
const [hotTopics, activeUsers] = await Promise.all([getHotTopics(), getActiveUsers()]);
return ( return (
<div className="min-h-screen bg-[#f8fafc]"> <div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="max-w-[1440px] mx-auto px-4 py-8"> <div className="grid grid-cols-1 lg:grid-cols-[208px_1fr_280px] gap-6">
<div className="mb-8">
<h1 className="text-2xl font-bold">追光会</h1>
<p className="mt-1 text-sm text-muted-foreground">AI爱好者交流社区</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> {/* Left Sidebar: Rankings */}
<div className="lg:col-span-2 space-y-6"> <aside className="hidden lg:block">
<div> <CommunitySidebar />
<h2 className="text-lg font-semibold mb-4">板块</h2> </aside>
<div className="space-y-4">
{categories.map((cat) => ( {/* Middle Content */}
<div key={cat.id}> <main className="min-w-0">
<div className="flex items-center gap-3 mb-2"> <HeroBanner
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/5 text-lg"> title="追光"
{cat.icon || "📌"} highlight="会"
</div> colorScheme="forest"
<h3 className="text-sm font-semibold">{cat.name}</h3> stats={[
{cat.description && ( { label: "板块", value: categories.reduce((sum, c) => sum + c.children.length + 1, 0), unit: "个" },
<span className="text-xs text-muted-foreground/40">{cat.description}</span> { label: "话题", value: topicsCount, unit: "帖" },
)} { label: "会员", value: userCount, unit: "人" },
</div> ]}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 ml-11"> slogan="AI爱好者交流社区,分享你的发现和思考"
{cat.children.map((child) => ( subSlogan="AI工具 · 开源技能 · 技术深度 · 教程分享 · 社区互动"
<div />
key={child.id}
className="flex items-center gap-3 p-3 rounded-lg border border-border/30 bg-white/60 transition-all hover:border-primary/30 hover:bg-white cursor-pointer group" {/* Forum Categories */}
onClick={() => (window.location.href = `/community/${child.slug}`)} <div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
> <div className="px-5 py-4 border-b border-border/30">
<span className="text-lg">{child.icon || "📌"}</span> <h2 className="text-sm font-semibold text-foreground">板块</h2>
<div className="min-w-0 flex-1">
<h4 className="text-xs font-medium group-hover:text-primary transition-colors truncate">{child.name}</h4>
{child.description && (
<p className="text-[10px] text-muted-foreground/50 truncate">{child.description}</p>
)}
</div>
<span className="text-[10px] text-muted-foreground/30">{child.topicCount}话题</span>
</div>
))}
</div>
</div>
))}
</div>
</div> </div>
<div className="p-5 space-y-5">
{categories.map((cat) => (
<div key={cat.id}>
<div className="flex items-center gap-3 mb-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/5 text-base">
{cat.icon || "📌"}
</div>
<h3 className="text-sm font-semibold">{cat.name}</h3>
{cat.description && (
<span className="text-[11px] text-muted-foreground/50">{cat.description}</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 ml-10">
{cat.children.map((child) => (
<Link href={`/community/${child.slug}`} key={child.id}
className="flex items-center gap-3 p-3 rounded-lg border border-border/30 bg-white/60 transition-all hover:border-primary/30 hover:bg-white group">
<span className="text-lg">{child.icon || "📌"}</span>
<div className="min-w-0 flex-1">
<h4 className="text-xs font-medium group-hover:text-primary transition-colors truncate">{child.name}</h4>
{child.description && (
<p className="text-[10px] text-muted-foreground/50 truncate">{child.description}</p>
)}
</div>
<span className="text-[10px] text-muted-foreground/30">{child.topicCount}话题</span>
</Link>
))}
</div>
</div>
))}
</div>
</div>
<div> {/* Hot Topics */}
<h2 className="text-lg font-semibold mb-4">最新话题</h2> <div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="space-y-3"> <div className="px-5 py-4 border-b border-border/30">
{hotTopics.map((topic) => { <h2 className="text-sm font-semibold text-foreground">最新话题</h2>
</div>
<div className="divide-y divide-border/30">
{hotTopics.length === 0 ? (
<div className="p-12 text-center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto mb-3 text-muted-foreground/30">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
<p className="text-sm text-muted-foreground">暂无话题</p>
</div>
) : (
hotTopics.map((topic) => {
const initial = topic.user.name?.charAt(0).toUpperCase() || "?"; const initial = topic.user.name?.charAt(0).toUpperCase() || "?";
return ( return (
<div <Link href={`/community/topic/${topic.id}`} key={topic.id}
key={topic.id} className="flex items-start gap-3 p-4 transition-all hover:bg-muted/20">
className="flex items-start gap-3 p-4 rounded-xl border border-border/40 bg-white transition-all hover:border-border/60 cursor-pointer"
onClick={() => (window.location.href = `/community/topic/${topic.id}`)}
>
{topic.user.avatarUrl ? ( {topic.user.avatarUrl ? (
<img src={topic.user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" /> <img src={topic.user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" />
) : ( ) : (
@@ -118,45 +128,54 @@ export default async function CommunityPage() {
</h3> </h3>
</div> </div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground/50"> <div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>{topic.user.name || topic.user.email.split("@")[0]}</span> <span>{topic.user.name || topic.user.email?.split("@")[0]}</span>
<span>{topic.category.name}</span> <span>{topic.category.name}</span>
<span>{topic._count.posts}回复</span> <span>{topic._count.posts}回复</span>
<span>{topic.viewCount}浏览</span> <span>{topic.viewCount}浏览</span>
</div> </div>
</div> </div>
</div> </Link>
); );
})} })
</div> )}
</div> </div>
</div> </div>
</main>
<div className="space-y-6"> {/* Right Sidebar */}
<div className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"> <aside className="hidden lg:block">
<h3 className="text-sm font-semibold mb-4">活跃用户</h3> <div className="sticky top-[68px] space-y-5">
<div className="space-y-3"> <div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
{activeUsers.map((user) => { <div className="px-4 py-3 border-b border-border/30">
const initial = user.name?.charAt(0).toUpperCase() || "?"; <div className="flex items-center gap-2">
return ( <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-blue-500">
<div key={user.id} className="flex items-center gap-3"> <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
{user.avatarUrl ? ( </svg>
<img src={user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" /> <h3 className="text-sm font-semibold text-foreground">社区指南</h3>
) : ( </div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary"> </div>
{initial} <div className="p-4 text-xs text-muted-foreground space-y-3">
</div> <div className="flex items-start gap-2">
)} <span className="text-amber-500 font-bold mt-0.5">1.</span>
<div className="min-w-0 flex-1"> <p>在对应板块发布高质量话题,获得积分奖励</p>
<p className="text-sm font-medium truncate">{user.name || user.email.split("@")[0]}</p> </div>
<p className="text-xs text-muted-foreground/50">{user.points}分</p> <div className="flex items-start gap-2">
</div> <span className="text-amber-500 font-bold mt-0.5">2.</span>
</div> <p>积极参与讨论,有价值的回复将被标记为最佳答案</p>
); </div>
})} <div className="flex items-start gap-2">
<span className="text-amber-500 font-bold mt-0.5">3.</span>
<p>分享你的AI工具使用心得和技能开发经验</p>
</div>
<div className="flex items-start gap-2">
<span className="text-amber-500 font-bold mt-0.5">4.</span>
<p>积分越高等级越高,解锁更多社区特权</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </aside>
</div> </div>
</div> </div>
); );
+4 -4
View File
@@ -79,9 +79,9 @@ export default async function TopicPage({ params }: Props) {
</div> </div>
)} )}
<div> <div>
<p className="text-sm font-medium">{topic.user.name || topic.user.email.split("@")[0]}</p> <p className="text-sm font-medium">{topic.user.name || topic.user.email?.split("@")[0]}</p>
<p className="text-xs text-muted-foreground/50"> <p className="text-xs text-muted-foreground/50">
{topic.createdAt.toLocaleDateString("zh-CN")} {topic.createdAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })} {topic.createdAt?.toLocaleDateString("zh-CN")} {topic.createdAt?.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</p> </p>
</div> </div>
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground/50"> <div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground/50">
@@ -113,9 +113,9 @@ export default async function TopicPage({ params }: Props) {
</div> </div>
)} )}
<div> <div>
<p className="text-sm font-medium">{post.user.name || post.user.email.split("@")[0]}</p> <p className="text-sm font-medium">{post.user.name || post.user.email?.split("@")[0]}</p>
<p className="text-xs text-muted-foreground/50"> <p className="text-xs text-muted-foreground/50">
{post.createdAt.toLocaleDateString("zh-CN")} {post.createdAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })} {post.createdAt?.toLocaleDateString("zh-CN")} {post.createdAt?.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</p> </p>
</div> </div>
{isTopicAuthor && ( {isTopicAuthor && (
+1 -2
View File
@@ -360,8 +360,7 @@ export default function DailyPage() {
<HeroBanner <HeroBanner
title="追光" title="追光"
highlight="日报" highlight="日报"
highlightColor="amber" colorScheme="dawn"
starStyle="dawn"
stats={[ stats={[
{ label: "日报", value: allItems.length, unit: "条" }, { label: "日报", value: allItems.length, unit: "条" },
{ label: "分类", value: CATEGORY_KEYS.length, unit: "个" }, { label: "分类", value: CATEGORY_KEYS.length, unit: "个" },
+8 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -181,10 +181,16 @@ export function ReviewsList({ initialReviews, initialTotal }: Props) {
setReviews(data.reviews || []); setReviews(data.reviews || []);
setTotal(data.total || 0); setTotal(data.total || 0);
} }
} catch {} } catch {
console.error("加载评测列表失败");
}
setLoading(false); setLoading(false);
}, []); }, []);
useEffect(() => {
fetchReviews(1);
}, []);
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
if (isInitial) setIsInitial(false); if (isInitial) setIsInitial(false);
setPage(newPage); setPage(newPage);
+175 -67
View File
@@ -1,80 +1,188 @@
import { prisma } from "@/lib/prisma"; "use client";
import { Metadata } from "next";
import { useState, useEffect } from "react";
import { HeroBanner } from "@/components/common/HeroBanner";
import { ReviewsList } from "./ReviewsList"; import { ReviewsList } from "./ReviewsList";
import { SidebarRankings } from "./SidebarRankings";
export const metadata: Metadata = { interface RankingItem {
title: "AI评测", id: number;
description: "基于五维模型的AI技能深度评测,涵盖场景能力、开发体验、成本许可、社区健康、性能稳定", overall?: number;
}; skill?: { name: string; slug: string; stars?: number };
name?: string;
slug?: string;
githubStars?: number;
starsChangeWeek?: number;
starsChangeDay?: number;
}
export default async function ReviewsPage() { interface RankingsData {
const pageSize = 12; topByRating: RankingItem[];
topByStarsChange: RankingItem[];
totalReviews: number;
}
const [reviews, total] = await Promise.all([ export default function ReviewsPage() {
prisma.skillReview.findMany({ const [rankings, setRankings] = useState<RankingsData | null>(null);
where: { status: "PUBLISHED" }, const [totalReviews, setTotalReviews] = useState(0);
include: {
skill: {
select: {
id: true,
name: true,
slug: true,
description: true,
sourceUrl: true,
category: { select: { name: true, slug: true } },
},
},
},
orderBy: { overall: "desc" },
skip: 0,
take: pageSize,
}),
prisma.skillReview.count({ where: { status: "PUBLISHED" } }),
]);
const serializedReviews = reviews.map((r) => ({ useEffect(() => {
id: r.id, fetch("/api/skill-reviews/rankings")
overall: r.overall, .then((r) => r.json())
capability: r.capability, .then((data) => {
devExp: r.devExp, setRankings(data);
costLicense: r.costLicense, setTotalReviews(data.totalReviews || 0);
community: r.community, })
performance: r.performance, .catch(() => {
reviews: r.reviews as Record<string, { score: number; summary: string; detail: string }> | undefined, console.error("加载排行数据失败");
skill: r.skill, });
reviewedAt: r.reviewedAt.toISOString(), }, []);
}));
return ( return (
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 py-8"> <div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="mb-8"> <div className="grid grid-cols-1 lg:grid-cols-[208px_1fr_280px] gap-6">
<h1 className="text-2xl font-bold text-foreground flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-violet-100"> {/* Left Sidebar: Rankings */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-violet-600"> <aside className="hidden lg:block">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> <div className="sidebar-bg rounded-xl p-3 sticky top-[68px] max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin space-y-4">
</svg>
{/* External Hot Data Ranking */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-emerald-500 shrink-0">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
<polyline points="17 6 23 6 23 12" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">外部热度排行</span>
</div>
<div className="space-y-0.5">
{rankings ? (
rankings.topByStarsChange.slice(0, 10).map((item, i) => (
<a
key={item.id}
href={`/skills/${item.slug || ""}`}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
>
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-emerald-100 text-emerald-700" : "bg-muted/30 text-muted-foreground"}`}>{i + 1}</span>
<span className="flex-1 truncate text-left">{item.name}</span>
<span className="text-[10px] text-emerald-600 font-medium tabular-nums">
{(item.starsChangeWeek && item.starsChangeWeek > 0 ? "+" : "")}{item.starsChangeWeek || 0}
</span>
</a>
))
) : (
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 animate-pulse">
<div className="h-5 w-5 rounded bg-muted/40" />
<div className="h-3 flex-1 rounded bg-muted/40" />
</div>
))
)}
</div>
</div>
<div className="h-px bg-border/40" />
{/* Internal Review Score Ranking */}
<div>
<div className="flex items-center gap-2 px-2 py-1 mb-2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-500 shrink-0">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
<span className="text-[12px] font-semibold text-foreground/80">评测分值排行</span>
</div>
<div className="space-y-0.5">
{rankings ? (
rankings.topByRating.slice(0, 10).map((item, i) => (
<a
key={item.id}
href={`/skills/${item.skill?.slug || ""}`}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
>
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-amber-100 text-amber-700" : "bg-muted/30 text-muted-foreground"}`}>{i + 1}</span>
<span className="flex-1 truncate text-left">{item.skill?.name}</span>
<span className="text-[10px] font-semibold text-violet-600 tabular-nums">{item.overall}</span>
</a>
))
) : (
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 animate-pulse">
<div className="h-5 w-5 rounded bg-muted/40" />
<div className="h-3 flex-1 rounded bg-muted/40" />
</div>
))
)}
</div>
</div>
</div> </div>
AI评测 </aside>
</h1>
<p className="mt-2 text-sm text-muted-foreground">
基于五维模型的AI技能深度评测,涵盖场景能力、开发体验、成本许可、社区健康、性能稳定
</p>
<div className="mt-3 flex items-center gap-4 text-xs text-muted-foreground">
<span>共 <strong className="text-foreground">{total}</strong> 项评测</span>
<span className="h-3 w-px bg-border/60" />
<span>按综合评分排序</span>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-6"> {/* Middle Content */}
<div className="flex-1 min-w-0"> <main className="min-w-0">
<ReviewsList initialReviews={serializedReviews} initialTotal={total} /> <HeroBanner
</div> title="AI"
highlight="评测"
colorScheme="aurora"
stats={[
{ label: "评测", value: totalReviews, unit: "项" },
{ label: "维度", value: 5, unit: "维" },
]}
slogan="基于五维模型的AI技能深度评测"
subSlogan="场景能力 · 开发体验 · 成本许可 · 社区健康 · 性能稳定"
/>
<ReviewsList initialReviews={[]} initialTotal={0} />
</main>
{/* Right Sidebar: Trending Tools */}
<aside className="hidden lg:block">
<div className="sticky top-[68px] space-y-5">
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30">
<div className="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-violet-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.26 10.147a60.438 60.438 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5" />
</svg>
<h3 className="text-sm font-semibold text-foreground">五维模型</h3>
</div>
</div>
<div className="p-4">
<div className="space-y-3">
{[
{ label: "场景能力", desc: "任务完成度与准确性", color: "bg-emerald-50 text-emerald-700 border-emerald-200" },
{ label: "开发体验", desc: "API/文档易用性", color: "bg-blue-50 text-blue-700 border-blue-200" },
{ label: "成本许可", desc: "价格合理性与开源度", color: "bg-amber-50 text-amber-700 border-amber-200" },
{ label: "社区健康", desc: "活跃度与维护情况", color: "bg-purple-50 text-purple-700 border-purple-200" },
{ label: "性能稳定", desc: "响应速度与可靠性", color: "bg-rose-50 text-rose-700 border-rose-200" },
].map((dim) => (
<div key={dim.label} className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border ${dim.color}`}>
<div className="flex-1">
<p className="text-xs font-semibold">{dim.label}</p>
<p className="text-[10px] opacity-60">{dim.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
<div className="rounded-2xl border border-border/50 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="px-4 py-3 border-b border-border/30">
<div className="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-emerald-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605" />
</svg>
<h3 className="text-sm font-semibold text-foreground">评测说明</h3>
</div>
</div>
<div className="p-4 text-xs text-muted-foreground space-y-2">
<p>每个维度满分5分,综合得分由加权平均得出。</p>
<p className="text-[11px]">4.5+ 卓越 · 4.0+ 优秀 · 3.5+ 良好 · 3.0+ 中等</p>
</div>
</div>
</div>
</aside>
<div className="lg:w-64 flex-shrink-0">
<SidebarRankings />
</div>
</div> </div>
</div> </div>
); );
+1 -2
View File
@@ -251,8 +251,7 @@ export default function SkillsPageClient({
<HeroBanner <HeroBanner
title="追光" title="追光"
highlight="Skills" highlight="Skills"
highlightColor="violet" colorScheme="crimson"
starStyle="aurora"
stats={[ stats={[
{ label: "AI技能", value: totalSkills, unit: "个" }, { label: "AI技能", value: totalSkills, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" }, { label: "分类", value: categories.length, unit: "个" },
+4 -4
View File
@@ -90,14 +90,14 @@ export default async function UserProfilePage({ params }: Props) {
<div className="pt-16 px-6 pb-6"> <div className="pt-16 px-6 pb-6">
<div className="flex items-start justify-between flex-wrap gap-4"> <div className="flex items-start justify-between flex-wrap gap-4">
<div> <div>
<h1 className="text-xl font-bold">{user.name || user.email.split("@")[0]}</h1> <h1 className="text-xl font-bold">{user.name || user.email?.split("@")[0]}</h1>
<p className="text-sm text-muted-foreground/60 mt-1">{user.email}</p> <p className="text-sm text-muted-foreground/60 mt-1">{user.email}</p>
{user.bio && <p className="text-sm mt-2 text-muted-foreground/70">{user.bio}</p>} {user.bio && <p className="text-sm mt-2 text-muted-foreground/70">{user.bio}</p>}
<div className="mt-2 flex items-center gap-2 flex-wrap"> <div className="mt-2 flex items-center gap-2 flex-wrap">
<MemberBadge level={user.level} points={user.points} size="md" /> <MemberBadge level={user.level} points={user.points} size="md" />
<span className="text-xs text-muted-foreground/50 flex items-center gap-1"> <span className="text-xs text-muted-foreground/50 flex items-center gap-1">
<Calendar className="w-3 h-3" /> <Calendar className="w-3 h-3" />
注册于 {user.createdAt.toLocaleDateString("zh-CN")} 注册于 {user.createdAt?.toLocaleDateString("zh-CN")}
</span> </span>
</div> </div>
</div> </div>
@@ -139,7 +139,7 @@ export default async function UserProfilePage({ params }: Props) {
<h3 className="text-sm font-medium">{topic.title}</h3> <h3 className="text-sm font-medium">{topic.title}</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50"> <div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>{topic.category.name}</span> <span>{topic.category.name}</span>
<span>{topic.createdAt.toLocaleDateString("zh-CN")}</span> <span>{topic.createdAt?.toLocaleDateString("zh-CN")}</span>
</div> </div>
</div> </div>
</Link> </Link>
@@ -163,7 +163,7 @@ export default async function UserProfilePage({ params }: Props) {
{target && ( {target && (
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50"> <div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>于 {comment.tool ? "工具" : "技能"}: {target.name}</span> <span>于 {comment.tool ? "工具" : "技能"}: {target.name}</span>
<span>{comment.createdAt.toLocaleDateString("zh-CN")}</span> <span>{comment.createdAt?.toLocaleDateString("zh-CN")}</span>
</div> </div>
)} )}
</div> </div>
+210 -359
View File
@@ -2,16 +2,15 @@
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
type StarStyle = "cosmos" | "aurora" | "dawn"; type ColorScheme = "cosmos" | "aurora" | "dawn" | "forest" | "crimson";
interface HeroBannerProps { interface HeroBannerProps {
title: string; title: string;
highlight: string; highlight: string;
highlightColor?: "blue" | "violet" | "amber"; colorScheme?: ColorScheme;
stats: { label: string; value: number | string; unit: string }[]; stats: { label: string; value: number | string; unit: string }[];
slogan: string; slogan: string;
subSlogan?: string; subSlogan?: string;
starStyle?: StarStyle;
} }
interface Star { interface Star {
@@ -38,14 +37,112 @@ interface ShootingStar {
size: number; size: number;
} }
interface SchemeConfig {
bgGradient: string;
highlight: string;
star: { r: number; g: number; b: number };
shoot: { r: number; g: number; b: number };
topBorder: string;
bottomBorder: string;
statBg: string;
statColor: string;
statBorder: string;
overlays: { position: string; color: string }[];
}
const SCHEMES: Record<ColorScheme, SchemeConfig> = {
cosmos: {
bgGradient: "linear-gradient(135deg, #1e3a5f 0%, #1e40af 35%, #2563eb 65%, #1d4ed8 100%)",
highlight: "#93c5fd",
star: { r: 191, g: 219, b: 254 },
shoot: { r: 200, g: 220, b: 255 },
topBorder: "linear-gradient(90deg, transparent, rgba(96,165,250,0.5), rgba(59,130,246,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(96,165,250,0.4), rgba(59,130,246,0.3), transparent)",
statBg: "rgba(255,255,255,0.15)",
statColor: "#fff",
statBorder: "1px solid rgba(255,255,255,0.25)",
overlays: [
{ position: "30% 50%", color: "rgba(59,130,246,0.15)" },
{ position: "80% 30%", color: "rgba(96,165,250,0.10)" },
],
},
aurora: {
bgGradient: "linear-gradient(135deg, #4c1d95 0%, #5b21b6 30%, #7c3aed 60%, #6d28d9 100%)",
highlight: "#c4b5fd",
star: { r: 210, g: 196, b: 255 },
shoot: { r: 220, g: 210, b: 255 },
topBorder: "linear-gradient(90deg, transparent, rgba(167,139,250,0.5), rgba(124,58,237,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(167,139,250,0.4), rgba(124,58,237,0.3), transparent)",
statBg: "rgba(167,139,250,0.15)",
statColor: "#ddd6fe",
statBorder: "1px solid rgba(167,139,250,0.25)",
overlays: [
{ position: "40% 40%", color: "rgba(124,58,237,0.22)" },
{ position: "75% 60%", color: "rgba(139,92,246,0.16)" },
],
},
dawn: {
bgGradient: "linear-gradient(135deg, #78350f 0%, #92400e 25%, #b45309 55%, #a16207 100%)",
highlight: "#fcd34d",
star: { r: 253, g: 230, b: 138 },
shoot: { r: 251, g: 191, b: 36 },
topBorder: "linear-gradient(90deg, transparent, rgba(251,191,36,0.5), rgba(245,158,11,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(251,191,36,0.4), rgba(245,158,11,0.3), transparent)",
statBg: "rgba(251,191,36,0.15)",
statColor: "#fef3c7",
statBorder: "1px solid rgba(251,191,36,0.25)",
overlays: [
{ position: "50% 80%", color: "rgba(245,158,11,0.20)" },
{ position: "20% 30%", color: "rgba(251,191,36,0.12)" },
{ position: "80% 60%", color: "rgba(217,119,6,0.10)" },
],
},
forest: {
bgGradient: "linear-gradient(135deg, #064e3b 0%, #065f46 25%, #059669 55%, #047857 100%)",
highlight: "#6ee7b7",
star: { r: 110, g: 231, b: 183 },
shoot: { r: 130, g: 240, b: 200 },
topBorder: "linear-gradient(90deg, transparent, rgba(52,211,153,0.5), rgba(16,185,129,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(52,211,153,0.4), rgba(16,185,129,0.3), transparent)",
statBg: "rgba(52,211,153,0.15)",
statColor: "#d1fae5",
statBorder: "1px solid rgba(52,211,153,0.25)",
overlays: [
{ position: "30% 50%", color: "rgba(16,185,129,0.15)" },
{ position: "70% 30%", color: "rgba(52,211,153,0.10)" },
],
},
crimson: {
bgGradient: "linear-gradient(135deg, #881337 0%, #9f1239 25%, #be123c 55%, #e11d48 100%)",
highlight: "#fda4af",
star: { r: 253, g: 164, b: 175 },
shoot: { r: 255, g: 180, b: 190 },
topBorder: "linear-gradient(90deg, transparent, rgba(251,113,133,0.5), rgba(244,63,94,0.4), transparent)",
bottomBorder: "linear-gradient(90deg, transparent, rgba(251,113,133,0.4), rgba(244,63,94,0.3), transparent)",
statBg: "rgba(251,113,133,0.15)",
statColor: "#ffe4e6",
statBorder: "1px solid rgba(251,113,133,0.25)",
overlays: [
{ position: "40% 40%", color: "rgba(244,63,94,0.18)" },
{ position: "75% 60%", color: "rgba(251,113,133,0.12)" },
],
},
};
const SCHEME_KEYS: ColorScheme[] = ["cosmos", "aurora", "dawn", "forest", "crimson"];
const STAR_COUNTS: Record<ColorScheme, number> = {
cosmos: 120, aurora: 80, dawn: 100, forest: 90, crimson: 85,
};
const SHOOTING_INTERVAL = 5000;
export function HeroBanner({ export function HeroBanner({
title, title,
highlight, highlight,
highlightColor = "blue", colorScheme = "cosmos",
stats, stats,
slogan, slogan,
subSlogan, subSlogan,
starStyle = "cosmos",
}: HeroBannerProps) { }: HeroBannerProps) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -56,22 +153,16 @@ export function HeroBanner({
const lastShootingTimeRef = useRef(0); const lastShootingTimeRef = useRef(0);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const isCosmos = starStyle === "cosmos"; const scheme = SCHEMES[colorScheme];
const isAurora = starStyle === "aurora"; const starCount = STAR_COUNTS[colorScheme];
const isDawn = starStyle === "dawn";
const STAR_COUNT = isCosmos ? 120 : isDawn ? 100 : 80;
const SHOOTING_INTERVAL = 5000;
const createStars = useCallback((width: number, height: number) => { const createStars = useCallback((width: number, height: number) => {
const arr: Star[] = []; const arr: Star[] = [];
for (let i = 0; i < STAR_COUNT; i++) { for (let i = 0; i < starCount; i++) {
const bx = Math.random() * width; const bx = Math.random() * width;
const by = Math.random() * height; const by = Math.random() * height;
arr.push({ arr.push({
x: bx, x: bx, y: by, baseX: bx, baseY: by,
y: by,
baseX: bx,
baseY: by,
size: Math.random() * 1.8 + 0.5, size: Math.random() * 1.8 + 0.5,
alpha: Math.random() * 0.5 + 0.5, alpha: Math.random() * 0.5 + 0.5,
alphaSpeed: Math.random() * 0.08 + 0.05, alphaSpeed: Math.random() * 0.08 + 0.05,
@@ -82,39 +173,29 @@ export function HeroBanner({
}); });
} }
starsRef.current = arr; starsRef.current = arr;
}, [STAR_COUNT]); }, [starCount]);
const createShootingStar = useCallback((width: number, height: number) => { const createShootingStar = useCallback((width: number, height: number) => {
const startX = Math.random() * width * 0.7 + width * 0.05; const startX = Math.random() * width * 0.7 + width * 0.05;
const startY = Math.random() * height * 0.25; const startY = Math.random() * height * 0.25;
const speed = 3 + Math.random() * 3; const speed = 3 + Math.random() * 3;
const angle = Math.PI / 4 + (Math.random() - 0.5) * Math.PI / 8; const angle = Math.PI / 4 + (Math.random() - 0.5) * Math.PI / 8;
shootingStarsRef.current.push({ shootingStarsRef.current.push({
x: startX, x: startX, y: startY,
y: startY, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
vx: Math.cos(angle) * speed, life: 1, length: 50 + Math.random() * 40, size: 1.2 + Math.random() * 0.8,
vy: Math.sin(angle) * speed,
life: 1,
length: 50 + Math.random() * 40,
size: 1.2 + Math.random() * 0.8,
}); });
}, []); }, []);
useEffect(() => { useEffect(() => { setMounted(true); }, []);
setMounted(true);
}, []);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const container = containerRef.current; const container = containerRef.current;
if (!canvas || !container) return; if (!canvas || !container) return;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
if (mq.matches) return;
const resize = () => { const resize = () => {
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
@@ -126,44 +207,27 @@ export function HeroBanner({
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
createStars(rect.width, rect.height); createStars(rect.width, rect.height);
}; };
resize(); resize();
const starColor = isCosmos const sc = scheme.star;
? { r: 191, g: 219, b: 254 } const sh = scheme.shoot;
: isDawn
? { r: 253, g: 230, b: 138 }
: { r: 210, g: 196, b: 255 };
const shootColor = isCosmos
? { r: 200, g: 220, b: 255 }
: isDawn
? { r: 251, g: 191, b: 36 }
: { r: 220, g: 210, b: 255 };
const animate = () => { const animate = () => {
timeRef.current += 0.016; timeRef.current += 0.016;
const t = timeRef.current; const t = timeRef.current;
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const w = rect.width; const w = rect.width;
const h = rect.height; const h = rect.height;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
for (const star of starsRef.current) { for (const star of starsRef.current) {
const fastTwinkle = 0.5 + Math.sin(t * star.alphaSpeed * 12 + star.twinkleOffset) * 0.5; const fastTwinkle = 0.5 + Math.sin(t * star.alphaSpeed * 12 + star.twinkleOffset) * 0.5;
const secondaryTwinkle = 0.7 + Math.sin(t * star.alphaSpeed * 7 + star.twinkleOffset * 1.5) * 0.3; const secondaryTwinkle = 0.7 + Math.sin(t * star.alphaSpeed * 7 + star.twinkleOffset * 1.5) * 0.3;
const easedAlpha = fastTwinkle * fastTwinkle * (3 - 2 * fastTwinkle) * secondaryTwinkle; const easedAlpha = fastTwinkle * fastTwinkle * (3 - 2 * fastTwinkle) * secondaryTwinkle;
const driftX = Math.sin(t * star.driftSpeed + star.driftOffset) * 8 + Math.cos(t * star.driftSpeed * 1.3 + star.driftOffset2) * 4;
const driftX = Math.sin(t * star.driftSpeed + star.driftOffset) * 8 const driftY = Math.cos(t * star.driftSpeed * 0.8 + star.driftOffset * 1.2) * 6 + Math.sin(t * star.driftSpeed * 0.6 + star.driftOffset2 * 0.9) * 3;
+ Math.cos(t * star.driftSpeed * 1.3 + star.driftOffset2) * 4;
const driftY = Math.cos(t * star.driftSpeed * 0.8 + star.driftOffset * 1.2) * 6
+ Math.sin(t * star.driftSpeed * 0.6 + star.driftOffset2 * 0.9) * 3;
star.x = star.baseX + driftX; star.x = star.baseX + driftX;
star.y = star.baseY + driftY; star.y = star.baseY + driftY;
const currentAlpha = star.alpha * easedAlpha; const currentAlpha = star.alpha * easedAlpha;
ctx.beginPath(); ctx.beginPath();
@@ -175,9 +239,8 @@ export function HeroBanner({
const glowSize = star.size * 2.5; const glowSize = star.size * 2.5;
const gradient = ctx.createRadialGradient(star.x, star.y, 0, star.x, star.y, glowSize); const gradient = ctx.createRadialGradient(star.x, star.y, 0, star.x, star.y, glowSize);
gradient.addColorStop(0, `rgba(255, 255, 255, ${currentAlpha * 0.5})`); gradient.addColorStop(0, `rgba(255, 255, 255, ${currentAlpha * 0.5})`);
gradient.addColorStop(0.4, `rgba(${starColor.r}, ${starColor.g}, ${starColor.b}, ${currentAlpha * 0.2})`); gradient.addColorStop(0.4, `rgba(${sc.r}, ${sc.g}, ${sc.b}, ${currentAlpha * 0.2})`);
gradient.addColorStop(1, `rgba(${starColor.r}, ${starColor.g}, ${starColor.b}, 0)`); gradient.addColorStop(1, `rgba(${sc.r}, ${sc.g}, ${sc.b}, 0)`);
ctx.beginPath(); ctx.beginPath();
ctx.arc(star.x, star.y, glowSize, 0, Math.PI * 2); ctx.arc(star.x, star.y, glowSize, 0, Math.PI * 2);
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
@@ -196,347 +259,145 @@ export function HeroBanner({
ss.x += ss.vx; ss.x += ss.vx;
ss.y += ss.vy; ss.y += ss.vy;
ss.life -= 0.008; ss.life -= 0.008;
const tailX = ss.x - ss.vx * ss.length * 0.12; const tailX = ss.x - ss.vx * ss.length * 0.12;
const tailY = ss.y - ss.vy * ss.length * 0.12; const tailY = ss.y - ss.vy * ss.length * 0.12;
const grad = ctx.createLinearGradient(tailX, tailY, ss.x, ss.y);
const gradient = ctx.createLinearGradient(tailX, tailY, ss.x, ss.y); grad.addColorStop(0, "rgba(255, 255, 255, 0)");
gradient.addColorStop(0, "rgba(255, 255, 255, 0)"); grad.addColorStop(0.3, `rgba(${sh.r}, ${sh.g}, ${sh.b}, ${ss.life * 0.3})`);
gradient.addColorStop(0.3, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, ${ss.life * 0.3})`); grad.addColorStop(0.7, `rgba(${sh.r}, ${sh.g}, ${sh.b}, ${ss.life * 0.7})`);
gradient.addColorStop(0.7, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, ${ss.life * 0.7})`); grad.addColorStop(1, `rgba(255, 255, 255, ${ss.life})`);
gradient.addColorStop(1, `rgba(255, 255, 255, ${ss.life})`);
ctx.save(); ctx.save();
ctx.globalCompositeOperation = "lighter"; ctx.globalCompositeOperation = "lighter";
ctx.beginPath(); ctx.moveTo(tailX, tailY); ctx.lineTo(ss.x, ss.y);
ctx.beginPath(); ctx.strokeStyle = grad; ctx.lineWidth = ss.size * 1.5; ctx.lineCap = "round"; ctx.stroke();
ctx.moveTo(tailX, tailY); ctx.beginPath(); ctx.moveTo(tailX, tailY); ctx.lineTo(ss.x, ss.y);
ctx.lineTo(ss.x, ss.y);
ctx.strokeStyle = gradient;
ctx.lineWidth = ss.size * 1.5;
ctx.lineCap = "round";
ctx.stroke();
ctx.beginPath();
ctx.moveTo(tailX, tailY);
ctx.lineTo(ss.x, ss.y);
ctx.strokeStyle = `rgba(255, 255, 255, ${ss.life * 0.6})`; ctx.strokeStyle = `rgba(255, 255, 255, ${ss.life * 0.6})`;
ctx.lineWidth = ss.size * 0.5; ctx.lineWidth = ss.size * 0.5; ctx.stroke();
ctx.stroke(); const hg = ctx.createRadialGradient(ss.x, ss.y, 0, ss.x, ss.y, ss.size * 3);
hg.addColorStop(0, `rgba(255, 255, 255, ${ss.life})`);
const headGlow = ctx.createRadialGradient(ss.x, ss.y, 0, ss.x, ss.y, ss.size * 3); hg.addColorStop(0.4, `rgba(${sh.r}, ${sh.g}, ${sh.b}, ${ss.life * 0.5})`);
headGlow.addColorStop(0, `rgba(255, 255, 255, ${ss.life})`); hg.addColorStop(1, `rgba(${sh.r}, ${sh.g}, ${sh.b}, 0)`);
headGlow.addColorStop(0.4, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, ${ss.life * 0.5})`); ctx.beginPath(); ctx.arc(ss.x, ss.y, ss.size * 3, 0, Math.PI * 2);
headGlow.addColorStop(1, `rgba(${shootColor.r}, ${shootColor.g}, ${shootColor.b}, 0)`); ctx.fillStyle = hg; ctx.fill();
ctx.beginPath();
ctx.arc(ss.x, ss.y, ss.size * 3, 0, Math.PI * 2);
ctx.fillStyle = headGlow;
ctx.fill();
ctx.restore(); ctx.restore();
} }
animationIdRef.current = requestAnimationFrame(animate); animationIdRef.current = requestAnimationFrame(animate);
}; };
lastShootingTimeRef.current = Date.now(); lastShootingTimeRef.current = Date.now();
animate(); animate();
const handleResize = () => resize(); const handleResize = () => resize();
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
return () => { return () => {
if (animationIdRef.current !== null) { if (animationIdRef.current !== null) cancelAnimationFrame(animationIdRef.current);
cancelAnimationFrame(animationIdRef.current);
}
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
}; };
}, [isCosmos, isDawn, createStars, createShootingStar]); }, [colorScheme, scheme, createStars, createShootingStar]);
const highlightStyle: React.CSSProperties = isCosmos
? { color: "#93c5fd" }
: isDawn
? { color: "#fcd34d" }
: { color: "#c4b5fd" };
const bgGradient = isCosmos
? "linear-gradient(135deg, #1e3a5f 0%, #1e40af 35%, #2563eb 65%, #1d4ed8 100%)"
: isDawn
? "linear-gradient(135deg, #78350f 0%, #92400e 25%, #b45309 55%, #a16207 100%)"
: "linear-gradient(135deg, #4c1d95 0%, #5b21b6 30%, #7c3aed 60%, #6d28d9 100%)";
return ( return (
<div ref={containerRef} className="relative w-full overflow-hidden rounded-2xl mb-6 star-banner"> <div ref={containerRef} className="relative w-full overflow-hidden rounded-2xl mb-6 star-banner">
<div className="absolute inset-0" style={{ background: bgGradient }} /> <div className="absolute inset-0" style={{ background: scheme.bgGradient }} />
{isCosmos ? ( {scheme.overlays.map((ov, i) => (
<> <div key={i} className="absolute inset-0 pointer-events-none"
<div style={{ background: `radial-gradient(ellipse at ${ov.position}, ${ov.color} 0%, transparent 55%)` }}
className="absolute inset-0 pointer-events-none" />
style={{ ))}
background: "radial-gradient(ellipse at 30% 50%, rgba(59,130,246,0.15) 0%, transparent 60%)",
}} {/* Background SVG pattern */}
/> {colorScheme === "cosmos" && (
<div <svg className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.08]" viewBox="0 0 1000 300" preserveAspectRatio="none">
className="absolute inset-0 pointer-events-none" <line x1="120" y1="60" x2="280" y2="90" stroke="#bfdbfe" strokeWidth="0.5" />
style={{ <line x1="280" y1="90" x2="350" y2="50" stroke="#bfdbfe" strokeWidth="0.5" />
background: "radial-gradient(ellipse at 80% 30%, rgba(96,165,250,0.10) 0%, transparent 50%)", <line x1="350" y1="50" x2="500" y2="80" stroke="#bfdbfe" strokeWidth="0.5" />
}} <line x1="600" y1="40" x2="720" y2="70" stroke="#bfdbfe" strokeWidth="0.5" />
/> <line x1="720" y1="70" x2="850" y2="55" stroke="#bfdbfe" strokeWidth="0.5" />
<svg <line x1="850" y1="55" x2="920" y2="90" stroke="#bfdbfe" strokeWidth="0.5" />
className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.08]" <circle cx="120" cy="60" r="2" fill="#bfdbfe" /><circle cx="280" cy="90" r="1.5" fill="#bfdbfe" />
viewBox="0 0 1000 300" <circle cx="350" cy="50" r="2" fill="#bfdbfe" /><circle cx="500" cy="80" r="1.5" fill="#bfdbfe" />
preserveAspectRatio="none" <circle cx="600" cy="40" r="2" fill="#bfdbfe" /><circle cx="720" cy="70" r="1.5" fill="#bfdbfe" />
> <circle cx="850" cy="55" r="2" fill="#bfdbfe" /><circle cx="920" cy="90" r="1.5" fill="#bfdbfe" />
<line x1="120" y1="60" x2="280" y2="90" stroke="#bfdbfe" strokeWidth="0.5" /> </svg>
<line x1="280" y1="90" x2="350" y2="50" stroke="#bfdbfe" strokeWidth="0.5" /> )}
<line x1="350" y1="50" x2="500" y2="80" stroke="#bfdbfe" strokeWidth="0.5" /> {colorScheme === "dawn" && (
<line x1="600" y1="40" x2="720" y2="70" stroke="#bfdbfe" strokeWidth="0.5" /> <svg className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.06]" viewBox="0 0 1000 300" preserveAspectRatio="none">
<line x1="720" y1="70" x2="850" y2="55" stroke="#bfdbfe" strokeWidth="0.5" /> <ellipse cx="500" cy="280" rx="400" ry="60" stroke="#fcd34d" strokeWidth="0.5" fill="none" opacity="0.6" />
<line x1="850" y1="55" x2="920" y2="90" stroke="#bfdbfe" strokeWidth="0.5" /> <ellipse cx="500" cy="280" rx="300" ry="40" stroke="#fbbf24" strokeWidth="0.5" fill="none" opacity="0.4" />
<circle cx="120" cy="60" r="2" fill="#bfdbfe" /> <line x1="500" y1="40" x2="500" y2="220" stroke="#fcd34d" strokeWidth="0.3" opacity="0.3" />
<circle cx="280" cy="90" r="1.5" fill="#bfdbfe" /> <line x1="460" y1="60" x2="500" y2="220" stroke="#fbbf24" strokeWidth="0.3" opacity="0.2" />
<circle cx="350" cy="50" r="2" fill="#bfdbfe" /> <line x1="540" y1="60" x2="500" y2="220" stroke="#fbbf24" strokeWidth="0.3" opacity="0.2" />
<circle cx="500" cy="80" r="1.5" fill="#bfdbfe" /> <circle cx="500" cy="40" r="3" fill="#fcd34d" opacity="0.5" />
<circle cx="600" cy="40" r="2" fill="#bfdbfe" /> </svg>
<circle cx="720" cy="70" r="1.5" fill="#bfdbfe" /> )}
<circle cx="850" cy="55" r="2" fill="#bfdbfe" /> {(colorScheme === "aurora" || colorScheme === "forest" || colorScheme === "crimson") && (
<circle cx="920" cy="90" r="1.5" fill="#bfdbfe" /> <svg className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.08]" viewBox="0 0 1000 300" preserveAspectRatio="none">
</svg> <ellipse cx="500" cy="150" rx="200" ry="80" stroke={scheme.highlight} strokeWidth="0.5" fill="none" opacity="0.5" />
</> <ellipse cx="500" cy="150" rx="140" ry="50" stroke={scheme.highlight} strokeWidth="0.5" fill="none" opacity="0.4" />
) : isDawn ? ( </svg>
<>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 50% 80%, rgba(245,158,11,0.20) 0%, transparent 55%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 20% 30%, rgba(251,191,36,0.12) 0%, transparent 50%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 80% 60%, rgba(217,119,6,0.10) 0%, transparent 45%)",
}}
/>
<svg
className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.06]"
viewBox="0 0 1000 300"
preserveAspectRatio="none"
>
<ellipse cx="500" cy="280" rx="400" ry="60" stroke="#fcd34d" strokeWidth="0.5" fill="none" opacity="0.6" />
<ellipse cx="500" cy="280" rx="300" ry="40" stroke="#fbbf24" strokeWidth="0.5" fill="none" opacity="0.4" />
<line x1="500" y1="40" x2="500" y2="220" stroke="#fcd34d" strokeWidth="0.3" opacity="0.3" />
<line x1="460" y1="60" x2="500" y2="220" stroke="#fbbf24" strokeWidth="0.3" opacity="0.2" />
<line x1="540" y1="60" x2="500" y2="220" stroke="#fbbf24" strokeWidth="0.3" opacity="0.2" />
<circle cx="500" cy="40" r="3" fill="#fcd34d" opacity="0.5" />
</svg>
</>
) : (
<>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 40% 40%, rgba(124,58,237,0.22) 0%, transparent 55%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
background: "radial-gradient(ellipse at 75% 60%, rgba(139,92,246,0.16) 0%, transparent 50%)",
}}
/>
<svg
className="absolute inset-0 w-full h-full pointer-events-none opacity-[0.08]"
viewBox="0 0 1000 300"
preserveAspectRatio="none"
>
<ellipse cx="500" cy="150" rx="200" ry="80" stroke="#c4b5fd" strokeWidth="0.5" fill="none" opacity="0.5" />
<ellipse cx="500" cy="150" rx="140" ry="50" stroke="#a78bfa" strokeWidth="0.5" fill="none" opacity="0.4" />
</svg>
</>
)} )}
<canvas <canvas ref={canvasRef} className="absolute inset-0 w-full h-full pointer-events-none" style={{ zIndex: 2 }} />
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ zIndex: 2 }}
/>
<div <div className="absolute top-0 left-0 right-0 h-px" style={{ background: scheme.topBorder, zIndex: 3 }} />
className="absolute top-0 left-0 right-0 h-px"
style={{
background: isCosmos
? "linear-gradient(90deg, transparent, rgba(96,165,250,0.5), rgba(59,130,246,0.4), transparent)"
: isDawn
? "linear-gradient(90deg, transparent, rgba(251,191,36,0.5), rgba(245,158,11,0.4), transparent)"
: "linear-gradient(90deg, transparent, rgba(167,139,250,0.5), rgba(124,58,237,0.4), transparent)",
zIndex: 3,
}}
/>
<div className="relative px-6 py-8 sm:px-8 sm:py-10 lg:px-10 lg:py-12" style={{ zIndex: 4 }}> <div className="relative px-6 py-8 sm:px-8 sm:py-10 lg:px-10 lg:py-12" style={{ zIndex: 4 }}>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6"> <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div <div className={`transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}>
className={`transition-all duration-700 ${
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
}`}
>
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-white" style={{ textShadow: "0 2px 16px rgba(0,0,0,0.3)" }}> <h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-white" style={{ textShadow: "0 2px 16px rgba(0,0,0,0.3)" }}>
{title} {title}
<span style={highlightStyle}>{highlight}</span> <span style={{ color: scheme.highlight }}>{highlight}</span>
</h1> </h1>
</div> </div>
<div className={`mt-3 transition-all duration-700 delay-150 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}>
<div <p className="text-base sm:text-lg font-medium text-white/85 leading-relaxed">{slogan}</p>
className={`mt-3 transition-all duration-700 delay-150 ${ {subSlogan && <p className="mt-1.5 text-sm text-white/55 leading-relaxed">{subSlogan}</p>}
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
}`}
>
<p className="text-base sm:text-lg font-medium text-white/85 leading-relaxed">
{slogan}
</p>
{subSlogan && (
<p className="mt-1.5 text-sm text-white/55 leading-relaxed">
{subSlogan}
</p>
)}
</div> </div>
<div className={`mt-5 flex flex-wrap items-center gap-4 sm:gap-6 transition-all duration-700 delay-300 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}>
<div
className={`mt-5 flex flex-wrap items-center gap-4 sm:gap-6 transition-all duration-700 delay-300 ${
mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
}`}
>
{stats.map((stat, i) => ( {stats.map((stat, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<span <span className="inline-flex items-center justify-center h-9 min-w-[2.25rem] px-2 rounded-lg text-sm font-bold tabular-nums"
className="inline-flex items-center justify-center h-9 min-w-[2.25rem] px-2 rounded-lg text-sm font-bold tabular-nums" style={{ background: scheme.statBg, color: scheme.statColor, border: scheme.statBorder, backdropFilter: "blur(4px)" }}>
style={{
background: isCosmos
? "rgba(255,255,255,0.15)"
: isDawn
? "rgba(251,191,36,0.15)"
: "rgba(167,139,250,0.15)",
color: isCosmos ? "#fff" : isDawn ? "#fef3c7" : "#ddd6fe",
border: isCosmos
? "1px solid rgba(255,255,255,0.25)"
: isDawn
? "1px solid rgba(251,191,36,0.25)"
: "1px solid rgba(167,139,250,0.25)",
backdropFilter: "blur(4px)",
}}
>
{typeof stat.value === "number" ? stat.value.toLocaleString() : stat.value} {typeof stat.value === "number" ? stat.value.toLocaleString() : stat.value}
</span> </span>
<span className="text-sm text-white/70"> <span className="text-sm text-white/70">{stat.unit}<span className="text-white/50 ml-0.5">{stat.label}</span></span>
{stat.unit}
<span className="text-white/50 ml-0.5">{stat.label}</span>
</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
<div <div className={`hidden lg:flex items-center justify-center transition-all duration-700 delay-200 ${mounted ? "opacity-100 scale-100" : "opacity-0 scale-90"}`}>
className={`hidden lg:flex items-center justify-center transition-all duration-700 delay-200 ${
mounted ? "opacity-100 scale-100" : "opacity-0 scale-90"
}`}
>
<div className="relative w-36 h-36"> <div className="relative w-36 h-36">
{isCosmos ? ( {colorScheme === "cosmos" && (
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none"> <svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
<circle cx="72" cy="72" r="60" stroke="rgba(147,197,253,0.25)" strokeWidth="0.5" fill="none"> <circle cx="72" cy="72" r="60" stroke="rgba(147,197,253,0.25)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" /></circle>
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" /> <circle cx="72" cy="72" r="45" stroke="rgba(191,219,254,0.20)" strokeWidth="0.5" fill="none" strokeDasharray="4 6"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" /></circle>
</circle> <circle cx="72" cy="72" r="30" stroke="rgba(147,197,253,0.15)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="20s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="45" stroke="rgba(191,219,254,0.20)" strokeWidth="0.5" fill="none" strokeDasharray="4 6"> <circle cx="72" cy="72" r="8" fill="rgba(147,197,253,0.25)" /><circle cx="72" cy="72" r="3" fill="rgba(191,219,254,0.8)" />
<animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" /> <circle cx="72" cy="12" r="2" fill="rgba(191,219,254,0.6)"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" /></circle>
</circle> <circle cx="72" cy="27" r="1.5" fill="rgba(147,197,253,0.5)"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" /></circle>
<circle cx="72" cy="72" r="30" stroke="rgba(147,197,253,0.15)" strokeWidth="0.5" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="20s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="8" fill="rgba(147,197,253,0.25)" />
<circle cx="72" cy="72" r="3" fill="rgba(191,219,254,0.8)" />
<circle cx="72" cy="12" r="2" fill="rgba(191,219,254,0.6)">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="30s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="27" r="1.5" fill="rgba(147,197,253,0.5)">
<animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="25s" repeatCount="indefinite" />
</circle>
</svg> </svg>
) : isDawn ? ( )}
{colorScheme === "dawn" && (
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none"> <svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
<defs> <defs><linearGradient id="dawn-sun" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor="#fbbf24" stopOpacity="0.4" /><stop offset="50%" stopColor="#f59e0b" stopOpacity="0.3" /><stop offset="100%" stopColor="#d97706" stopOpacity="0.2" /></linearGradient></defs>
<linearGradient id="dawn-sun-grad" x1="0%" y1="0%" x2="100%" y2="100%"> <circle cx="72" cy="72" r="60" stroke="rgba(251,191,36,0.20)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="35s" repeatCount="indefinite" /></circle>
<stop offset="0%" stopColor="#fbbf24" stopOpacity="0.4" /> <circle cx="72" cy="72" r="45" stroke="rgba(245,158,11,0.15)" strokeWidth="0.5" fill="none" strokeDasharray="6 4"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="28s" repeatCount="indefinite" /></circle>
<stop offset="50%" stopColor="#f59e0b" stopOpacity="0.3" /> <circle cx="72" cy="72" r="30" stroke="rgba(251,191,36,0.12)" strokeWidth="0.5" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="22s" repeatCount="indefinite" /></circle>
<stop offset="100%" stopColor="#d97706" stopOpacity="0.2" /> <circle cx="72" cy="72" r="18" fill="url(#dawn-sun)" /><circle cx="72" cy="72" r="10" fill="rgba(251,191,36,0.25)" /><circle cx="72" cy="72" r="4" fill="rgba(253,230,138,0.7)" />
</linearGradient> {[0,45,90,135,180,225,270,315].map(deg => (
</defs> <line key={deg} x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform={`rotate(${deg} 72 72)`}><animateTransform attributeName="transform" type="rotate" from={`${deg} 72 72`} to={`${deg+360} 72 72`} dur="35s" repeatCount="indefinite" /></line>
<circle cx="72" cy="72" r="60" stroke="rgba(251,191,36,0.20)" strokeWidth="0.5" fill="none"> ))}
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="35s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="45" stroke="rgba(245,158,11,0.15)" strokeWidth="0.5" fill="none" strokeDasharray="6 4">
<animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="28s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="30" stroke="rgba(251,191,36,0.12)" strokeWidth="0.5" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="22s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="18" fill="url(#dawn-sun-grad)" />
<circle cx="72" cy="72" r="10" fill="rgba(251,191,36,0.25)" />
<circle cx="72" cy="72" r="4" fill="rgba(253,230,138,0.7)" />
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(45 72 72)">
<animateTransform attributeName="transform" type="rotate" from="45 72 72" to="405 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(90 72 72)">
<animateTransform attributeName="transform" type="rotate" from="90 72 72" to="450 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(135 72 72)">
<animateTransform attributeName="transform" type="rotate" from="135 72 72" to="495 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(180 72 72)">
<animateTransform attributeName="transform" type="rotate" from="180 72 72" to="540 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(225 72 72)">
<animateTransform attributeName="transform" type="rotate" from="225 72 72" to="585 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(270 72 72)">
<animateTransform attributeName="transform" type="rotate" from="270 72 72" to="630 72 72" dur="35s" repeatCount="indefinite" />
</line>
<line x1="72" y1="8" x2="72" y2="22" stroke="rgba(251,191,36,0.3)" strokeWidth="1" strokeLinecap="round" transform="rotate(315 72 72)">
<animateTransform attributeName="transform" type="rotate" from="315 72 72" to="675 72 72" dur="35s" repeatCount="indefinite" />
</line>
</svg> </svg>
) : ( )}
{(colorScheme === "aurora" || colorScheme === "forest" || colorScheme === "crimson") && (
<svg className="w-full h-full" viewBox="0 0 144 144" fill="none"> <svg className="w-full h-full" viewBox="0 0 144 144" fill="none">
<defs> <defs><linearGradient id="orb-grad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor={scheme.highlight} stopOpacity="0.3" /><stop offset="50%" stopColor={scheme.highlight} stopOpacity="0.15" /><stop offset="100%" stopColor={scheme.highlight} stopOpacity="0.25" /></linearGradient></defs>
<linearGradient id="aurora-grad" x1="0%" y1="0%" x2="100%" y2="100%"> <circle cx="72" cy="72" r="55" stroke="url(#orb-grad)" strokeWidth="1" fill="none"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="40s" repeatCount="indefinite" /></circle>
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.3" /> <circle cx="72" cy="72" r="38" stroke={scheme.highlight} strokeWidth="0.8" strokeOpacity="0.15" fill="none" strokeDasharray="8 4"><animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="30s" repeatCount="indefinite" /></circle>
<stop offset="50%" stopColor="#06b6d4" stopOpacity="0.2" /> <circle cx="72" cy="72" r="22" fill={scheme.highlight} fillOpacity="0.08" /><circle cx="72" cy="72" r="10" fill={scheme.highlight} fillOpacity="0.12" />
<stop offset="100%" stopColor="#8b5cf6" stopOpacity="0.3" /> <circle cx="72" cy="72" r="3" fill={scheme.highlight} fillOpacity="0.5" />
</linearGradient> <path d="M72 17L79 52H112L86 72L96 105L72 87L48 105L58 72L32 52H65Z" stroke={scheme.highlight} strokeWidth="0.8" strokeOpacity="0.18" fill={scheme.highlight} fillOpacity="0.04"><animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="60s" repeatCount="indefinite" /></path>
</defs>
<circle cx="72" cy="72" r="55" stroke="url(#aurora-grad)" strokeWidth="1" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="40s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="38" stroke="rgba(139,92,246,0.15)" strokeWidth="0.8" fill="none" strokeDasharray="8 4">
<animateTransform attributeName="transform" type="rotate" from="360 72 72" to="0 72 72" dur="30s" repeatCount="indefinite" />
</circle>
<circle cx="72" cy="72" r="22" fill="rgba(124,58,237,0.1)" />
<circle cx="72" cy="72" r="10" fill="rgba(139,92,246,0.15)" />
<circle cx="72" cy="72" r="3" fill="rgba(196,181,253,0.6)" />
<path d="M72 17L79 52H112L86 72L96 105L72 87L48 105L58 72L32 52H65Z" stroke="rgba(196,181,253,0.2)" strokeWidth="0.8" fill="rgba(124,58,237,0.05)">
<animateTransform attributeName="transform" type="rotate" from="0 72 72" to="360 72 72" dur="60s" repeatCount="indefinite" />
</path>
</svg> </svg>
)} )}
</div> </div>
@@ -544,17 +405,7 @@ export function HeroBanner({
</div> </div>
</div> </div>
<div <div className="absolute bottom-0 left-0 right-0 h-px" style={{ background: scheme.bottomBorder, zIndex: 3 }} />
className="absolute bottom-0 left-0 right-0 h-px"
style={{
background: isCosmos
? "linear-gradient(90deg, transparent, rgba(96,165,250,0.4), rgba(59,130,246,0.3), transparent)"
: isDawn
? "linear-gradient(90deg, transparent, rgba(251,191,36,0.4), rgba(245,158,11,0.3), transparent)"
: "linear-gradient(90deg, transparent, rgba(167,139,250,0.4), rgba(124,58,237,0.3), transparent)",
zIndex: 3,
}}
/>
</div> </div>
); );
} }
+1 -1
View File
@@ -14,7 +14,7 @@ const navLinks = [
{ label: "AI技能", href: "/skills", icon: "M13 10V3L4 14h7v7l9-11h-7z" }, { label: "AI技能", href: "/skills", icon: "M13 10V3L4 14h7v7l9-11h-7z" },
{ label: "AI评测", href: "/reviews", icon: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" }, { label: "AI评测", href: "/reviews", icon: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" },
{ label: "AI日报", href: "/daily", icon: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" }, { label: "AI日报", href: "/daily", icon: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" },
{ label: "社区", href: "/community", icon: "M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v2l-3-3H6a2 2 0 01-2-2v-2l3-3h10z" }, { label: "社区", href: "/community", icon: "M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" },
]; ];
export default function Header() { export default function Header() {
+27 -12
View File
@@ -80,18 +80,33 @@ export const authOptions = {
}); });
if (!existing) { if (!existing) {
const newUser = await prisma.user.create({ try {
data: { const newUser = await prisma.user.create({
email: email || `github_${oauthId}@placeholder.com`, data: {
name: profile.name || user.name || null, email: email || `github_${oauthId}@placeholder.com`,
avatarUrl: profile.avatar_url || user.image || null, name: profile.name || user.name || null,
oauthProvider: "github", avatarUrl: profile.avatar_url || user.image || null,
oauthId, oauthProvider: "github",
role: "user", oauthId,
}, role: "user",
}); },
user.id = newUser.id.toString(); });
user.role = newUser.role; user.id = newUser.id.toString();
user.role = newUser.role;
} catch (createErr: unknown) {
const ce = createErr as { code?: string };
if (ce.code === "P2002") {
const retry = await prisma.user.findFirst({
where: { oauthProvider: "github", oauthId },
});
if (retry) {
user.id = retry.id.toString();
user.role = retry.role;
return true;
}
}
throw createErr;
}
} else { } else {
user.id = existing.id.toString(); user.id = existing.id.toString();
user.role = existing.role; user.role = existing.role;
+5 -1
View File
@@ -4,8 +4,12 @@ import { extractJSON } from "./utils";
const openai = new OpenAI({ const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY, apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1", baseURL: "https://api.deepseek.com/v1",
timeout: 120000,
maxRetries: 2,
}); });
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
const REVIEW_PROMPT = `你是一个GitHub开源项目评测专家。请基于以下项目信息进行五维深度评测,并以JSON格式返回。务必只返回JSON,不要包含任何其他文字或Markdown格式。 const REVIEW_PROMPT = `你是一个GitHub开源项目评测专家。请基于以下项目信息进行五维深度评测,并以JSON格式返回。务必只返回JSON,不要包含任何其他文字或Markdown格式。
项目信息: 项目信息:
@@ -84,7 +88,7 @@ export async function generateReview(
.replace("{readmeSummary}", readme.substring(0, 1500)); .replace("{readmeSummary}", readme.substring(0, 1500));
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({
model: "deepseek-v4-pro", model: DEEPSEEK_MODEL,
messages: [{ role: "user", content: prompt }], messages: [{ role: "user", content: prompt }],
temperature: 0.3, temperature: 0.3,
}); });
+43 -12
View File
@@ -2,8 +2,34 @@ import { Octokit } from "octokit";
const octokit = new Octokit({ const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN, auth: process.env.GITHUB_TOKEN,
request: {
timeout: 30000,
},
}); });
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 2,
label = "GitHub API"
): Promise<T> {
let lastError: unknown;
for (let i = 0; i <= maxRetries; i++) {
try {
return await fn();
} catch (err: unknown) {
lastError = err;
const e = err as { status?: number; response?: unknown };
if (e.status === 403 || e.status === 404 || e.status === 422) throw err;
if (i < maxRetries) {
const delay = 1000 * Math.pow(2, i);
console.warn(`${label} 请求失败,${delay}ms 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, delay));
}
}
}
throw lastError;
}
export interface GitHubRepo { export interface GitHubRepo {
id: number; id: number;
full_name: string; full_name: string;
@@ -24,13 +50,15 @@ export async function searchHotRepos(
maxResults: number = 10 maxResults: number = 10
): Promise<GitHubRepo[]> { ): Promise<GitHubRepo[]> {
const q = `${query} stars:>${minStars} pushed:>2024-01-01`; const q = `${query} stars:>${minStars} pushed:>2024-01-01`;
const { data } = await octokit.rest.search.repos({ return withRetry(async () => {
q, const { data } = await octokit.rest.search.repos({
sort: "stars", q,
order: "desc", sort: "stars",
per_page: maxResults, order: "desc",
per_page: maxResults,
});
return data.items as GitHubRepo[];
}); });
return data.items as GitHubRepo[];
} }
export async function getRepoReadme( export async function getRepoReadme(
@@ -38,12 +66,15 @@ export async function getRepoReadme(
repo: string repo: string
): Promise<string> { ): Promise<string> {
try { try {
const { data } = await octokit.rest.repos.getReadme({ return await withRetry(async () => {
owner, const { data } = await octokit.rest.repos.getReadme({
repo, owner,
}); repo,
return Buffer.from(data.content, "base64").toString("utf-8"); });
} catch { return Buffer.from(data.content, "base64").toString("utf-8");
}, 2, `GitHub Readme: ${owner}/${repo}`);
} catch (err) {
console.warn(`github.ts: 获取 ${owner}/${repo} README 失败`, err);
return ""; return "";
} }
} }
+10 -9
View File
@@ -96,7 +96,7 @@ async function getConfig(): Promise<LogoFetchConfig> {
if (row?.value && typeof row.value === "object") { if (row?.value && typeof row.value === "object") {
return { ...DEFAULT_CONFIG, ...(row.value as Partial<LogoFetchConfig>) }; return { ...DEFAULT_CONFIG, ...(row.value as Partial<LogoFetchConfig>) };
} }
} catch { /* */ } } catch (err) { console.warn("logo-fetcher: Failed to load logoFetchConfig from database, using defaults", err); }
return DEFAULT_CONFIG; return DEFAULT_CONFIG;
} }
@@ -129,7 +129,7 @@ async function checkImageUrl(url: string, timeout: number, minSize: number): Pro
} }
} }
} }
} catch { /* */ } } catch (err) { console.warn("logo-fetcher: Failed to check image URL", err); }
return ""; return "";
} }
@@ -160,7 +160,7 @@ async function fetchManifestIcons(origin: string, html: string, timeout: number,
manifestHref = `${origin}${candidate}`; manifestHref = `${origin}${candidate}`;
break; break;
} }
} catch { /* */ } } catch (err) { console.warn("logo-fetcher: Failed to probe manifest candidate path", err); }
} }
} }
@@ -188,7 +188,7 @@ async function fetchManifestIcons(origin: string, html: string, timeout: number,
if (valid) return valid; if (valid) return valid;
} }
} }
} catch { /* */ } } catch (err) { console.warn("logo-fetcher: Failed to fetch manifest icons", err); }
return ""; return "";
} }
@@ -258,7 +258,7 @@ async function fetchSvgLogo(origin: string, html: string, timeout: number, minSi
} }
} }
} }
} catch { /* */ } } catch (err) { console.warn("logo-fetcher: Failed to fetch SVG logo candidate", err); }
} }
return ""; return "";
@@ -329,7 +329,7 @@ async function fetchBrandfetch(domain: string, timeout: number): Promise<string>
if (best?.src) return best.src; if (best?.src) return best.src;
} }
} }
} catch { /* */ } } catch (err) { console.warn("logo-fetcher: Failed to fetch brand from Brandfetch", err); }
return ""; return "";
} }
@@ -361,7 +361,7 @@ async function fetchScreenshotFallback(domain: string, timeout: number): Promise
} }
} }
} }
} catch { /* */ } } catch (err) { console.warn("logo-fetcher: Failed to fetch screenshot fallback", err); }
} }
return ""; return "";
@@ -559,7 +559,7 @@ export async function fetchLogoWithMethod(websiteUrl: string, html?: string): Pr
return { url: valid, method: `Favicon服务(${svc.name})` }; return { url: valid, method: `Favicon服务(${svc.name})` };
} }
} }
} catch { /* */ } } catch (err) { console.warn("logo-fetcher: Failed to fetch favicon from service", err); }
} }
} }
@@ -588,7 +588,8 @@ export async function fetchLogoWithMethod(websiteUrl: string, html?: string): Pr
setCachedLogo(domain, ""); setCachedLogo(domain, "");
return { url: "", method: "" }; return { url: "", method: "" };
} catch { } catch (err) {
console.warn("logo-fetcher: Failed to fetch logo for URL", err);
return { url: "", method: "" }; return { url: "", method: "" };
} }
} }
+19 -4
View File
@@ -4,6 +4,19 @@ import path from "path";
import crypto from "crypto"; import crypto from "crypto";
const LOGOS_DIR = path.join(process.cwd(), "public", "logos"); const LOGOS_DIR = path.join(process.cwd(), "public", "logos");
const MAX_LOGO_SIZE = 5 * 1024 * 1024;
const SVG_DANGEROUS_TAGS = /<script[\s>]|<iframe[\s>]|<object[\s>]|<embed[\s>]|<foreignObject[\s>]/gi;
const SVG_DANGEROUS_ATTRS = /\bon\w+\s*=/gi;
function sanitizeSvg(content: string): string {
if (SVG_DANGEROUS_TAGS.test(content) || SVG_DANGEROUS_ATTRS.test(content)) {
return content
.replace(SVG_DANGEROUS_TAGS, "<!--sanitized-->")
.replace(SVG_DANGEROUS_ATTRS, "data-sanitized=");
}
return content;
}
async function ensureDir() { async function ensureDir() {
if (!existsSync(LOGOS_DIR)) { if (!existsSync(LOGOS_DIR)) {
@@ -38,11 +51,11 @@ function getExtension(url: string, contentType?: string): string {
function generateFilename(url: string, ext: string): string { function generateFilename(url: string, ext: string): string {
try { try {
const domain = new URL(url).hostname.replace(/^www\./, ""); const domain = new URL(url).hostname.replace(/^www\./, "");
const hash = crypto.createHash("md5").update(url).digest("hex").slice(0, 8); const hash = crypto.createHash("sha256").update(url).digest("hex").slice(0, 8);
const safeName = domain.replace(/[^a-z0-9.-]/g, "_").substring(0, 40); const safeName = domain.replace(/[^a-z0-9.-]/g, "_").substring(0, 40);
return `${safeName}_${hash}${ext}`; return `${safeName}_${hash}${ext}`;
} catch { } catch {
const hash = crypto.createHash("md5").update(url).digest("hex").slice(0, 12); const hash = crypto.createHash("sha256").update(url).digest("hex").slice(0, 12);
return `logo_${hash}${ext}`; return `logo_${hash}${ext}`;
} }
} }
@@ -58,7 +71,7 @@ export async function downloadLogo(
if (logoUrl.startsWith("data:image/svg+xml,")) { if (logoUrl.startsWith("data:image/svg+xml,")) {
await ensureDir(); await ensureDir();
const hash = crypto const hash = crypto
.createHash("md5") .createHash("sha256")
.update(logoUrl) .update(logoUrl)
.digest("hex") .digest("hex")
.slice(0, 12); .slice(0, 12);
@@ -66,9 +79,10 @@ export async function downloadLogo(
const filePath = path.join(LOGOS_DIR, filename); const filePath = path.join(LOGOS_DIR, filename);
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
const svgContent = decodeURIComponent( const rawContent = decodeURIComponent(
logoUrl.replace("data:image/svg+xml,", "") logoUrl.replace("data:image/svg+xml,", "")
); );
const svgContent = sanitizeSvg(rawContent);
await writeFile(filePath, svgContent, "utf-8"); await writeFile(filePath, svgContent, "utf-8");
} }
@@ -102,6 +116,7 @@ export async function downloadLogo(
const buffer = Buffer.from(await res.arrayBuffer()); const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length < 50) return logoUrl; if (buffer.length < 50) return logoUrl;
if (buffer.length > MAX_LOGO_SIZE) return logoUrl;
const ext = getExtension(logoUrl, contentType); const ext = getExtension(logoUrl, contentType);
const filename = generateFilename(logoUrl, ext); const filename = generateFilename(logoUrl, ext);
+12 -8
View File
@@ -92,10 +92,14 @@ export async function getUserStats(userId: number) {
select: { select: {
points: true, points: true,
level: true, level: true,
comments: { select: { id: true } }, _count: {
forumTopics: { select: { id: true } }, select: {
forumPosts: { select: { id: true } }, comments: true,
reviews: { select: { id: true } }, forumTopics: true,
forumPosts: true,
reviews: true,
},
},
}, },
}); });
@@ -104,9 +108,9 @@ export async function getUserStats(userId: number) {
return { return {
points: user.points, points: user.points,
level: getLevelInfo(user.points), level: getLevelInfo(user.points),
commentCount: user.comments.length, commentCount: user._count.comments,
topicCount: user.forumTopics.length, topicCount: user._count.forumTopics,
postCount: user.forumPosts.length, postCount: user._count.forumPosts,
reviewCount: user.reviews.length, reviewCount: user._count.reviews,
}; };
} }
+4 -1
View File
@@ -6,7 +6,10 @@ const globalForPrisma = globalThis as unknown as {
}; };
function createPrismaClient() { function createPrismaClient() {
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4"; if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL 环境变量未设置,无法连接数据库");
}
const base = process.env.DATABASE_URL;
const sep = base.includes("?") ? "&" : "?"; const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString); const adapter = new PrismaMariaDb(connectionString);
+15
View File
@@ -1,6 +1,21 @@
const ipCounts = new Map<string, { count: number; resetAt: number }>(); const ipCounts = new Map<string, { count: number; resetAt: number }>();
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
let lastCleanup = Date.now();
function cleanupExpired() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
lastCleanup = now;
ipCounts.forEach((record, key) => {
if (now > record.resetAt) {
ipCounts.delete(key);
}
});
}
export function rateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean { export function rateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
cleanupExpired();
const now = Date.now(); const now = Date.now();
const record = ipCounts.get(ip); const record = ipCounts.get(ip);
if (!record || now > record.resetAt) { if (!record || now > record.resetAt) {
+45 -47
View File
@@ -28,21 +28,12 @@ const CATEGORY_KEYWORDS: Record<string, string[]> = {
"Prompt工程": ["prompt", "prompting", "instruction"], "Prompt工程": ["prompt", "prompting", "instruction"],
}; };
async function guessCategoryId( function matchCategoryId(
prisma: PrismaClient, categories: { id: number; name: string }[],
name: string, name: string,
description: string, description: string,
tags: string[] tags: string[]
): Promise<number> { ): number {
const categories = await prisma.skillCategory.findMany({
select: { id: true, name: true },
orderBy: { sortOrder: "asc" },
});
if (categories.length === 0) {
throw new Error("数据库中没有技能分类,请先创建分类");
}
const text = `${name} ${description} ${tags.join(" ")}`.toLowerCase(); const text = `${name} ${description} ${tags.join(" ")}`.toLowerCase();
for (const [categoryName, keywords] of Object.entries(CATEGORY_KEYWORDS)) { for (const [categoryName, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
@@ -102,6 +93,19 @@ export async function discoverSkills(
details: [], details: [],
}; };
const allCategories = await prisma.skillCategory.findMany({
select: { id: true, name: true },
orderBy: { sortOrder: "asc" },
});
if (allCategories.length === 0) {
throw new Error("数据库中没有技能分类,请先创建分类");
}
const allSkillSlugs = new Set(
(await prisma.skill.findMany({ select: { slug: true } })).map((s) => s.slug)
);
for (const query of queries) { for (const query of queries) {
if (result.discovered >= dailyLimit) break; if (result.discovered >= dailyLimit) break;
@@ -119,13 +123,30 @@ export async function discoverSkills(
continue; continue;
} }
const repoSourceUrls = repos.map((r) => r.html_url);
const existingSkills = new Set(
(
await prisma.skill.findMany({
where: { sourceUrl: { in: repoSourceUrls } },
select: { sourceUrl: true },
})
).map((s) => s.sourceUrl)
);
const existingPendingSkills = new Set(
(
await prisma.pendingSkill.findMany({
where: { sourceUrl: { in: repoSourceUrls } },
select: { sourceUrl: true },
})
).map((s) => s.sourceUrl)
);
for (const repo of repos) { for (const repo of repos) {
if (result.discovered >= dailyLimit) break; if (result.discovered >= dailyLimit) break;
const existing = await prisma.skill.findFirst({ if (existingSkills.has(repo.html_url)) {
where: { sourceUrl: repo.html_url },
});
if (existing) {
result.details.push({ result.details.push({
name: repo.full_name, name: repo.full_name,
status: "skipped", status: "skipped",
@@ -135,10 +156,7 @@ export async function discoverSkills(
continue; continue;
} }
const existingPending = await prisma.pendingSkill.findFirst({ if (existingPendingSkills.has(repo.html_url)) {
where: { sourceUrl: repo.html_url },
});
if (existingPending) {
result.details.push({ result.details.push({
name: repo.full_name, name: repo.full_name,
status: "skipped", status: "skipped",
@@ -149,8 +167,7 @@ export async function discoverSkills(
} }
const slug = generateSlug(repo.full_name); const slug = generateSlug(repo.full_name);
const slugExists = await prisma.skill.findUnique({ where: { slug } }); if (allSkillSlugs.has(slug)) {
if (slugExists) {
result.details.push({ result.details.push({
name: repo.full_name, name: repo.full_name,
status: "skipped", status: "skipped",
@@ -184,14 +201,14 @@ export async function discoverSkills(
} }
} }
const categoryId = await guessCategoryId( const categoryId = matchCategoryId(
prisma, allCategories,
repo.full_name, repo.full_name,
repo.description || "", repo.description || "",
reviewData?.tags || [] reviewData?.tags || []
); );
const skill = await prisma.skill.create({ const pendingSkill = await prisma.pendingSkill.create({
data: { data: {
name: repo.full_name.substring(0, 100), name: repo.full_name.substring(0, 100),
slug, slug,
@@ -205,39 +222,20 @@ export async function discoverSkills(
language: repo.language, language: repo.language,
license: repo.license?.spdx_id || null, license: repo.license?.spdx_id || null,
topics: repo.topics || [], topics: repo.topics || [],
reviewData: reviewData ? JSON.parse(JSON.stringify(reviewData)) : null,
}, },
tags: reviewData?.tags || [], tags: reviewData?.tags || [],
status: "draft", status: "pending",
lastReviewedAt: reviewData ? new Date() : null,
githubStarsHistory: [
{
date: new Date().toISOString().split("T")[0],
stars: repo.stargazers_count,
},
],
}, },
}); });
if (reviewData) { if (reviewData) {
await prisma.skillReview.create({
data: {
skillId: skill.id,
capability: reviewData.capability.score,
devExp: reviewData.devExp.score,
costLicense: reviewData.costLicense.score,
community: reviewData.community.score,
performance: reviewData.performance.score,
overall: reviewData.overall,
reviews: JSON.parse(JSON.stringify(reviewData)),
status: "DRAFT",
},
});
result.reviewed++; result.reviewed++;
} }
await prisma.reviewGenerationLog.create({ await prisma.reviewGenerationLog.create({
data: { data: {
skillId: skill.id, skillId: pendingSkill.id,
status: "SUCCESS", status: "SUCCESS",
prompt: `discover: ${repo.full_name}`, prompt: `discover: ${repo.full_name}`,
response: reviewData ? JSON.stringify(reviewData) : null, response: reviewData ? JSON.stringify(reviewData) : null,
+1 -1
View File
@@ -20,7 +20,7 @@ export const toolImportSchema = z.object({
tools: z.array(z.object({ tools: z.array(z.object({
name: z.string().min(1, "名称不能为空"), name: z.string().min(1, "名称不能为空"),
description: z.string().optional(), description: z.string().optional(),
websiteUrl: z.string().url().optional().or(z.literal("")), websiteUrl: z.string().url().optional().or(z.literal("").transform(() => undefined)),
logoUrl: z.string().optional(), logoUrl: z.string().optional(),
pricingModel: z.enum(["free", "freemium", "paid", "subscription"]).optional(), pricingModel: z.enum(["free", "freemium", "paid", "subscription"]).optional(),
pricingDetail: z.string().optional(), pricingDetail: z.string().optional(),
+2 -2
View File
@@ -11,7 +11,7 @@ export default withAuth(
return NextResponse.redirect(new URL("/admin/login", req.url)); return NextResponse.redirect(new URL("/admin/login", req.url));
} }
const role = (token as { role?: string }).role; const role = (token as { role?: string }).role;
if (role !== "admin" && role !== "vip") { if (role !== "admin") {
return NextResponse.redirect(new URL("/admin/login?error=unauthorized", req.url)); return NextResponse.redirect(new URL("/admin/login?error=unauthorized", req.url));
} }
} }
@@ -21,7 +21,7 @@ export default withAuth(
return NextResponse.json({ error: "未登录" }, { status: 401 }); return NextResponse.json({ error: "未登录" }, { status: 401 });
} }
const role = (token as { role?: string }).role; const role = (token as { role?: string }).role;
if (role !== "admin" && role !== "vip") { if (role !== "admin") {
return NextResponse.json({ error: "无管理员权限" }, { status: 403 }); return NextResponse.json({ error: "无管理员权限" }, { status: 403 });
} }
} }