全面审查优化: 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:
@@ -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=""
|
||||||
@@ -27,6 +27,8 @@ yarn-error.log*
|
|||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 唯一约束冲突竞态
|
||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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返回为空");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: "个" },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" })));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } }) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
+130
-111
@@ -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,46 +128,55 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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: "个" },
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: "个" },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user