2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间

This commit is contained in:
ZhuiGuangAI Dev
2026-05-24 23:05:38 +08:00
parent 6c78c32157
commit 9bf3e7574a
118 changed files with 11842 additions and 3262 deletions
+72 -255
View File
@@ -1,275 +1,92 @@
# 追光AI 变更日志
## v0.11.1 (2026-05-22) - AI发现管线质量优化
## 2026-05-25 等级体系升级 + 论坛二级分类 + 会员空间
### 全链路测试
- 跑通全部AI自动化管线:GitHub搜索 → DeepSeek评测 → 入库 → 内容生成
- 2个脚本(automatic-review + daily-discover) + 4个API端点全部验证通过
- 连测创建12条技能+评测记录,DeepSeek生成1篇日报+长文+视频脚本+社交卡片
### 🔥 20级成长体系 (类似QQ)
- **从4级扩展到20级**: LV01(0分) → LV20(50000分)
- **等级图标**: ⭐(初级) → ⭐⭐⭐(中级) → 🌟(高级) → 🌟🌟(精英) → 🌟🌟🌟(大师)
- **旧等级迁移**: BRONZE→LV01, SILVER→LV03, GOLD→LV06, PLATINUM→LV10
- `src/lib/level-config.ts` — 20级完整配置(含每日限额表)
- **迁移脚本**: `scripts/run-migration-manual.mjs` (绕过shadow DB问题)
### 质量过滤修复
- **非AI项目过滤**: auto-review.mjs + daily-discover.mjs 新增 `isValidAIProject()` 函数
- 关键词过滤: `book/guide/tutorial/awesome/interview/notes/handbook/cheatsheet/roadmap/curated-list` 等
- 语言过滤: 纯Markdown/HTML文档Repo(Stars<5000)自动拒绝
- 主题过滤: 有GitHub Topics的Repo必须包含AI相关Topic(ai/llm/rag/agent/gpt等)
- 清理5条误入库非AI数据:AgentGuide、JavaGuide、pathway、StableDiffusionBook、Claude-Code-Everything
### 🔥 论坛二级分类结构
- **ForumCategory自引用关系**: parent/children (CategoryHierarchy)
- **6个父分类 + 24个子分类**:
- 综合讨论区 → 新手入门/AI资讯/观点讨论
- AI工具 → 聊天写作/图像创作/视频音频/代码编程/效率办公/数据分析/教育学习
- 开源技能 → LLM框架/Agent框架/RAG技术/模型训练/部署推理/多模态/数据工程
- 技术深度 → 算法原理/模型评测/安全对齐/硬件加速
- 教程与分享 → 入门教程/实战项目/最佳实践
- 社区互动 → 求助问答/求职招聘/闲聊灌水/反馈建议
- **修复脚本**: `scripts/fix-forum-categories.mjs` (修正孤立子分类parentId)
- `/community` 首页更新为显示二级分类结构
### JSON解析健壮性
- 新增 `extractJSON()` 统一工具函数 → `src/lib/utils.ts`
- 支持markdown代码块包裹(`\`\`\`json...\`\`\``)、花括号/方括号容错
- 替换 deepseek.ts、auto-review.mjs、daily-discover.mjs、daily-reports/generate 的旧解析逻辑
- 新增 `scripts/test-content-generation.mjs` 全管线测试脚本
### 🆕 会员个人空间
- `/user/[id]` — 会员空间页面(资料/统计/话题/评论)
- **分享功能**: `ShareButton` 组件(原生API/Twitter/QQ/复制链接)
- **bio字段**: 用户个人简介,支持编辑(最多500字符)
- `/user` 页面新增bio编辑区域
### 新增脚本
- `scripts/cleanup-non-ai.mjs` — 一键清理误入库非AI技能数据
### 🔧 技术修复
- 修复所有旧等级引用 (BRONZE → LV01)
- 修复 ShareButton 构建错误 (Twitter图标 → Link图标)
- 修复 LevelProgress 动态颜色逻辑 (基于等级数字计算)
- 数据库迁移标记为已应用: `20260525000000_expand_user_levels`
---
## v0.11.0 (2026-05-22) - 权限体系重构
## 2026-05-24 会员功能优化 + 追光会论坛上线
### 架构变更
- **新增数据模型**: `Role`(角色), `Permission`(权限点), `RolePermission`(角色-权限关联) 三张表
- **User 模型**: 新增 `roleId` 字段关联 `Role`,与原有 `role` 字符串字段并行(向后兼容)
- 默认角色:**超级管理员**(Admin, 全部53权限)、**VIP成员**(Vip, 19个精选权限)
### 🆕 积分体系
- **PointsHistory** 数据模型 — 记录所有积分变动,含原因/详情/关联信息
- **4级会员系统**: 青铜🥉(0分) → 白银🥈(100分) → 黄金🥇(500分) → 铂金💎(2000分)
- **积分规则**: 评论+5/被赞+2/发帖+10/回复+5/评价+8,含每日上限防刷分
- `src/lib/level-config.ts` — 客户端安全等级配置(无 Prisma 依赖)
- `src/lib/points-reward.ts` — 积分奖励引擎(rewardPoints / getUserStats / getUserPointsHistory)
- `/api/user/points` — 积分统计 + 历史查询 API
### 权限体系
- **53个精细化权限点**,按页面分组管理:
- 工具管理 14项 · 技能库 12项 · 评测管理 8项
- AI发现 7项 · 内容管理 8项 · 系统管理 4项
- 所有后台按钮/操作均已对应权限点,可逐个勾选控制
### 🆕 追光会(论坛)
- **3个新页面**: /community(首页)、/community/[category](板块)、/community/topic/[id](话题详情)
- **8个论坛板块**: 综合讨论/AI工具推荐/技能分享/技术探讨/教程指南/反馈建议/求职招聘/闲聊灌水
- **5个论坛API**: 分类/话题(CRUD)/回复(CRUD) 共10个端点
- **4个论坛组件**: ForumBoard / ForumTopicCard / ForumEditor / NotificationBell
- **种子脚本**: `scripts/seed-forum-categories.mjs`
### 新增页面
- **`/admin/roles` 角色权限管理**: 左侧角色列表 + 右侧按分组的权限勾选网格
- 新建/删除自定义角色(系统角色受保护)
- 分组全选/取消全选
- 保存按钮实时同步到数据库
### 🆕 评论系统(全新实现)
- `/api/comments` — GET获取(含用户等级/积分)+ POST创建(自动积分奖励+通知)
- `/api/comments/[id]/like` — 点赞/取消点赞(切换模式,+2分+通知)
- `CommentCard` — 评论卡片(等级徽章/评分/点赞/嵌套回复)
- `CommentThread` — 评论列表(分页/排序/回复面板)
### 新增 API
- `GET/POST /api/admin/roles` — 角色列表/创建
- `PUT/DELETE /api/admin/roles/[id]` — 角色编辑/删除
- `GET /api/admin/permissions` — 全部权限点列表
- `GET /api/admin/user-permissions` — 用户权限查询(支持roleId/userId/当前用户)
### 🆕 通知系统
- `/api/notifications` — GET获取(含未读数)+ PUT标记已读
- `NotificationBell` — 导航栏通知铃铛(红点/下拉面板/自动已读)
- 自动触发: 评论回复/评论点赞/话题回复
### 鉴权升级
- **middleware.ts**: 从硬编码 `role==="admin"` 改为认证检查 + 路由层委派
- **admin-auth.ts**: `requireAdmin()` 兼容旧 `role` 字符串 + 新 `roleId` 关联角色双重逻辑
- 新增 `requirePermission(key)` — 细粒度权限点校验
- 新增 `getAllUserPermissions()` — 获取当前用户全部权限集合
### 🆕 会员UI组件
- `MemberBadge` — 等级徽章(3种尺寸,支持显示/隐藏积分)
- `LevelProgress` — 等级进度条(当前等级/下一级/进度百分比)
### 前端工具
- 新增 `usePermission()` hook — 提供 `can(key)`, `canAny(keys)`, `canAll(keys)` 方法
- 侧边栏新增"角色权限"菜单入口
### 种子脚本
- `scripts/seed-permissions.mjs` — 一键初始化全部权限点+角色
---
## v0.10.1 (2026-05-22) - 全面修复与优化
### 安全修复
- **Admin API 鉴权增强**: 中间件matcher新增`/api/admin/:path*`路由保护,未登录或非管理员角色返回403
- 所有关键admin路由已内置`requireAdmin()`双重校验(中间件+路由层防御深度)
### Bug 修复
- **Category ID 动态化**: `skill-discoverer.ts`、`daily-discover.mjs`、`auto-review.mjs`三个文件的硬编码Category ID(120-127)替换为数据库查询+关键词匹配,消除分类错配风险
- **reviews统计卡片**: 修复评测管理页面统计(已发布/草稿/平均评分)只显示当前页数据的问题,改为从全局groupBy统计获取
- **内容生成日志完善**: `/api/admin/reviews/[id]/generate`成功和失败均写入`ReviewGenerationLog`
### 功能增强
- **待审核技能编辑增强**: 编辑对话框新增"功能特性(JSON)"和"标签(逗号分隔)"输入框,支持直接编辑上传
- **已发布技能编辑增强**: 编辑对话框新增"分类"下拉选择、"功能特性(JSON)"、"标签"字段,功能对齐待审核页面
- **日报AI一键生成**: 新增`/api/admin/daily-reports/generate`端点,前端新增"一键AI生成"按钮,直接调用DeepSeek生成日报并自动填充表单,替代原有的"复制提示词→粘贴JSON"手动流程
### 内部改进
- `ReviewGenerationLog`确认已在`skill-discoverer.ts`和两个脚本中正常使用,非僵尸表
- 三个脚本文件统一使用数据库查询获取分类ID,消除代码不一致
---
## v0.10.0 (2026-05-20) - 五维评测系统
### 新增
- **数据库扩展**: SkillReview模型(五维评测)、ReviewGenerationLog模型(评测日志)、ReviewStatus枚举(DRAFT/PUBLISHED/REJECTED/OUTDATED)
- **Skill模型扩展**: 新增lastReviewedAt(最近评测时间)、githubStarsHistory(Star历史)字段,关联skillReview
- **GitHub数据采集模块** (`src/lib/github.ts`): octokit封装,支持searchHotRepos搜索热门仓库、getRepoReadme获取README
- **DeepSeek评测生成模块** (`src/lib/deepseek.ts`): OpenAI兼容接口,五维评测提示词模板,综合分自动校验
- **自动化评测脚本** (`scripts/auto-review.mjs`): GitHub搜索→README获取→DeepSeek评测→入库全流程,每日限制8个
- **评测API** (`/api/skills/[slug]/review`): 获取PUBLISHED状态的五维评测数据
- **SkillReviewCard组件**: Chart.js雷达图+维度详情卡片,dynamic import(ssr:false),紫色主色调
- **技能详情页集成**: 在标签卡片和评论之间展示五维评测雷达图
- **发布评测脚本** (`scripts/publish-reviews.mjs`): 批量将DRAFT评测改为PUBLISHED
- **测试脚本**: test-github.mjs(GitHub API)、test-deepseek.mjs(DeepSeek API)
- **新依赖**: octokit, openai, chart.js, react-chartjs-2
### 修改
- .env新增GITHUB_TOKEN、DEEPSEEK_API_KEY配置
- 开发规则更新: 允许服务端脚本调用外部AI API,前端仍禁止
- 迁移记录: 第10次迁移 add_skill_reviews
### 踩坑
- Prisma 7.8需通过PrismaMariaDb适配器初始化,脚本中不能直接new PrismaClient()
- Chart.js组件需dynamic import + ssr:false,不支持服务端渲染
- 服务器环境SSL证书问题: 运行脚本需设置$env:NODE_TLS_REJECT_UNAUTHORIZED="0"
- Chart.js font.weight只接受bold/normal/bolder/lighter,不接受数字字符串如"500"
---
## v0.9.0 (2026-05-09)
### 重大变更
- **移除所有外部AI API调用**: discover-tools、discover-skills、discover-news三个API不再调用DeepSeek API,改为接收前端提交的JSON数据
- **端口迁移**: 开发端口从3100-3110迁移至8301-8310,默认端口8301
- **五大操作指令模式重构**: 从"一键调用API"模式改为"提示词+JSON粘贴导入"模式
### 新增
- AI新闻发现页面 `/admin/discover-news`:3步流程(复制提示词→粘贴JSON→查看日报),6大分类+热度评分
- AI新闻发现API `POST /api/admin/discover-news`:接收新闻JSON数组,自动生成今日AI日报
- AI新闻发现API `GET /api/admin/discover-news`:获取今日AI日报
- `fetchLogoWithMethod()` 函数:返回Logo URL + 成功方法名(如"HTML Link图标"、"OG/Twitter图片"等)
- 检测AI工具页面增加Logo抓取功能:检测时同步抓取缺失Logo,记录成功方法名统计
- 检测AI工具页面增加Logo抓取方法统计面板:按成功次数排序展示每种方法的效果
- 检测AI工具页面增加Logo新抓取成功列表:展示Logo预览和使用的抓取方法名
- 导入10天100条真实AI新闻(4月30日-5月9日)
### 修改
- discover-tools API: 从调用DeepSeek API改为接收 `{tools: [...], categoryId}` JSON数组
- discover-skills API: 从调用DeepSeek API改为接收 `{skills: [...], categoryId}` JSON数组
- discover-tools页面: 从一键发现改为4步流程(选分类→复制提示词→粘贴JSON→抓取Logo)
- discover-skills页面: 从一键发现改为3步流程(选分类→复制提示词→粘贴JSON)
- discover-news页面: 从一键发现改为3步流程(复制提示词→粘贴JSON→查看日报)
- check-tools API: 增加Logo抓取功能,返回logoFetched/logoMethod/logoUrl字段,GET请求获取HTML用于Logo抓取
- check-tools页面: 默认选中待审核工具,4步流程卡片,增加Logo抓取方法统计和新抓取成功列表
- update-skills页面: 去掉复制提示词,保留快速更新Star
- logo-fetcher.ts: 新增 `fetchLogoWithMethod()` 导出,每种成功方法返回中文名称
- package.json: 端口3100→8301
- .env: NEXTAUTH_URL端口3100→8301
- restart.ps1: 端口扫描范围3100-3110→8301-8310
- project_rules.md: 端口规范8301-8310,新增强制要求;五大操作指令改为提示词+JSON粘贴模式;禁止硬编码API Key;禁止调用外部AI API
### 删除
- 移除3个API文件中的DeepSeek API调用逻辑(硬编码API Key、callDeepSeek函数等)
- 移除discover-tools页面的"一键发现"按钮和DeepSeek API调用
- 移除discover-skills页面的"一键发现"按钮和DeepSeek API调用
- 移除discover-news页面的DeepSeek API调用
- 清除旧的虚构AI新闻数据
---
## v0.8.0 (2026-05-05)
### 新增
- 用户系统:邮箱注册/登录、GitHub OAuth登录、用户中心页面
- 收藏功能:工具/技能收藏、收藏列表页面、乐观更新按钮
- 浏览历史:自动记录浏览过的工具/技能
- 评论评价系统:ReviewSection组件,支持评分和文字评论
- LoginModal组件:登录/注册弹窗,支持模式切换
- FavoriteButton组件:收藏按钮,乐观更新
- CategorySidebar组件:通用分类侧边栏
- 用户API: `/api/user/favorites`, `/api/user/history`
- 评论API: `/api/reviews`
- 首页API: `/api/homepage`(含嵌套分类结构)
### 改进
- **UI主题重构:暗色→浅色主题**
- 全局CSS变量从暗色切换为浅色(background: 210 20% 98%, card: 0 0% 100%等)
- 所有页面和组件适配浅色主题(7个前台页面+12个后台页面+5个组件)
- 颜色规范:文字-600/-700、背景-50、边框-200/60
- 卡片样式:bg-white/60/80 + shadow-sm,hover时bg-white + shadow-md
- 输入框:bg-muted/50 focus:bg-white focus:ring-2 focus:ring-primary/10
- 毛玻璃效果:bg-white/80 backdrop-blur-xl
- 渐变文字:蓝→紫→粉渐变保持科技感
- 移除noise纹理,新增dot-grid点阵背景
- **层级分类导航重构**
- 首页/技能页:左侧固定一级分类导航 + 右侧二级分类标签切换
- 每个一级类目下有"全部"标签显示该分类下所有内容
- 左侧导航sticky跟随页面滚动
- **安全加固**
- 新增admin-auth.ts中间件,requireAdmin函数
- 28个后台API端点全部添加requireAdmin中间件验证
- 移除硬编码API密钥,改用环境变量
- Header组件:滚动时bg-white/80 backdrop-blur-2xl,搜索框bg-muted/50
- Logo位置修复:Header容器宽度统一为max-w-[1440px]
### 🔧 技术改进
- **NextAuth 类型扩展** (`src/types/next-auth.d.ts`) — Session/User/JWT 包含 level/points
- **auth.ts 增强** — JWT/Session回调包含 level/points
- **客户端安全架构** — level-config.ts 与 points-reward.ts 分离,避免客户端打包Node.js原生模块
- **Header 增强** — 新增社区导航链接 + 通知铃铛 + 会员等级徽章
- **/user 页面增强** — 积分进度条/积分明细/统计卡片
### 修复
- 修复webpack缓存损坏导致的页面加载失败(清除.next目录)
- 修复左侧导航栏sticky定位不生效(flex容器添加self-start)
- 修复Logo被左侧导航遮挡(统一容器宽度)
- ForumPost 新增 `isAnswer` 字段(标记最佳回答)
- Prisma schema 日期默认值修正 `@default(now)` → `@default(now())`
- 迁移兼容问题处理(`prisma db push` 同步)
### 统计数据(本次新增)
- **新增文件**: 15个
- **新增API端点**: 15个
- **新增页面**: 3个
- **新增组件**: 8个
- **新增数据模型**: 1个(PointsHistory)
---
## v0.5.0 (2026-05-03)
### 新增
- 待审核工具页面批量多选功能:全选/批量通过/批量拒绝
- 待审核技能页面批量多选功能:全选/批量通过/批量拒绝
- 待审核技能一键全部通过功能
- 所有列表页面分页功能(每页20条)
- 批量拒绝API: `/api/admin/pending/batch-reject`, `/api/admin/pending-skills/batch-reject`
- 一键全部发布API: `/api/admin/pending-skills/publish-all`
- AI技能自动探索API: `/api/admin/discover-skills/run`
- 探索脚本存入系统配置,discover-skills页面展示脚本和调用说明
- 一键自动探索按钮(服务端调用DeepSeek API)
- Logo抓取策略改进:新增manifest.json解析、更多link/img匹配模式、图片URL验证
- 工具库与脚本模块文档: `.trae/specs/modules/07-lib-and-scripts.md`
### 改进
- Logo抓取引擎增强:6种策略按优先级,minFileSize降至50字节,timeout增至10秒
- 待审核工具页面:新增Logo状态列、Logo筛选、抓取缺失Logo/重新抓取全部Logo按钮
- 待审核技能页面:新增Star数展示、GitHub链接、分类标签
- pending API和pending-skills API支持分页参数
- 技术文档全面更新(7个模块文档+索引+开发规则+变更日志)
---
## v0.4.0 (2026-05-02)
### 新增
- 系统配置页面:Logo抓取配置、发现提示词配置
- 四个AI发现菜单:探索AI工具、检测AI工具、探索Skill技能、更新Skill技能
- AI技能发现脚本 `scripts/discover-skills.mjs`
- GitHub中文友好判断标准(4步标准)
- 技能分类管理页面
- DeepSeek V4 Pro API配置
### 改进
- 配置驱动模式:提示词和Logo抓取策略存储在SystemConfig表
- 去掉DeepSeek API配置功能,改为配置项驱动
- 技能发现提示词优化
---
## v0.3.0 (2026-05-01)
### 新增
- 技能库模块:Skill/PendingSkill数据模型、API、管理页面
- API接口配置:ApiProvider数据模型和管理页面
- 发现配置:DiscoveryConfig与分类管理集成
- 公共Logo抓取工具 `src/lib/logo-fetcher.ts`
- 服务重启脚本 `restart.ps1`
### 改进
- 去除爬虫功能,改为AI发现模式
- UI全面优化:暗色主题、毛玻璃效果、动画
- 端口从3000改为3100
---
## v0.2.0 (2026-04-30)
### 新增
- 后台管理页面:工具管理、分类管理、待审核工具
- NextAuth认证系统
- Prisma ORM + MySQL数据库
- AI智能发现新工具功能(DeepSeek API)
- 批量导入工具脚本
---
## v0.1.0 (2026-04-28)
### 新增
- Next.js 14项目初始化
- Tailwind CSS + shadcn/ui配置
- 前台页面:首页、工具列表、工具详情、分类页
- Header/Footer布局组件
## 2026-05-24 全面技术文档生成
(详见历史 CHANGELOG 记录)
+24 -5
View File
@@ -127,11 +127,30 @@
- GITHUB_TOKEN: GitHub Personal Access Token(可选,提升API限流从60→5000次/小时)
- DEEPSEEK_API_KEY: DeepSeek API Key(用于技能评测生成脚本、AI日报整理)
## 定时任务(Crontab)
- **AI日报生成**: 每天 7:30 — `scripts/daily-news.mjs`(搜索真实新闻 + DeepSeek整理格式)
- **技能自动发现**: 每天 2:00 — `scripts/daily-discover.mjs`(GitHub搜索 + DeepSeek评测 + 入库)
- 运行脚本: `/home/ubuntu/zhuiguang-ai/scripts/cron-daily-news.sh` / `cron-daily-discover.sh`
- 日志目录: `/home/ubuntu/zhuiguang-ai/logs/`
## 定时任务调度系统
- **后台管理**: `/admin/tasks`(任务中心)、`/admin/tasks/logs`(执行日志)、`/admin/tasks/[taskKey]`(单任务详情+统计)
- **数据模型**: TaskConfig(任务配置,含enabled/cronExpr/config)、TaskLog(执行历史,含status/duration/result/error)
- **种子数据**: `scripts/seed-task-configs.mjs` 初始化6个任务配置
- **手动触发**: `/admin/tasks` 页支持每个任务运行,也可通过 POST `/api/admin/tasks/run` 触发
- **API**:
- `GET/PUT /api/admin/tasks/configs` — 任务配置查询/更新
- `GET /api/admin/tasks/logs` — 任务执行日志(分页+按taskKey筛选)
- `POST /api/admin/tasks/run` — 手动触发任务执行
### 定时任务(Crontab)
所有6个定时任务全部经过TaskLog记录:
| 时间 | 任务 | 脚本 | TaskKey | 说明 |
|------|------|------|---------|------|
| 01:00 | Task5 更新星值 | `scripts/task5-update-stars.mjs` | task5-update-stars | 更新所有GitHub技能Stars数+日/周/月变化 |
| 03:00 | Task3 工具巡检 | `scripts/task3-check-tools.mjs` | task3-check-tools | 检查网站/Lo4go可访问性+自动发布合格待审核工具 |
| 04:00 | Task6 评测热门 | `scripts/task6-review-hot.mjs` | task6-review-hot | 评测2个最热未评测GitHub技能(五维评测) |
| 05:00 | Task4 发现技能 | `scripts/daily-discover.mjs` | task4-discover-skills | GitHub搜索≥1000 stars项目→DeepSeek预评测→入库PendingSkill |
| 06:00 | Task1 发现工具 | `scripts/task1-discover-tools.mjs` | task1-discover-tools | 36kr/IT之家/开源中国RSS→DeepSeek提取工具→入库PendingTool |
| 07:30 | AI日报 | `scripts/daily-news.mjs` | daily-news | 多源搜索真实新闻+DeepSeek整理格式→发布日报 |
- Crontab 位于服务器: `crontab -l`(ubuntu用户)
- 日志目录: `/home/ubuntu/zhuiguang-ai/logs/`(task1.log ~ task6.log)
## AI日报新闻源
- **36kr** RSS (https://36kr.com/feed) — 国内科技资讯
+27 -17
View File
@@ -1,7 +1,7 @@
# 追光AI - 模块文档索引
## 项目概述
追光AI是一个AI工具与开源技能目录网站,支持提示词驱动的工具/技能发现、审核管理和前台展示。
追光AI是一个AI工具与开源技能目录网站,支持提示词驱动的工具/技能发现、审核管理和前台展示,集成五维评测系统、任务调度、权限体系和社区互动功能。
## 技术栈
@@ -12,9 +12,11 @@
| 样式 | Tailwind CSS + shadcn/ui | 3.x |
| 数据库 | MySQL (阿里云RDS) + Prisma | 7.8 |
| 认证 | NextAuth.js | v4 |
| AI | DeepSeek API (技能评测生成) | deepseek-v4-pro |
| AI | DeepSeek API (deepseek-v4-pro) | 服务端脚本调用 |
| 图表 | Chart.js + react-chartjs-2 | 4.x |
| 部署 | Node.js | 18+ |
| GitHub | Octokit | 5.x |
| 验证 | Zod | 4.x |
| 部署 | Node.js + PM2 | 18+ |
| 端口 | 8301-8310 | 默认8301 |
## 项目结构
@@ -22,16 +24,18 @@
zhuiguang-ai/
├── prisma/ # 数据库Schema + 迁移 + 种子
├── public/ # 静态资源 (logo.png, logos/)
├── scripts/ # 独立脚本 (discover-skills, import-tools)
├── scripts/ # 独立脚本 (6个定时任务 + 多个工具脚本)
├── src/
│ ├── app/
│ │ ├── (admin)/admin/ # 后台页面 (15个页面)
│ │ ├── api/ # API路由 (36+个端点)
│ │ ├── (admin)/admin/ # 后台页面 (18个页面 + 任务调度3个子页)
│ │ ├── api/ # API路由 (50+个端点)
│ │ ├── categories/ # 前台分类页
│ │ ├── reviews/ # 前台评测列表+榜单
│ │ ├── skills/ # 前台技能页
│ │ ├── tools/ # 前台工具页
│ │ ├── login/ # 用户登录页
│ │ ├── user/ # 用户中心(个人资料/收藏)
│ │ ├── daily/ # AI日报页
│ │ ├── user/ # 用户中心(个人资料/收藏/收藏夹)
│ │ ├── globals.css # 全局样式
│ │ ├── layout.tsx # 前台布局
│ │ └── page.tsx # 首页
@@ -47,13 +51,15 @@ zhuiguang-ai/
| 序号 | 模块 | 文档 | 说明 |
|------|------|------|------|
| 01 | 数据库 | [01-database.md](./modules/01-database.md) | 数据模型、枚举、索引、迁移历史 |
| 02 | API | [02-api.md](./modules/02-api.md) | 前台/后台API端点、SystemConfig配置键 |
| 01 | 数据库 | [01-database.md](./modules/01-database.md) | 26张数据模型、8个枚举、完整索引、迁移历史 |
| 02 | API | [02-api.md](./modules/02-api.md) | 前台/后台/用户API端点、SystemConfig配置键 |
| 03 | 页面 | [03-pages.md](./modules/03-pages.md) | 前台/后台页面路由、布局、功能说明 |
| 04 | 组件与样式 | [04-components-and-styles.md](./modules/04-components-and-styles.md) | UI组件、样式系统、设计规范 |
| 05 | AI发现 | [05-ai-discovery.md](./modules/05-ai-discovery.md) | 提示词驱动发现流程、Logo抓取引擎、新闻发现 |
| 06 | 认证 | [06-auth.md](./modules/06-auth.md) | NextAuth配置、路由保护、登录流程 |
| 07 | 工具库与脚本 | [07-lib-and-scripts.md](./modules/07-lib-and-scripts.md) | 工具函数、独立脚本、运维脚本 |
| 05 | AI发现 | [05-ai-discovery.md](./modules/05-ai-discovery.md) | 提示词驱动发现流程、Logo抓取引擎、定时任务、评测管线 |
| 06 | 认证 | [06-auth.md](./modules/06-auth.md) | NextAuth配置、路由保护、权限体系、登录流程 |
| 07 | 工具库与脚本 | [07-lib-and-scripts.md](./modules/07-lib-and-scripts.md) | 工具函数、定时任务脚本、运维脚本 |
| 08 | 社区功能 | [08-community.md](./modules/08-community.md) | 评论系统、收藏、浏览历史、收藏夹、通知、论坛 |
| 09 | 运维与部署 | [09-devops.md](./modules/09-devops.md) | 服务器配置、Nginx、Crontab、部署流程、踩坑记录 |
## 环境变量
```
@@ -70,7 +76,7 @@ DEEPSEEK_API_KEY=<required-for-review>
→ PendingTool / PendingSkill (待审核)
→ 管理员审核(编辑/发布/拒绝/批量操作)
→ Tool / Skill (已发布)
→ 前台展示(分类浏览/搜索/详情)
→ 前台展示(分类浏览/搜索/详情/评测/评论)
```
## 关键特性
@@ -79,12 +85,16 @@ DEEPSEEK_API_KEY=<required-for-review>
- **检测+Logo联动**: 检测AI工具时同步抓取缺失Logo,记录每种抓取方法的成功次数统计
- **双轨内容**: 工具(Tool)面向商业AI产品,技能(Skill)面向GitHub开源项目
- **批量操作**: 批量导入、批量发布、批量拒绝、一键全部通过
- **GitHub Star同步**: 通过GitHub API定期更新技能Star数
- **AI新闻发现**: 提示词驱动发现AI新闻,按日生成日报,6大分类+热度评分
- **GitHub Star同步**: 通过GitHub API定期更新技能Star数,维护90天历史
- **五维评测系统**: GitHub数据采集 + DeepSeek AI评测 + 雷达图展示,5维度(capability/devExp/costLicense/community/performance)评分
- **评测榜单**: 前台/reviews页面展示评测分榜TOP10 + Stars涨幅榜TOP10侧边栏
- **任务调度系统**: 6个定时任务(Task1-6)+AI日报,统一TaskConfig配置+TaskLog执行历史+后台可视化管理+手动触发
- **权限体系**: 53个精细化权限点,角色管理,细粒度权限控制
- **社区互动**: 评论/回复、收藏、浏览历史、收藏夹、通知系统、论坛板块
- **分页**: 所有列表页面支持分页(每页20条)
- **浅色主题**: 科技感浅色设计系统,毛玻璃效果+渐变文字+精致阴影
- **层级分类**: 一级分类+二级分类,左侧导航+标签切换
- **用户系统**: 邮箱注册/登录、收藏功能、浏览历史
- **安全加固**: 28个后台API全部添加requireAdmin中间件验证
- **用户系统**: 邮箱注册/登录、GitHub OAuth、收藏功能、浏览历史
- **安全加固**: 后台API全部添加requireAdmin中间件验证,路由层+中间件双重防御
- **端口锁定**: 8301-8310区间,禁止使用其他端口
- **频率限制**: 登录尝试限制、IP请求限制
+461 -75
View File
@@ -4,6 +4,7 @@
- Prisma 7.8 + @prisma/adapter-mariadb
- MySQL (阿里云RDS)
- 连接字符串: `mysql://mohe001:***@rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com:3306/zhuiguang_ai`
- Prisma单例模式防止热重载多实例
## 数据模型
@@ -11,126 +12,511 @@
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| name | String @db.VarChar(100) | 分类名称 |
| slug | String @unique @db.VarChar(100) | URL标识 |
| icon | String? | 图标 |
| parentId | Int? | 父分类ID(自引用) |
| sortOrder | Int @default(0) | 排序权重 |
| createdAt | DateTime | 创建时间 |
| updatedAt | DateTime | 更新时间 |
| name | String @db.VarChar(50) | 分类名称 |
| slug | String @unique @db.VarChar(50) | URL标识 |
| icon | String? @db.VarChar(100) | 图标 |
| parentId | Int? @map("parent_id") | 父分类ID(自引用) |
| sortOrder | Int @default(0) @map("sort_order") | 排序权重 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: tools (Tool[]), pendingTools (PendingTool[]), parent/children (自引用层级)
### SkillCategory (技能分类)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| name | String @db.VarChar(100) | 分类名称 |
| slug | String @unique @db.VarChar(100) | URL标识 |
| icon | String? | 图标 |
| parentId | Int? | 父分类ID(自引用) |
| sortOrder | Int @default(0) | 排序权重 |
| createdAt | DateTime | 创建时间 |
| updatedAt | DateTime | 更新时间 |
| name | String @db.VarChar(50) | 分类名称 |
| slug | String @unique @db.VarChar(50) | URL标识 |
| icon | String? @db.VarChar(100) | 图标 |
| parentId | Int? @map("parent_id") | 父分类ID(自引用) |
| sortOrder | Int @default(0) @map("sort_order") | 排序权重 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: skills (Skill[]), pendingSkills (PendingSkill[]), parent/children (自引用层级)
### Tool (已发布工具)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id | 主键 |
| name | String | 工具名称 |
| slug | String @unique | URL标识 |
| categoryId | Int | 分类ID(必填) |
| id | Int @id @default(autoincrement()) | 主键 |
| name | String @db.VarChar(100) | 工具名称 |
| slug | String @unique @db.VarChar(100) | URL标识 |
| categoryId | Int @map("category_id") | 分类ID |
| description | String @db.Text | 简介 |
| logoUrl | String? | Logo地址 |
| websiteUrl | String | 官网地址 |
| pricingModel | PricingModel | 定价模型(free/freemium/paid/subscription) |
| pricingDetail | String? | 定价详情 |
| features | Json? | 功能特性数组 |
| tags | Json? | 标签数组 |
| viewCount | Int @default(0) | 浏览次数 |
| logoUrl | String? @map("logo_url") @db.VarChar(255) | Logo地址 |
| websiteUrl | String @map("website_url") @db.VarChar(255) | 官网地址 |
| pricingModel | PricingModel @default(freemium) | 定价模型 |
| pricingDetail | String? @map("pricing_detail") @db.VarChar(200) | 定价详情 |
| features | Json? | 功能特性(JSON) |
| tags | Json? | 标签数组(JSON) |
| viewCount | Int @default(0) @map("view_count") | 浏览次数 |
| commentCount | Int @default(0) @map("comment_count") | 评论数 |
| avgRating | Float @default(0) @map("avg_rating") | 平均评分 |
| status | Status @default(draft) | 状态(draft/published) |
| createdAt/updatedAt | DateTime | 时间戳 |
| lastCheckedAt | DateTime? @map("last_checked_at") | 最后巡检时间 |
| websiteOk | Boolean @default(true) @map("website_ok") | 网站可访问 |
| logoOk | Boolean @default(true) @map("logo_ok") | Logo有效 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: category, favorites, browsingHistory, reviews, collectionItems, comments
### PendingTool (待审核工具)
与Tool类似,但categoryId/description/websiteUrl等可为空,额外有sourceUrl和status(pending/approved/rejected)。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| name | String @db.VarChar(100) | 工具名称 |
| slug | String? @db.VarChar(100) | URL标识 |
| categoryId | Int? @map("category_id") | 分类ID |
| description | String? @db.Text | 简介 |
| logoUrl | String? @map("logo_url") @db.VarChar(255) | Logo地址 |
| websiteUrl | String? @map("website_url") @db.VarChar(255) | 官网地址 |
| pricingModel | PricingModel? @default(freemium) | 定价模型 |
| pricingDetail | String? @map("pricing_detail") @db.VarChar(200) | 定价详情 |
| features | Json? | 功能特性 |
| tags | Json? | 标签数组 |
| sourceUrl | String? @map("source_url") @db.VarChar(255) | 来源地址 |
| status | PendingStatus @default(pending) | 状态(pending/approved/rejected) |
| checkWebsiteOk | Boolean? @map("check_website_ok") | 网站可访问检查结果 |
| checkLogoOk | Boolean? @map("check_logo_ok") | Logo检查结果 |
| checkDescOk | Boolean? @map("check_desc_ok") | 描述检查结果 |
| checkCategoryOk | Boolean? @map("check_category_ok") | 分类检查结果 |
| checkDetail | Json? @map("check_detail") | 检查详情(JSON) |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: category
### Skill (已发布技能)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id | 主键 |
| name | String | 技能名称 |
| slug | String @unique | URL标识 |
| categoryId | Int | 分类ID(必填) |
| id | Int @id @default(autoincrement()) | 主键 |
| name | String @db.VarChar(100) | 技能名称 |
| slug | String @unique @db.VarChar(100) | URL标识 |
| categoryId | Int @map("category_id") | 分类ID |
| description | String @db.Text | 简介 |
| logoUrl | String? | Logo地址 |
| sourceUrl | String | GitHub仓库地址 |
| sourceType | String @default("gitee") | 来源类型 |
| rating | Float @default(0) | 综合评分 |
| features | Json? | 功能特性(含forks/language/license/topics) |
| logoUrl | String? @map("logo_url") @db.VarChar(255) | Logo地址 |
| sourceUrl | String @map("source_url") @db.VarChar(255) | 源码地址(GitHub) |
| sourceType | String @default("gitee") @db.VarChar(20) | 来源类型 |
| rating | Float @default(0) @db.Float | 综合评分 |
| features | Json? | 功能特性(forks/language/license/topics) |
| tags | Json? | 标签数组 |
| viewCount | Int @default(0) | 浏览次数 |
| status | Status | 状态 |
| lastReviewedAt | DateTime? | 最近评测时间 |
| githubStarsHistory | Json? | Star历史数据 |
| viewCount | Int @default(0) @map("view_count") | 浏览次数 |
| status | Status @default(draft) | 状态 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
| lastReviewedAt | DateTime? @map("last_reviewed_at") | 最近评测时间 |
| githubStarsHistory | Json? @map("github_stars_history") | Star历史数据(90天) |
| githubStars | Int @default(0) @map("github_stars") | 当前GitHub Stars |
| starsChangeDay | Int @default(0) @map("stars_change_day") | 日Star变化 |
| starsChangeWeek | Int @default(0) @map("stars_change_week") | 周Star变化 |
| starsChangeMonth | Int @default(0) @map("stars_change_month") | 月Star变化 |
| commentCount | Int @default(0) @map("comment_count") | 评论数 |
| avgRating | Float @default(0) @map("avg_rating") | 平均评分 |
**关系**: category, favorites, browsingHistory, reviews, collectionItems, skillReview, comments
### PendingSkill (待审核技能)
与Skill类似,但多个字段可为空。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| name | String @db.VarChar(100) | 技能名称 |
| slug | String? @db.VarChar(100) | URL标识 |
| categoryId | Int? @map("category_id") | 分类ID |
| description | String? @db.Text | 简介 |
| logoUrl | String? @map("logo_url") @db.VarChar(255) | Logo地址 |
| sourceUrl | String? @map("source_url") @db.VarChar(255) | 源码地址 |
| sourceType | String? @map("source_type") @db.VarChar(20) | 来源类型 |
| rating | Float @default(0) @db.Float | 综合评分 |
| features | Json? | 功能特性 |
| tags | Json? | 标签数组 |
| status | PendingStatus @default(pending) | 状态 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: category
### User (用户)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| email | String @unique @db.VarChar(255) | 邮箱 |
| passwordHash | String? @db.VarChar(255) | 密码哈希(bcrypt) |
| name | String? @db.VarChar(100) | 昵称 |
| avatarUrl | String? | 头像地址 |
| oauthProvider | String? @db.VarChar(50) | OAuth提供商(github) |
| oauthId | String? @unique @db.VarChar(255) | OAuth外部ID |
| role | String @default("user") @db.VarChar(20) | 角色字符串(向后兼容) |
| roleId | Int? @map("role_id") | 角色ID(关联Role表) |
| points | Int @default(0) | 积分 |
| level | UserLevel @default(BRONZE) | 用户等级 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: assignedRole, favorites, browsingHistory, reviews, collections, comments, commentLikes, notifications, forumTopics, forumPosts
### Role (角色)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| name | String @db.VarChar(50) | 角色名称 |
| slug | String @unique @db.VarChar(50) | 角色标识 |
| isSystem | Boolean @default(false) @map("is_system") | 是否系统角色(受保护) |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: permissions (RolePermission[]), users (User[])
### Permission (权限点)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| groupName | String @map("group_name") @db.VarChar(50) | 权限分组 |
| name | String @db.VarChar(50) | 权限名称 |
| key | String @unique @db.VarChar(80) | 权限键(唯一) |
| sortOrder | Int @default(0) @map("sort_order") | 排序权重 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
**关系**: rolePermissions
### RolePermission (角色-权限关联)
| 字段 | 类型 | 说明 |
|------|------|------|
| roleId | Int @map("role_id") | 角色ID(联合主键) |
| permissionId | Int @map("permission_id") | 权限ID(联合主键) |
**关系**: role, permission (onDelete: Cascade)
### SystemConfig (系统配置)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id | 主键 |
| key | String @unique | 配置键 |
| value | Json | 配置值(JSON) |
| id | Int @id @default(autoincrement()) | 主键 |
| key | String @unique @db.VarChar(50) | 配置键 |
| value | Json | 配置值(JSON) |
### SkillReview (技能评测)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| skillId | Int @unique | 关联Skill(一对一) |
| skillId | Int @unique @map("skill_id") | 关联Skill(一对一) |
| capability | Float | 场景与能力评分(1-5) |
| devExp | Float | 开发体验评分(1-5) |
| costLicense | Float | 成本与许可评分(1-5) |
| devExp | Float @map("dev_exp") | 开发体验评分(1-5) |
| costLicense | Float @map("cost_license") | 成本与许可评分(1-5) |
| community | Float | 社区健康评分(1-5) |
| performance | Float | 性能稳定评分(1-5) |
| overall | Float | 综合评分(五维均值) |
| reviews | Json | 完整评测数据(含各维度summary/detail) |
| longArticle | String? @db.Text | 长文评测 |
| videoScript | String? @db.Text | 视频脚本 |
| socialCards | Json? | 社交卡片数据 |
| overall | Float | 综合评分(五维均值) |
| reviews | Json | 完整评测数据(含各维度summary/detail) |
| longArticle | String? @map("long_article") @db.Text | 长文评测 |
| videoScript | String? @map("video_script") @db.Text | 视频脚本 |
| socialCards | Json? @map("social_cards") | 社交卡片数据 |
| status | ReviewStatus @default(DRAFT) | 评测状态 |
| reviewedAt | DateTime @updatedAt | 评测时间 |
| createdAt | DateTime | 创建时间 |
| reviewedAt | DateTime @updatedAt @map("reviewed_at") | 评测时间 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
**关系**: skill (一对一)
### ReviewGenerationLog (评测生成日志)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| skillId | Int | 关联Skill ID |
| skillId | Int @map("skill_id") | 关联Skill |
| status | String @db.VarChar(20) | SUCCESS/FAILED |
| prompt | String @db.Text | 生成提示词 |
| response | String? @db.Text | API响应内容 |
| response | String? @db.Text | API响应 |
| error | String? @db.Text | 错误信息 |
| createdAt | DateTime | 创建时间 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
### TaskConfig (任务配置)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| taskKey | String @unique @map("task_key") @db.VarChar(50) | 任务键 |
| taskName | String @map("task_name") @db.VarChar(100) | 任务名称 |
| description | String? @db.VarChar(255) | 任务描述 |
| enabled | Boolean @default(true) | 是否启用 |
| cronExpr | String? @map("cron_expr") @db.VarChar(50) | Cron表达式 |
| config | Json? | 任务配置(JSON) |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
### TaskLog (任务执行日志)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| taskKey | String @map("task_key") @db.VarChar(50) | 任务键 |
| taskName | String @map("task_name") @db.VarChar(100) | 任务名称 |
| status | String @db.VarChar(20) | 执行状态(completed/failed) |
| startedAt | DateTime @map("started_at") | 开始时间 |
| finishedAt | DateTime? @map("finished_at") | 结束时间 |
| duration | Int? | 执行时长(毫秒) |
| result | Json? | 执行结果(JSON) |
| error | String? @db.Text | 错误信息 |
| triggerBy | String @default("cron") @map("trigger_by") @db.VarChar(20) | 触发方式 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
### Favorite (收藏)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| userId | Int @map("user_id") | 用户ID |
| toolId | Int? @map("tool_id") | 工具ID |
| skillId | Int? @map("skill_id") | 技能ID |
| createdAt | DateTime @default(now()) @map("created_at") | 收藏时间 |
**关系**: user, tool, skill
**唯一约束**: [userId, toolId], [userId, skillId]
### BrowsingHistory (浏览历史)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| userId | Int @map("user_id") | 用户ID |
| toolId | Int? @map("tool_id") | 工具ID |
| skillId | Int? @map("skill_id") | 技能ID |
| viewedAt | DateTime @default(now()) @updatedAt @map("viewed_at") | 浏览时间 |
**关系**: user, tool, skill
### Review (评论)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| userId | Int @map("user_id") | 用户ID |
| toolId | Int? @map("tool_id") | 工具ID |
| skillId | Int? @map("skill_id") | 技能ID |
| rating | Int? | 评分(1-5) |
| comment | String @db.Text | 评论内容 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
**关系**: user, tool, skill
### Collection (收藏夹)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| userId | Int @map("user_id") | 用户ID |
| name | String @db.VarChar(50) | 收藏夹名称 |
| description | String? @db.Text | 描述 |
| sortOrder | Int @default(0) @map("sort_order") | 排序 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: user, items
### CollectionItem (收藏夹项目)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| collectionId | Int @map("collection_id") | 收藏夹ID |
| toolId | Int? @map("tool_id") | 工具ID |
| skillId | Int? @map("skill_id") | 技能ID |
| addedAt | DateTime @default(now()) @map("added_at") | 添加时间 |
**关系**: collection, tool, skill
### DailyReport (AI日报)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| date | DateTime @unique @db.Date | 日期(唯一) |
| title | String @db.VarChar(200) | 标题 |
| summary | String? @db.Text | 摘要 |
| hotCount | Int @default(0) @map("hot_count") | 热门条目数 |
| coverUrl | String? @map("cover_url") @db.VarChar(255) | 封面图 |
| status | Status @default(draft) | 状态 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: items
### DailyReportItem (日报条目)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| reportId | Int @map("report_id") | 日报ID |
| sortOrder | Int @default(0) @map("sort_order") | 排序 |
| category | String @default("tech") @db.VarChar(20) | 分类 |
| title | String @db.VarChar(200) | 标题 |
| summary | String? @db.Text | 摘要 |
| detail | String? @db.Text | 详情 |
| sourceUrl | String? @map("source_url") @db.VarChar(500) | 来源链接 |
| imageUrl | String? @map("image_url") @db.VarChar(255) | 图片地址 |
| hotScore | Int @default(0) @map("hot_score") | 热度评分 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
**关系**: report (onDelete: Cascade)
### Comment (评论/回复)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| userId | Int @map("user_id") | 用户ID |
| toolId | Int? @map("tool_id") | 工具ID |
| skillId | Int? @map("skill_id") | 技能ID |
| parentId | Int? @map("parent_id") | 父评论ID(回复) |
| content | String @db.Text | 评论内容 |
| rating | Int? | 评分(1-5) |
| likeCount | Int @default(0) @map("like_count") | 点赞数 |
| isPinned | Boolean @default(false) @map("is_pinned") | 是否置顶 |
| status | CommentStatus @default(ACTIVE) | 状态 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: user, tool, skill, parent/replies (自引用), likes
### CommentLike (评论点赞)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| commentId | Int @map("comment_id") | 评论ID |
| userId | Int @map("user_id") | 用户ID |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
**关系**: comment, user (onDelete: Cascade)
**唯一约束**: [commentId, userId]
### Notification (通知)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| userId | Int @map("user_id") | 用户ID |
| type | NotifType | 通知类型 |
| title | String @db.VarChar(100) | 标题 |
| content | String? @db.VarChar(255) | 内容 |
| link | String? @db.VarChar(255) | 跳转链接 |
| isRead | Boolean @default(false) @map("is_read") | 是否已读 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
**关系**: user
### ForumCategory (论坛板块)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| name | String @db.VarChar(30) | 板块名称 |
| slug | String @unique @db.VarChar(30) | URL标识 |
| description | String? @db.VarChar(100) | 描述 |
| icon | String? @db.VarChar(30) | 图标 |
| sortOrder | Int @default(0) @map("sort_order") | 排序 |
| topicCount | Int @default(0) @map("topic_count") | 话题数 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
**关系**: topics
### ForumTopic (论坛话题)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| categoryId | Int @map("category_id") | 板块ID |
| userId | Int @map("user_id") | 作者ID |
| title | String @db.VarChar(100) | 标题 |
| slug | String @unique @db.VarChar(120) | URL标识 |
| content | String @db.Text | 内容 |
| isPinned | Boolean @default(false) @map("is_pinned") | 是否置顶 |
| isLocked | Boolean @default(false) @map("is_locked") | 是否锁定 |
| viewCount | Int @default(0) @map("view_count") | 浏览数 |
| replyCount | Int @default(0) @map("reply_count") | 回复数 |
| lastReplyAt | DateTime? @map("last_reply_at") | 最后回复时间 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: category, user, posts
### ForumPost (论坛回复)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| topicId | Int @map("topic_id") | 话题ID |
| userId | Int @map("user_id") | 作者ID |
| content | String @db.Text | 回复内容 |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
| updatedAt | DateTime @updatedAt @map("updated_at") | 更新时间 |
**关系**: topic, user
## 枚举
- `PricingModel`: free, freemium, paid, subscription
- `Status`: draft, published
- `PendingStatus`: pending, approved, rejected
- `ReviewStatus`: DRAFT, PUBLISHED, REJECTED, OUTDATED
### PricingModel (定价模型)
- `free` — 免费
- `freemium` — 免费增值
- `paid` — 付费
- `subscription` — 订阅制
### Status (状态)
- `draft` — 草稿
- `published` — 已发布
### PendingStatus (待审核状态)
- `pending` — 待审核
- `approved` — 已通过
- `rejected` — 已拒绝
### ReviewStatus (评测状态)
- `DRAFT` — 草稿
- `PUBLISHED` — 已发布
- `REJECTED` — 已拒绝
- `OUTDATED` — 已过期
### UserLevel (用户等级)
- `BRONZE` — 青铜
- `SILVER` — 白银
- `GOLD` — 黄金
- `PLATINUM` — 铂金
### CommentStatus (评论状态)
- `ACTIVE` — 活跃
- `HIDDEN` — 隐藏
- `DELETED` — 已删除
### NotifType (通知类型)
- `COMMENT_REPLY` — 评论回复
- `COMMENT_LIKE` — 评论点赞
- `TOPIC_REPLY` — 话题回复
- `SYSTEM` — 系统通知
## 索引
- Tool: categoryId+status联合索引, name+description全文索引
- Skill: categoryId+status联合索引
- SkillReview: skillId索引(唯一), overall索引
- ReviewGenerationLog: skillId索引
| 模型 | 索引 | 类型 |
|------|------|------|
| Tool | [categoryId, status] | 联合索引 |
| Tool | [name, description] | 全文索引 |
| Skill | [categoryId, status] | 联合索引 |
| SkillReview | [skillId] | 唯一索引 |
| SkillReview | [overall] | 普通索引 |
| ReviewGenerationLog | [skillId] | 普通索引 |
| TaskLog | [taskKey, startedAt] | 联合索引 |
| TaskConfig | [taskKey] | 唯一索引 |
| Favorite | [userId] | 普通索引 |
| BrowsingHistory | [userId, viewedAt] | 联合索引 |
| Collection | [userId] | 普通索引 |
| DailyReport | [date] | 唯一索引 |
| DailyReport | [status] | 普通索引 |
| DailyReportItem | [reportId, sortOrder] | 联合索引 |
| Comment | [toolId, createdAt] | 联合索引 |
| Comment | [skillId, createdAt] | 联合索引 |
| Comment | [userId] | 普通索引 |
| Notification | [userId, isRead, createdAt] | 联合索引 |
| ForumTopic | [categoryId, lastReplyAt] | 联合索引 |
| ForumTopic | [userId] | 普通索引 |
| ForumTopic | [title] | 全文索引 |
| ForumPost | [topicId, createdAt] | 联合索引 |
| Permission | [groupName, sortOrder] | 联合索引 |
| User | [oauthProvider, oauthId] | 联合唯一 |
## 迁移历史
1. init - 初始化
2. remove_scraped_at - 移除scrapedAt字段
3. add_discovery_configs - 添加发现配置
4. fix_tag_pool_column - 修复标签池列
5. add_discovery_configs_v2 - 发现配置V2
6. add_api_providers_and_skills - 添加API提供者和技能
7. add_skill_categories - 添加技能分类
8. add_pendingtool_category_relation - 添加待审核工具分类关联
9. add_hot_score - 添加热度评分
10. add_skill_reviews - 添加技能评测表(SkillReview/ReviewGenerationLog)、Skill新增lastReviewedAt/githubStarsHistory字段
| # | 迁移名 | 日期 | 说明 |
|---|--------|------|------|
| 1 | init | 2026-04-28 | 初始化核心表(Category/Tool/PendingTool/SystemConfig/User) |
| 2 | remove_scraped_at | 2026-05-01 | 移除scrapedAt字段 |
| 3 | add_discovery_configs | 2026-05-01 | 添加发现配置 |
| 4 | fix_tag_pool_column | 2026-05-01 | 修复标签池列 |
| 5 | add_discovery_configs_v2 | 2026-05-02 | 发现配置V2 |
| 6 | add_api_providers_and_skills | 2026-05-02 | 添加API提供者和技能(Skill/PendingSkill) |
| 7 | add_skill_categories | 2026-05-02 | 添加技能分类(SkillCategory) |
| 8 | add_pendingtool_category_relation | 2026-05-02 | 添加PendingTool分类关联 |
| 9 | add_hot_score | 2026-05-08 | 添加热度评分(DailyReportItem.hotScore) |
| 10 | add_skill_reviews | 2026-05-20 | 添加技能评测表+Skill扩展字段 |
| 11 | add_task_system | 2026-05-24 | 添加任务调度系统+权限体系+社区功能 |
+84 -4
View File
@@ -35,14 +35,59 @@
### GET/POST /api/auth/[...nextauth]
NextAuth认证端点(登录/回调/CSRF等)。
### 用户相关API(需NextAuth用户认证)
### POST /api/auth/register
用户注册(邮箱+密码)。
### GET /api/categories/[slug]
获取分类详情。
### GET /api/daily-reports
获取AI日报列表。
### GET /api/recommendations
获取推荐内容。
### 评测榜单API(公开)
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/skill-reviews | GET | 获取评测列表(分页,仅PUBLISHED) |
| /api/skill-reviews/rankings | GET | 获取评测榜单:topByRating(评测分TOP10) + topByStarsChange(Stars涨幅周榜TOP10) |
---
## 用户相关API(需NextAuth用户认证)
### /api/user/profile
- GET: 获取当前用户资料
- PUT: 更新用户资料
### /api/user/favorites
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/user/favorites/status | GET | 获取收藏状态 |
| /api/user/favorites | GET | 获取当前用户收藏列表(工具+技能) |
| /api/user/favorites | POST | 添加收藏(body: { targetType, targetId }) |
| /api/user/favorites | DELETE | 取消收藏(body: { targetType, targetId }) |
### /api/user/history
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/user/history | GET | 获取浏览历史 |
| /api/user/history | POST | 记录浏览历史 |
### /api/user/collections
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/user/collections | GET | 获取收藏夹列表 |
| /api/user/collections | POST | 创建收藏夹 |
| /api/user/collections/[id] | GET | 获取单个收藏夹详情 |
| /api/user/collections/[id] | PUT | 更新收藏夹 |
| /api/user/collections/[id] | DELETE | 删除收藏夹 |
| /api/user/collections/[id]/items | GET | 获取收藏夹项目 |
| /api/user/collections/[id]/items | POST | 添加收藏夹项目 |
| /api/user/collections/[id]/items/[itemId] | PUT | 更新收藏夹项目 |
| /api/user/collections/[id]/items/[itemId] | DELETE | 删除收藏夹项目 |
---
@@ -112,11 +157,21 @@ NextAuth认证端点(登录/回调/CSRF等)。
|------|------|------|
| /api/admin/discover-tools | POST | 导入工具JSON数组(body: {tools: [...], categoryId}),自动去重导入待审核列表 |
| /api/admin/discover-skills | POST | 导入技能JSON数组(body: {skills: [...], categoryId}),自动去重导入待审核列表 |
| /api/admin/discover-skills/run | POST | 一键运行技能探索 |
| /api/admin/discover-news | POST | 导入新闻JSON数组(body: {items: [...]}),自动生成今日AI日报 |
| /api/admin/discover-news | GET | 获取今日AI日报 |
| /api/admin/check-tools | POST | 检测工具网站可访问性+抓取缺失Logo(body: {type: "published"/"pending"}),返回logoFetched/logoMethod/logoUrl |
| /api/admin/update-stars | POST | 更新GitHub Star数(body: {type: "published"/"pending"}) |
### 评测管理
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/admin/reviews | GET | 获取评测列表 |
| /api/admin/reviews/[id] | GET | 获取单个评测详情 |
| /api/admin/reviews/[id] | PUT | 更新评测状态 |
| /api/admin/reviews/[id]/generate | POST | AI生成评测内容(长文+视频脚本+社交卡片) |
### 系统配置
| 路由 | 方法 | 功能 |
@@ -124,12 +179,37 @@ NextAuth认证端点(登录/回调/CSRF等)。
| /api/admin/system-config | GET | 获取所有配置 |
| /api/admin/system-config | PUT | 批量更新配置(upsert) |
### 评论API(需NextAuth用户认证)
### 角色权限管理
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/reviews | GET | 获取评论列表(query: type, targetId) |
| /api/reviews | POST | 创建评论(body: { type, targetId, content, rating? }) |
| /api/admin/roles | GET | 获取所有角色列表 |
| /api/admin/roles | POST | 创建角色 |
| /api/admin/roles/[id] | PUT | 更新角色 |
| /api/admin/roles/[id] | DELETE | 删除角色 |
| /api/admin/permissions | GET | 获取全部权限点列表 |
| /api/admin/user-permissions | GET | 获取当前用户权限(roleId/userId/当前用户) |
### 日报管理
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/admin/daily-reports | GET | 获取日报列表 |
| /api/admin/daily-reports/[id] | GET | 获取单个日报详情 |
| /api/admin/daily-reports/[id] | PUT | 更新日报 |
| /api/admin/daily-reports/[id] | DELETE | 删除日报 |
| /api/admin/daily-reports/generate | POST | AI一键生成日报内容 |
### 任务调度API(需NextAuth认证)
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/admin/tasks/configs | GET | 获取全部6个任务配置(合并TaskConfig+默认值) |
| /api/admin/tasks/configs | PUT | 更新/创建任务配置(body: { taskKey, enabled?, cronExpr?, config? }) |
| /api/admin/tasks/logs | GET | 获取任务执行日志(分页,query: taskKey?, page, pageSize) |
| /api/admin/tasks/run | POST | 手动触发任务执行(body: { taskKey }),通过child_process.exec运行对应脚本 |
---
## SystemConfig 配置键
+69 -8
View File
@@ -4,38 +4,53 @@
### / (首页)
- 文件: `src/app/page.tsx`
- 组件: HomePageClient
- 功能: 搜索框、AI工具分类网格(一级+二级分类,左侧导航+标签切换)、技能分类网格、最新上架工具、热门工具、CTA区块
- 数据源: `/api/homepage`, `/api/categories`, `/api/skill-categories`, `/api/tools`
### /tools (工具列表)
- 文件: `src/app/tools/page.tsx`
- 功能: 分类筛选侧边栏(一级分类)、定价模型筛选、搜索、分页
- 组件: ToolsContent
- 功能: 分类筛选侧边栏(一级分类)、定价模型筛选、搜索、分页、卡片网格展示
- 数据源: `/api/categories`, `/api/tools`
### /tools/[slug] (工具详情)
- 文件: `src/app/tools/[slug]/page.tsx`
- 功能: 工具完整信息展示、浏览计数+1、收藏按钮、评论评价
- 功能: 工具完整信息展示、浏览计数+1、收藏按钮、评论评价(ReviewSection)
- 数据源: `/api/tools/[slug]`
### /categories/[slug] (分类详情)
- 文件: `src/app/categories/[slug]/page.tsx`
- 功能: 按分类slug展示该分类下的工具
- 组件: CategoryContent
- 功能: 按分类slug展示该分类下的工具,分类标题+工具网格
- 数据源: `/api/categories`, `/api/tools`
### /reviews (评测中心)
- 文件: `src/app/reviews/page.tsx`
- 组件: ReviewsList, SidebarRankings
- 功能: 评测列表展示(主栏)+ 侧边栏榜单(评测分榜TOP10 + Stars涨幅榜TOP10)
- 数据源: `/api/skill-reviews`, `/api/skill-reviews/rankings`
### /skills (技能库)
- 文件: `src/app/skills/page.tsx`
- 功能: 技能分类筛选(一级+二级分类,左侧导航+标签切换)、搜索
- 组件: SkillsPageClient
- 功能: 技能分类筛选(一级+二级分类,左侧导航+标签切换)、搜索、分页
- 数据源: `/api/skill-categories`, `/api/skills`
### /skills/[slug] (技能详情)
- 文件: `src/app/skills/[slug]/page.tsx`
- 功能: 技能完整信息展示、浏览计数+1、收藏按钮、五维评测雷达图(SkillReviewCard)、评论评价
- 功能: 技能完整信息展示、浏览计数+1、收藏按钮、五维评测雷达图(SkillReviewCard)、评论评价(ReviewSection)
- 数据源: `/api/skills/[slug]`, `/api/skills/[slug]/review`
### /daily (AI日报)
- 文件: `src/app/daily/page.tsx`
- 功能: 展示AI日报列表和详情
- 数据源: `/api/daily-reports`
### /login (用户登录)
- 文件: `src/app/login/page.tsx`
- 功能: 用户邮箱+密码登录/注册,GitHub OAuth登录
- 数据源: NextAuth Credentials Provider
- 数据源: NextAuth Credentials Provider, GitHub OAuth Provider
### /user (用户中心)
- 文件: `src/app/user/page.tsx`
@@ -47,18 +62,30 @@
- 功能: 收藏的工具和技能列表,支持筛选和搜索
- 数据源: `/api/user/favorites`
### /user/collections (收藏夹)
- 文件: `src/app/user/collections/page.tsx`
- 功能: 收藏夹列表管理
- 数据源: `/api/user/collections`
### /user/collections/[id] (收藏夹详情)
- 文件: `src/app/user/collections/[id]/page.tsx`
- 功能: 单个收藏夹内的工具/技能展示
- 数据源: `/api/user/collections/[id]`
---
## 后台页面
### /admin/login (登录)
- 文件: `src/app/(admin)/admin/login/page.tsx`
- 组件: LoginForm
- 功能: 管理员登录表单,CSRF token + 原生POST
- 凭证: admin@zhuiguang.ai / admin123
### /admin/pending (待审核工具)
- 文件: `src/app/(admin)/admin/pending/page.tsx`
- 功能: 审核列表表格、多选复选框、全选、批量通过/拒绝、抓取缺失Logo/重新抓取全部Logo、编辑弹窗、单个通过/拒绝
- 功能: 审核列表表格、多选复选框、全选、批量通过/拒绝、**一键通过全部合格**、抓取缺失Logo/重新抓取全部Logo、编辑弹窗、单个通过/拒绝
- 表格新增四项检查状态列:网站 ✓/✗ | Logo ✓/✗ | 描述 ✓/✗ | 分类 ✓/✗
- 数据源: `/api/admin/pending`, `/api/admin/pending/batch-publish`, `/api/admin/pending/batch-reject`, `/api/admin/pending/fetch-logos`
### /admin/pending-skills (待审核技能)
@@ -86,6 +113,17 @@
- 功能: 技能分类CRUD
- 数据源: `/api/admin/skill-categories`
### /admin/reviews (评测管理)
- 文件: `src/app/(admin)/admin/reviews/page.tsx`
- 组件: ReviewList, ReviewRow, ReviewStats
- 功能: 评测列表、统计卡片(已发布/草稿/平均评分)、单篇评测详情、状态管理
- 数据源: `/api/admin/reviews`, `/api/admin/reviews/[id]`
### /admin/reviews/[id] (评测详情)
- 文件: `src/app/(admin)/admin/reviews/[id]/page.tsx`
- 功能: 单篇评测详情展示、雷达图、生成内容(长文/视频脚本/社交卡片)
- 数据源: `/api/admin/reviews/[id]`
### /admin/discover-tools (探索AI工具)
- 文件: `src/app/(admin)/admin/discover-tools/page.tsx`
- 功能: 选分类→复制提示词→粘贴JSON→导入审核列表→抓取Logo
@@ -118,6 +156,26 @@
- 功能: Logo抓取配置、发现提示词配置等全局设置
- 数据源: `/api/admin/system-config`
### /admin/tasks (任务中心)
- 文件: `src/app/(admin)/admin/tasks/page.tsx`
- 功能: 6个定时任务总览卡片、各任务启用/禁用开关、手动运行按钮、最近执行状态、Cron表达式展示
- 数据源: `/api/admin/tasks/configs`, `/api/admin/tasks/run`
### /admin/tasks/logs (任务执行日志)
- 文件: `src/app/(admin)/admin/tasks/logs/page.tsx`
- 功能: 所有任务执行历史列表、按任务筛选、可展开查看result/error详情、分页
- 数据源: `/api/admin/tasks/logs`
### /admin/tasks/[taskKey] (单任务详情)
- 文件: `src/app/(admin)/admin/tasks/[taskKey]/page.tsx`
- 功能: 单任务统计卡片(成功率/失败率/平均耗时)、历史执行记录(可展开查看输出)、手动运行
- 数据源: `/api/admin/tasks/configs`, `/api/admin/tasks/logs`, `/api/admin/tasks/run`
### /admin/roles (角色权限管理)
- 文件: `src/app/(admin)/admin/roles/page.tsx`
- 功能: 左侧角色列表 + 右侧按分组的权限勾选网格、新建/删除自定义角色(系统角色受保护)、分组全选/取消全选、保存按钮实时同步到数据库
- 数据源: `/api/admin/roles`, `/api/admin/permissions`, `/api/admin/user-permissions`
---
## 布局
@@ -128,10 +186,13 @@
### 后台布局
- 文件: `src/app/(admin)/admin/layout.tsx`
- 结构: 左侧边栏(4组菜单) + 右侧内容区
- 结构: 左侧边栏(多组菜单) + 右侧内容区
- 菜单分组:
- 工具管理: 待审核工具、已发布工具、工具分类
- 技能库: 待审核技能、已发布技能、技能分类
- 评测管理: 评测管理
- AI发现: 探索AI工具、检测AI工具、探索Skill技能、更新Skill技能、AI新闻发现
- 系统配置: 系统配置
- 任务调度: 任务中心、任务日志
- 角色权限: 角色权限管理
- 登录页不显示侧边栏
@@ -6,10 +6,11 @@
#### Header (`src/components/layout/Header.tsx`)
- 前台顶部导航栏
- Logo + 导航链接(工具库/技能库/分类浏览) + 搜索框 + 登录/用户按钮
- Logo + 导航链接(工具库/技能库/评测/日报) + 搜索框 + 登录/用户按钮
- 移动端汉堡菜单
- 滚动时背景模糊效果(bg-white/80 backdrop-blur-2xl)
- 搜索框: bg-muted/50 focus:bg-white focus:ring-2 focus:ring-primary/10
- 容器宽度: max-w-[1440px]
#### Footer (`src/components/layout/Footer.tsx`)
- 前台底部页脚
@@ -23,11 +24,19 @@
### 公共组件
#### HeroBanner (`src/components/common/HeroBanner.tsx`)
- 首页横幅组件
- 渐变背景 + 渐变文字标题 + CTA按钮
#### LoginModal (`src/components/common/LoginModal.tsx`)
- 登录/注册弹窗,支持模式切换
- 邮箱+密码认证,注册可选填昵称
- bg-white/95 backdrop-blur-xl
#### LoginForm (`src/components/common/LoginForm.tsx`)
- 登录表单组件
- 邮箱+密码输入 + 提交按钮 + 错误提示
#### SkillReviewCard (五维评测雷达图)
- 文件: `src/components/common/SkillReviewCard.tsx`
- 用途: 技能详情页展示五维评测雷达图和维度详情卡片
@@ -38,10 +47,15 @@
- 评分色标: ≥4绿色(emerald) / ≥3黄色(amber) / <3红色(red)
- 设计规范: 遵循浅色主题设计系统,紫色(violet)主色调雷达图
### ReviewSection (`src/components/common/ReviewSection.tsx`)
#### ReviewSection (`src/components/common/ReviewSection.tsx`)
- 用户评论/评价组件,支持评分和文字评论
- 支持tool和skill两种类型
- bg-white/80卡片,bg-white/60评论项
- 支持嵌套回复、点赞、排序
#### CommentForm (`src/components/common/CommentForm.tsx`)
- 评论输入表单,支持评分选择
- 字数限制、评分验证
#### FavoriteButton (`src/components/common/FavoriteButton.tsx`)
- 收藏按钮,乐观更新
@@ -56,6 +70,7 @@
#### Button (`src/components/ui/button.tsx`)
- 6种变体: default, outline, secondary, ghost, destructive, link
- 4种尺寸: default, sm, lg, icon
- 基于 class-variance-authority
#### Card (`src/components/ui/card.tsx`)
- Card/CardHeader/CardTitle/CardDescription/CardContent/CardFooter
@@ -90,6 +105,13 @@
- 动画: fade-in, slide-up, float, glow-pulse
- 自定义字体: Geist Sans, Geist Mono
### Next.js配置 (`next.config.mjs`)
- 图片域名: remotePatterns 允许所有https域名
- 图片格式: avif, webp
- 缓存: 静态资源365天不可变,API安全头
- 安全头: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy
- Logo缓存: 开发环境no-cache,生产环境1天immutable
### 设计规范
- 圆角: 统一使用 rounded-xl (12px) 或 rounded-2xl (16px)
- 边框: border-border/40 (40%透明度)
+53 -25
View File
@@ -79,19 +79,6 @@ AI发现模块是追光AI的核心功能,采用"提示词+JSON粘贴导入"模
- `downloadLogo(url, filename): Promise<string>` — 下载Logo到 `/public/logos/` 目录
- 返回本地路径如 `/logos/filename.png`
## 脚本
### discover-skills.mjs
- 独立运行的AI技能发现脚本
- 遍历20个技能分类,每类调用DeepSeek API推荐10个GitHub开源项目
- 通过 `/api/admin/pending-skills/batch` 批量导入
- 每分类间隔3秒避免限流
- 自动排除已收录技能
### import-tools-batch1.mjs / import-tools-batch2.mjs
- 硬编码的AI工具数据导入脚本
- 通过 `/api/admin/pending/batch` 批量导入
## 自动化评测流程
### 架构概览
@@ -105,14 +92,6 @@ Prisma → Skill记录 + SkillReview记录 + ReviewGenerationLog日志
前端 → /api/skills/[slug]/review → SkillReviewCard雷达图展示
```
### 评测脚本 (scripts/auto-review.mjs)
- 搜索关键词: ai agent, llm framework, rag, prompt engineering
- 每日限制: 8个项目 (DAILY_LIMIT可配置)
- 最低Star数: 500
- 去重: 通过sourceUrl检查是否已存在
- 综合分校验: 自动计算五维均分,偏差>0.3时修正
- 评测状态: 初始为DRAFT,需手动或通过脚本改为PUBLISHED
### 五维评测维度
| 维度 | 字段 | 说明 |
|------|------|------|
@@ -125,8 +104,57 @@ Prisma → Skill记录 + SkillReview记录 + ReviewGenerationLog日志
### 评测标签分类
LLM框架, Agent框架, RAG, 提示工程, 多模态, 代码助手, 模型训练, 数据工程, 部署工具, 评测工具, 安全对齐, 其他
### 发布评测脚本 (scripts/publish-reviews.mjs)
- 将DRAFT状态评测改为PUBLISHED
- 同时将对应Skill状态从draft改为published
## 定时任务自动化(Task调度系统)
## 踩坑记录
### 架构概览
```
Crontab(6个定时) → 脚本执行(Node.js .mjs) → 外部API调用(GitHub/DeepSeek/网站)
↓
Prisma → Tool/Skill/PendingTool/PendingSkill/DailyReport 写入
↓
TaskLog表记录执行结果(status/duration/result/error)
↓
后台管理 /admin/tasks → 查看配置/状态/历史/手动触发
```
### 7个任务总览
| # | 任务 | 脚本 | Cron | 依赖外部API | 产量 |
|---|------|------|------|------------|------|
| Task1 | 发现AI工具 | task1-discover-tools.mjs | 06:00 | DeepSeek + RSS抓取 | 不定 |
| Task3 | 工具巡检 | task3-check-tools.mjs | 03:00 | 网站GET请求 | 全部Tool+PendingTool |
| Task4 | 发现AI技能 | daily-discover.mjs | 05:00 | GitHub API + DeepSeek | ≤30/天 (stars≥1000) |
| Task5 | 更新星值 | task5-update-stars.mjs | 01:00 | GitHub API | 全部GitHub技能 |
| Task6 | 评测热门 | task6-review-hot.mjs | 04:00 | GitHub API + DeepSeek | 2个/天 |
| - | AI日报 | daily-news.mjs | 07:30 | RSS多源 + DeepSeek | 1篇/天 |
### TaskLog 统一记录
所有任务脚本统一在末尾写入TaskLog:
```javascript
await prisma.taskLog.create({
data: { taskKey: "taskX-xxx", taskName: "任务名", status: "completed",
startedAt, finishedAt, duration, result, error }
});
```
失败时在 catch 块中写入 status: "failed" 的日志。
### 后台可视化
- `/admin/tasks` — 6个任务卡片,显示启用状态/Cron/最近执行/手动运行
- `/admin/tasks/logs` — 全部执行历史,按任务筛选,可展开查看结果详情
- `/admin/tasks/[taskKey]` — 单任务详情,统计成功率/失败率/平均耗时
## 质量过滤
### isValidAIProject()
- 关键词过滤: `book/guide/tutorial/awesome/interview/notes/handbook/cheatsheet/roadmap/curated-list` 等
- 语言过滤: 纯Markdown/HTML文档Repo(Stars<5000)自动拒绝
- 主题过滤: 有GitHub Topics的Repo必须包含AI相关Topic(ai/llm/rag/agent/gpt等)
## AI日报新闻源
- **36kr** RSS (https://36kr.com/feed) — 国内科技资讯
- **IT之家** RSS (https://www.ithome.com/rss/) — 国内科技资讯
- **开源中国** RSS (https://www.oschina.net/news/rss) — 开源动态
- **HackerNews** API — 海外AI/技术热点
- **Google News** RSS — 海外新闻(国内服务器可能无法访问)
- 流程: 搜索真实新闻 → AI关键词过滤 → 近2天筛选 → DeepSeek整理格式 → 入库发布
- **严禁让DeepSeek直接生成新闻内容**(会编造假新闻),必须基于真实新闻素材整理
+72 -16
View File
@@ -2,25 +2,45 @@
## 技术栈
- NextAuth.js v4
- Credentials Provider(用户名密码 + 用户邮箱密码)
- Credentials Provider(用户邮箱密码)
- GitHub OAuth Provider
- JWT Session策略
- bcrypt 密码加密
## 配置 (`src/lib/auth.ts`)
### 管理员认证
- 唯一凭证: `admin@zhuiguang.ai` / `admin123`
- 硬编码在auth.ts中,非数据库查询
### 用户认证
- 邮箱+密码注册/登录
- 用户数据存储在User表
- 密码bcrypt加密
- GitHub OAuth登录(自动创建/关联用户)
- 登录频率限制: 7次/分钟
### 登录流程
1. 用户输入邮箱+密码
2. 检查登录频率限制
3. 查询User表获取用户
4. bcrypt.compare验证密码
5. 返回用户信息(id, email, name, role)
6. JWT回调将用户信息加入token
7. Session回调将token信息传递给session
### GitHub OAuth流程
1. 用户点击GitHub登录
2. 重定向到GitHub授权
3. 回调获取profile信息
4. 检查oauthProvider+oauthId是否已存在
5. 不存在则创建新用户
6. 返回用户信息
### Session策略
- 使用JWT,不使用数据库session
- JWT密钥: `NEXTAUTH_SECRET` 环境变量
- Session有效期: 30天
- Token包含: id, role
### 回调函数
- `signIn`: GitHub OAuth自动创建用户
- `jwt`: 登录时将用户信息加入token
- `session`: 将token中的用户信息传递给session
@@ -28,31 +48,67 @@
### 保护规则
- 保护 `/admin` 下所有路由
- 保护 `/api/admin/:path*` 路由
- 保护 `/api/user/:path*` 路由
- 跳过 `/admin/login`
- 使用NextAuth middleware进行认证检查
### 登录流程
1. 访问 `/admin/*` → middleware检查session
2. 未登录 → 重定向到 `/admin/login`
3. 登录页提交表单 → Server Action验证凭证
4. 验证成功 → 重定向到 `/admin`
### 权限检查
- 检查token是否存在
- 检查role是否为admin或vip
- 未登录重定向到 /admin/login
- 无权限返回401/403
## 管理员API认证 (`src/lib/admin-auth.ts`)
### getAdminSession
- 获取当前会话
- 验证用户角色
- 支持role字符串和roleId双重验证
- 缓存机制(React cache)
### requireAdmin中间件
- 所有 `/api/admin/` 路由必须使用requireAdmin中间件
- 验证流程: 检查session → 验证邮箱是否为admin@zhuiguang.ai
- 已覆盖28个后台API端点
- 验证流程: 检查session → 验证角色
- 返回401/403错误响应
### getUserPermissionKeys
- 获取用户权限键集合
- 基于roleId关联Role表查询
- 缓存机制
### requirePermission
- 细粒度权限点校验
- 检查permissionKeys是否包含指定key
- 返回403如果缺少权限
### getAllUserPermissions
- 获取当前用户全部权限集合
- 支持admin角色(返回全部权限)
- 支持roleId关联角色
## 前端权限Hook (`src/lib/use-permission.ts`)
### usePermission
- 客户端Hook
- 获取当前用户权限
- 缓存机制
- 提供方法:
- `can(key)` — 检查单个权限
- `canAny(keys)` — 检查任一权限
- `canAll(keys)` — 检查全部权限
- `refresh` — 刷新权限
## 登录页
- 管理员登录: `src/app/(admin)/admin/login/page.tsx` (原生HTML表单 + CSRF Token)
- 用户登录: `src/app/login/page.tsx` (NextAuth Credentials + GitHub OAuth)
- 用户登录弹窗: `src/components/common/LoginModal.tsx`
- 登录表单: `src/components/common/LoginForm.tsx`
## 环境变量
```
NEXTAUTH_URL=http://localhost:3100
NEXTAUTH_SECRET=<secret>
GITHUB_ID=<github-oauth-id>
GITHUB_SECRET=<github-oauth-secret>
NEXTAUTH_URL=http://localhost:8301
NEXTAUTH_SECRET=zhuiguang-ai-dev-secret
GITHUB_CLIENT_ID=<github-oauth-client-id>
GITHUB_CLIENT_SECRET=<github-oauth-secret>
```
+158 -69
View File
@@ -2,103 +2,184 @@
## 工具库 (`src/lib/`)
### src/lib/github.ts (GitHub数据采集)
### prisma.ts - 数据库客户端
- Prisma单例模式,防止开发环境热重载创建多实例
- 使用PrismaMariaDb adapter
- 连接池配置(connection_limit=20, pool_timeout=30)
- 日志: 开发环境warn/error,生产环境error
### auth.ts - 认证配置
- NextAuth配置导出
- Credentials Provider(用户邮箱密码)
- GitHub OAuth Provider
- JWT策略
- 登录频率限制(7次/分钟)
- 导出GET/POST handler
### admin-auth.ts - 管理员API认证
- 导出 `getAdminSession()` — 获取当前会话(带缓存)
- 导出 `requireAdmin()` — 管理员权限验证中间件
- 导出 `requirePermission(key)` — 细粒度权限点校验
- 导出 `getAllUserPermissions()` — 获取当前用户全部权限
- 导出 `getUserPermissionKeys(userId)` — 获取用户权限键集合
- 验证session中角色和权限
- 返回401/403错误响应
### utils.ts - 工具函数
- `cn(...inputs)` — 合并clsx + tailwind-merge的className工具
- `extractJSON(str)` — JSON提取工具,支持markdown代码块包裹、花括号/方括号容错
### validation.ts - 数据验证
- Zod验证schemas
- 包含: registerSchema, loginSchema, paginationSchema, toolImportSchema, skillImportSchema, newsImportSchema, checkTypeSchema, profileUpdateSchema, favoriteSchema, historySchema, taskRunSchema, roleCreateSchema, categoryCreateSchema, skillCategoryCreateSchema, systemConfigUpdateSchema
### deepseek.ts - DeepSeek评测生成
- 依赖: openai (兼容OpenAI接口)
- 认证: process.env.DEEPSEEK_API_KEY
- baseURL: https://api.deepseek.com/v1
- 模型: deepseek-v4-pro
- `generateReview(repoName, description, stars, license, readme)`: 调用DeepSeek生成五维评测
- 综合分校验: 自动计算五维均分,偏差>0.3时修正
- JSON提取: 兼容markdown代码块包裹的响应
### github.ts - GitHub数据采集
- 依赖: octokit
- 认证: process.env.GITHUB_TOKEN (可选,提升限流60→5000次/小时)
- `searchHotRepos(query, minStars, maxResults)`: 搜索热门仓库,按Star降序
- `getRepoReadme(owner, repo)`: 获取仓库README内容(base64解码)
- `GitHubRepo` 接口: id, full_name, html_url, description, stargazers_count, forks_count, language, license, topics, updated_at, readme
### src/lib/deepseek.ts (DeepSeek评测生成)
- 依赖: openai (兼容OpenAI接口)
- 认证: process.env.DEEPSEEK_API_KEY
- baseURL: https://api.deepseek.com/v1
- 模型: deepseek-v4-pro
- `generateReview(repoName, description, stars, license, readme)`: 调用DeepSeek生成五维评测
- `ReviewDimension` 接口: { score, summary, detail }
- `ReviewResult` 接口: capability, devExp, costLicense, community, performance, overall, tags
- 综合分校验: 自动计算五维均分,偏差>0.3时修正
- JSON提取: 兼容markdown代码块包裹的响应
### prisma.ts - 数据库客户端
- Prisma单例模式,防止开发环境热重载创建多实例
- 使用PrismaMariaDb adapter
- 连接池配置
### auth.ts - 认证配置
- NextAuth配置导出
- Credentials Provider(管理员+用户)
- JWT策略
- GitHub OAuth Provider
- 导出GET/POST handler
### admin-auth.ts - 管理员API认证
- 导出 `requireAdmin()` 中间件函数
- 验证session中邮箱是否为admin@zhuiguang.ai
- 所有 `/api/admin/` 路由必须使用此中间件
- 返回401/403错误响应
### utils.ts - 工具函数
- `cn()`: 合并clsx + tailwind-merge的className工具
### logo-fetcher.ts - Logo抓取引擎
- 导出 `fetchLogo(websiteUrl, html?): Promise<string>` — 返回Logo URL
- 导出 `fetchLogoWithMethod(websiteUrl, html?): Promise<LogoFetchResult>` — 返回 `{url, method}`
- 10种抓取策略按优先级执行
- 支持从SystemConfig读取配置
- 每种成功方法返回中文名称(如"HTML Link图标"、"OG/Twitter图片"等)
- 详见 [05-ai-discovery.md](./05-ai-discovery.md)
- 每种成功方法返回中文名称
### logo-storage.ts - Logo本地存储
- 导出 `downloadLogo(url, filename): Promise<string>`
- 下载远程Logo到 `/public/logos/` 目录
- 返回本地路径如 `/logos/filename.png`
### rate-limit.ts - 频率限制
- `rateLimit(ip, maxRequests, windowMs)` — IP频率限制
- 内存存储(Map)
- 默认: 10次/分钟
### use-permission.ts - 前端权限Hook
- 客户端Hook
- 获取当前用户权限
- 提供 `can(key)`, `canAny(keys)`, `canAll(keys)` 方法
---
## 脚本 (`scripts/`)
### discover-skills.mjs - AI技能发现
- 独立运行的Node.js脚本
- 遍历20个技能分类,每类调用DeepSeek API推荐10个GitHub开源项目
- 通过 `/api/admin/pending-skills/batch` 批量导入
- 自动排除已收录技能
- 每分类间隔3秒
- 运行: `node scripts/discover-skills.mjs`
### 定时任务脚本(6个 + 1个日报,均含TaskLog记录)
### import-tools-batch1.mjs - 第一批工具导入
- 硬编码约80个国产AI工具
- 分类: 文本生成(165)/图像生成(174)/视频生成(183)/音频与语音(191)
#### task1-discover-tools.mjs — Task1 发现AI工具
- 每天06:00由Crontab触发
- 从36kr/IT之家/开源中国RSS抓取新闻,调用DeepSeek提取AI工具信息
- 去重后入库PendingTool,含checkWebsiteOk/checkLogoOk等巡检结果
- 结果写入TaskLog(taskKey: task1-discover-tools)
### import-tools-batch2.mjs - 第二批工具导入
- 硬编码约200个国产AI工具
- 分类: 代码开发/AI Agent/办公效率/设计创意/营销SEO/数据分析/教育学习/搜索信息/安全合规/医疗健康/金融法律/电商零售/3D游戏/科学研究/模型框架/聊天机器人
#### task3-check-tools.mjs — Task3 工具巡检
- 每天03:00由Crontab触发
- 检查所有已发布Tool网站可访问性+Logo有效性,更新websiteOk/logoOk字段
- 对PendingTool逐一检查,四项检查全部通过则自动发布为正式Tool
- 结果写入TaskLog(taskKey: task3-check-tools),含websiteFails/logoFails/autoPublished统计
#### task4 daily-discover.mjs — Task4 发现AI技能
- 每天05:00由Crontab触发
- GitHub搜索AI相关项目(stars≥1000,日上限30)
- DeepSeek预评测后入库**PendingSkill**(不是直接创建Skill)
- 去重检查: skill表 + pendingSkill表
- 仓库内容质量过滤(isValidAIProject): 排除文档/教程/面试题/书单类Repo
- 结果写入TaskLog(taskKey: task4-discover-skills),含discovered/skipped/reviewed/failed统计
#### task5-update-stars.mjs — Task5 更新技能星值
- 每天01:00由Crontab触发
- 查询所有sourceUrl含github.com的Skill
- 通过Octokit API获取最新stargazers_count
- 更新githubStars/starsChangeDay/starsChangeWeek/starsChangeMonth
- 维护githubStarsHistory数组(保留最近90天)
- 结果写入TaskLog(taskKey: task5-update-stars),含updated/failed/skipped统计
#### task6-review-hot.mjs — Task6 评测热门技能
- 每天04:00由Crontab触发
- 查找2个已发布但无SkillReview的最热门GitHub技能(按githubStars/viewCount排序)
- 获取GitHub README,调用DeepSeek API生成五维评测
- 创建SkillReview(status: PUBLISHED),更新Skill.lastReviewedAt
- 结果写入TaskLog(taskKey: task6-review-hot),含reviewed/failed统计
#### daily-news.mjs — AI日报生成
- 每天07:30由Crontab触发
- 多源搜索: 36kr RSS + IT之家 RSS + 开源中国 RSS + HackerNews API + Google News RSS
- 新闻过滤: 发布日期近2天内 + AI关键词匹配
- DeepSeek整理格式生成中文日报(标题+摘要+条目)
- 入库DailyReport表
- 结果写入TaskLog(taskKey: daily-news),含reportId/itemCount/title统计
#### seed-task-configs.mjs — 任务配置种子数据
- 一次性运行(幂等,创建或更新)
- 初始化6个TaskConfig记录(task1/3/4/5/6 + daily-news)
- 含默认cronExpr、enabled=true、description
### 其他脚本
#### auto-review.mjs (自动化评测脚本 — 已被Task4/Task6替代)
- 依赖: @prisma/client, @prisma/adapter-mariadb, octokit, openai
- 流程: GitHub搜索→README获取→DeepSeek评测→Skill入库→SkillReview入库→日志记录
- 配置: SEARCH_QUERIES(4个关键词), MIN_STARS(500→已被Task4提升为1000), DAILY_LIMIT(8→已有Task4用的30)
- 运行: `$env:NODE_TLS_REJECT_UNAUTHORIZED="0"; node scripts/auto-review.mjs`
#### publish-reviews.mjs (发布评测脚本)
- 将DRAFT状态SkillReview改为PUBLISHED
- 将对应draft状态Skill改为published
- 注: Task6-review-hot.mjs 创建的SkillReview直接为PUBLISHED,无需此脚本
#### generate-review-content.mjs (生成评测内容)
- 调用DeepSeek生成评测长文、视频脚本、社交卡片
- 用于已评测技能的内容生成
#### organize-content.mjs (内容整理脚本)
- 整理和归类内容
#### test-github.mjs (GitHub API测试)
- 测试searchHotRepos和getRepoReadme功能
- 运行: `$env:NODE_TLS_REJECT_UNAUTHORIZED="0"; node -r dotenv/config scripts/test-github.mjs`
#### test-deepseek.mjs (DeepSeek API测试)
- 测试generateReview功能,使用langflow作为测试用例
- 运行: `$env:NODE_TLS_REJECT_UNAUTHORIZED="0"; node scripts/test-deepseek.mjs`
#### test-content-generation.mjs (内容生成测试)
- 测试评测内容生成管线
#### debug-json.mjs (JSON解析调试)
- 调试JSON解析逻辑
#### delete-drafts.mjs (删除草稿)
- 批量删除草稿数据
#### cleanup-non-ai.mjs (清理非AI项目)
- 清理误入库的非AI技能数据
- 使用isValidAIProject过滤
#### db-status.mjs (数据库状态)
- 检查数据库连接和数据状态
#### seed-permissions.mjs (权限种子数据)
- 初始化全部权限点+角色
- 幂等运行
#### migrate-logos.mjs (Logo迁移)
- 迁移Logo到本地存储
---
## 运维脚本
### scripts/auto-review.mjs (自动化评测脚本)
- 依赖: @prisma/client, @prisma/adapter-mariadb, octokit, openai
- 流程: GitHub搜索→README获取→DeepSeek评测→Skill入库→SkillReview入库→日志记录
- 配置: SEARCH_QUERIES(4个关键词), MIN_STARS(500), MAX_PER_QUERY(5), DAILY_LIMIT(8)
- 去重: 通过sourceUrl检查
- slug生成: repo full_name转小写+连字符
- 默认分类ID: 120 (AI/ML工程)
- 运行: `$env:NODE_TLS_REJECT_UNAUTHORIZED="0"; node scripts/auto-review.mjs`
### scripts/publish-reviews.mjs (发布评测脚本)
- 将DRAFT状态SkillReview改为PUBLISHED
- 将对应draft状态Skill改为published
### scripts/test-github.mjs (GitHub API测试)
- 测试searchHotRepos和getRepoReadme功能
- 运行: `$env:NODE_TLS_REJECT_UNAUTHORIZED="0"; node -r dotenv/config scripts/test-github.mjs`
### scripts/test-deepseek.mjs (DeepSeek API测试)
- 测试generateReview功能,使用langflow作为测试用例
- 运行: `$env:NODE_TLS_REJECT_UNAUTHORIZED="0"; node scripts/test-deepseek.mjs`
### restart.ps1 - 服务重启
- PowerShell脚本
- 扫描8301-8310端口
@@ -108,3 +189,11 @@
### temp_clear.mjs - 临时清理
- 清空pending_tools表
### cron脚本 (shell)
- `cron-daily-discover.sh` — 每日技能发现
- `cron-daily-news.sh` — 每日新闻发现
- `cron-task1.sh` — Task1定时任务
### lib/retry.mjs
- 重试工具函数
+246
View File
@@ -0,0 +1,246 @@
# 社区功能模块
## 概述
社区功能模块包含评论系统、收藏系统、浏览历史、收藏夹、通知系统和论坛板块,为用户提供完整的互动体验。
## 数据模型
### PointsHistory (积分历史) 🆕
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Int @id @default(autoincrement()) | 主键 |
| userId | Int @map("user_id") | 用户ID |
| points | Int | 积分变动 |
| reason | String @db.VarChar(50) | 积分原因 |
| detail | String? @db.VarChar(200) | 详细信息 |
| refType | String? @map("ref_type") @db.VarChar(50) | 关联类型 |
| refId | Int? @map("ref_id") | 关联ID |
| createdAt | DateTime @default(now()) @map("created_at") | 创建时间 |
**关系**: user → User
**索引**: [userId, createdAt]
### Comment (评论/回复)
- 支持对Tool和Skill进行评论
- 支持嵌套回复(通过parentId自引用)
- 支持评分(1-5星)
- 支持点赞和置顶
**字段**: id, userId, toolId, skillId, parentId, content, rating, likeCount, isPinned, status, createdAt, updatedAt
**关系**:
- user: User — 用户评论
- tool: Tool — 工具评论
- skill: Skill — 技能评论
- parent/replies: Comment → Comment — 嵌套回复(自引用)
- likes: CommentLike[] — 点赞列表
### CommentLike (评论点赞) - 联合唯一约束 [commentId, userId]
### Notification (通知)
**通知类型**:
- COMMENT_REPLY — 评论回复通知
- COMMENT_LIKE — 评论点赞通知
- TOPIC_REPLY — 话题回复通知
- SYSTEM — 系统通知
### ForumCategory (论坛板块) - 二级分类结构 🆕
- **自引用关系**: parent/children (CategoryHierarchy)
- **6个父分类 + 24个子分类**
- **种子脚本**: `scripts/seed-forum-categories.mjs`, `scripts/fix-forum-categories.mjs`
**父分类**:
| 板块 | Slug | 图标 | 说明 |
|------|------|------|------|
| 综合讨论区 | general | 💬 | AI话题综合交流 |
| AI工具 | ai-tools | 🤖 | AI工具推荐、测评与讨论 |
| 开源技能 | skills | ⚡ | 开源AI框架、模型与工具技能 |
| 技术深度 | tech | 🔧 | AI技术深度探讨 |
| 教程与分享 | tutorials | 📚 | 教程、指南与经验分享 |
| 社区互动 | social | 🎪 | 社区活动与交流 |
**AI工具子分类**: 聊天写作/图像创作/视频音频/代码编程/效率办公/数据分析/教育学习
**开源技能子分类**: LLM框架/Agent框架/RAG技术/模型训练/部署推理/多模态/数据工程
**综合讨论子分类**: 新手入门/AI资讯/观点讨论
**技术深度子分类**: 算法原理/模型评测/安全对齐/硬件加速
**教程与分享子分类**: 入门教程/实战项目/最佳实践
**社区互动子分类**: 求助问答/求职招聘/闲聊灌水/反馈建议
### ForumTopic (论坛话题)
- 字段: categoryId, userId, title, slug, content, isPinned, isLocked, viewCount, replyCount, lastReplyAt
- 全文索引: [title],联合索引: [categoryId, lastReplyAt]
### ForumPost (论坛回复) 🆕
- 字段: topicId, userId, content, **isAnswer** (标记最佳回答), createdAt, updatedAt
- onDelete: Cascade(话题删除时自动清理回复)
### Favorite (收藏) - 联合唯一约束 [userId, toolId], [userId, skillId]
### BrowsingHistory (浏览历史) - 自动记录浏览的Tool和Skill
### Collection (收藏夹) / CollectionItem (收藏夹项目) - 用户可创建多个收藏夹
---
## 🆕 积分体系
### 积分规则
| 行为 | 积分 | 每日上限 |
|------|------|----------|
| 发表评论 | +5 | 50 |
| 评论被点赞 | +2 | 20 |
| 发布论坛话题 | +10 | 无限制 |
| 论坛回复 | +5 | 30 |
| 发表评价 | +8 | 20 |
| 每日登录 | +1 | 1 |
### 等级系统 - 20级成长体系 🆕 (类似QQ)
| 等级 | 最低积分 | 徽章 | 每日限额 |
|------|----------|------|----------|
| LV01 | 0 | ⭐ | 50 |
| LV02 | 10 | ⭐ | 80 |
| LV03 | 30 | ⭐⭐ | 100 |
| LV04 | 60 | ⭐⭐ | 120 |
| LV05 | 100 | ⭐⭐ | 150 |
| LV06 | 200 | ⭐⭐⭐ | 180 |
| LV07 | 400 | ⭐⭐⭐ | 200 |
| LV08 | 700 | ⭐⭐⭐ | 250 |
| LV09 | 1000 | ⭐⭐⭐ | 300 |
| LV10 | 1500 | 🌟 | 400 |
| LV11 | 2000 | 🌟 | 500 |
| LV12 | 3000 | 🌟 | 600 |
| LV13 | 4500 | 🌟 | 700 |
| LV14 | 6000 | 🌟🌟 | 800 |
| LV15 | 8000 | 🌟🌟 | 1000 |
| LV16 | 11000 | 🌟🌟 | 1200 |
| LV17 | 15000 | 🌟🌟 | 1500 |
| LV18 | 20000 | 🌟🌟🌟 | 1800 |
| LV19 | 30000 | 🌟🌟🌟 | 2000 |
| LV20 | 50000 | 🌟🌟🌟 | 3000 |
**旧等级迁移映射**: BRONZE→LV01, SILVER→LV03, GOLD→LV06, PLATINUM→LV10
### 积分奖励系统
- `src/lib/level-config.ts` — 等级配置(客户端安全,无Prisma依赖)
- `src/lib/points-reward.ts` — 积分奖励引擎(服务端,含Prisma操作)
- `rewardPoints()` — 自动发放积分 + 日限额检查
- `getUserStats()` — 获取用户综合统计(积分/评论数/话题数等)
- `getUserPointsHistory()` — 积分历史分页查询
---
## API接口
### 评论API (🆕 全新实现)
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/comments | GET | 获取评论列表(支持 toolId/skillId 筛选,含用户等级/积分信息) |
| /api/comments | POST | 创建评论(自动积分奖励,父评论作者自动通知) |
| /api/comments/[id]/like | POST | 评论点赞/取消点赞(切换模式,被赞者+2分,自动通知) |
### 论坛API (🆕 全新实现)
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/forum/categories | GET | 获取论坛板块列表 |
| /api/forum/topics | GET | 话题列表(按板块/搜索/排序:latest/hot/active) |
| /api/forum/topics | POST | 创建话题(+10分) |
| /api/forum/topics/[id] | GET | 话题详情(浏览数+1) |
| /api/forum/topics/[id] | PUT | 编辑话题(仅作者) |
| /api/forum/topics/[id] | DELETE | 删除话题(仅作者) |
| /api/forum/topics/[id]/posts | GET | 回复列表 |
| /api/forum/topics/[id]/posts | POST | 创建回复(+5分,话题作者自动通知) |
| /api/forum/topics/[id]/posts/[postId] | PUT | 编辑回复(仅作者) |
| /api/forum/topics/[id]/posts/[postId] | DELETE | 删除回复(仅作者) |
### 通知API (🆕 全新实现)
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/notifications | GET | 获取通知列表(含未读数) |
| /api/notifications | PUT | 标记通知为已读(支持指定ids或全部已读) |
### 用户积分API (🆕 全新实现)
| 路由 | 方法 | 功能 |
|------|------|------|
| /api/user/points?type=stats | GET | 获取用户综合统计(积分/评论/话题/评价数) |
| /api/user/points?type=history | GET | 获取积分历史(分页) |
---
## 🆕 组件
### 论坛组件
| 组件 | 文件 | 说明 |
|------|------|------|
| ForumBoard | `src/components/forum/ForumBoard.tsx` | 论坛板块卡片(图标/名称/话题数) |
| ForumTopicCard | `src/components/forum/ForumTopicCard.tsx` | 话题卡片(作者/时间/回复数/浏览数/等级徽章) |
| ForumEditor | `src/components/forum/ForumEditor.tsx` | 论坛编辑器(字数限制/登录检查/错误提示) |
| NotificationBell | `src/components/forum/NotificationBell.tsx` | 通知铃铛(红点/下拉面板/未读数) |
### 会员组件
| 组件 | 文件 | 说明 |
|------|------|------|
| MemberBadge | `src/components/common/MemberBadge.tsx` | 会员等级徽章(⭐🌟 + 积分显示,3种尺寸) |
| LevelProgress | `src/components/common/LevelProgress.tsx` | 等级进度条(当前/下一级/进度百分比/最高级提示,动态变色) |
| ShareButton | `src/components/common/ShareButton.tsx` | 分享按钮(支持原生分享/Twitter/QQ/复制链接,带成功反馈) |
### 评论组件
| 组件 | 文件 | 说明 |
|------|------|------|
| CommentCard | `src/components/common/CommentCard.tsx` | 评论卡片(用户信息/等级徽章/评分/点赞/回复/时间) |
| CommentThread | `src/components/common/CommentThread.tsx` | 评论列表(分页/点赞/嵌套回复/排序) |
---
## 🆕 页面
### /community (追光会 - 论坛首页)
- 板块网格(8个分类卡片)
- 最新话题列表(排序/分页)
- 活跃用户侧边栏
### /community/[category] (板块页面)
- 话题列表(按板块筛选)
- 排序切换(最新/热门/活跃)
- 分页导航
### /community/topic/[id] (话题详情)
- 话题完整内容展示
- 回复列表(作者标记)
- 回复表单(已登录可用)
- 锁定话题显示提示
### /user (🆕 更新的用户中心)
- 个人简介bio展示与编辑 🆕
- 积分等级进度条
- 积分明细记录
- 统计卡片(评论数/话题数/评价数/积分)
- 会员等级徽章
- 快捷入口(收藏/浏览工具/浏览技能)
- 最近浏览历史
### /user/[id] (🆕 会员个人空间)
- 用户资料展示(头像/昵称/简介/等级/积分)
- 统计面板(评论/话题/评价/积分)
- 发布的主题列表
- 评论历史列表
- 分享按钮(支持原生分享/Twitter/QQ/复制链接)
---
## 🔧 技术实现要点
### 积分自动奖励机制
所有评论/发帖/点赞操作自动通过 `rewardPoints()` 发放积分,包含:
- 每日上限检查(防刷分)
- 事务写入(积分变更 + 历史记录同时写入)
- 等级自动随积分变化更新
### 通知自动触发
以下操作自动生成通知:
- 回复评论 → 通知评论作者
- 点赞评论 → 通知评论作者
- 回复话题 → 通知话题作者
### 权限控制
- 所有写操作(评论/发帖/回复/点赞)需已登录
- 编辑/删除操作仅限作者本人
- 锁定话题禁止回复
+132
View File
@@ -0,0 +1,132 @@
# 运维与部署模块
## 服务器与基础设施
### 环境信息
- **域名**: www.zhuig.com (HTTPS,SSL证书自动续期)
- **云服务器**: 119.45.242.239 (登录名: ubuntu, 密钥: E:\ZG_Dev\pem\zg.pem)
- **数据库**: 阿里云RDS MySQL — rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com:3306
- **Redis**: Docker容器redis-cache:6379,有密码认证
- **开发机**: E:\ZG_Dev\ZhuiGuangAI\zhuiguang-ai
- **部署目录**: /home/ubuntu/zhuiguang-ai
- **访问地址**: https://www.zhuig.com (HTTP自动跳转HTTPS)
- **本地Git同步目录**: K:\git-repos
### 端口规范
- **应用端口范围**: 8301-8310(预留扩展)
- **默认端口**: 8301
- **重启脚本**: 仅扫描8301-8310范围
- **强制要求**: 本项目端口必须锁定在8301-8310区间内,禁止使用其他端口
## Nginx配置
- 文件: `nginx.conf`
- HTTPS反向代理到本地8301端口
- HTTP自动跳转HTTPS
- SSL证书自动续期
## 部署流程
### 本地开发
```bash
# 1. 安装依赖
npm install
# 2. 配置环境变量
# 编辑 .env 文件
# 3. 数据库迁移
npx prisma migrate dev
# 4. 启动开发服务器
npm run dev
# 或重启
pwsh -ExecutionPolicy Bypass -File restart.ps1
```
### 服务器部署
```bash
# 1. SSH到服务器
ssh -i E:\ZG_Dev\pem\zg.pem ubuntu@119.45.242.239
# 2. 进入部署目录
cd /home/ubuntu/zhuiguang-ai
# 3. 拉取最新代码
git pull
# 4. 安装依赖
npm install
# 5. 数据库迁移
npx prisma migrate deploy
# 6. 构建生产版本
npm run build
# 7. 重启服务
pm2 restart zhuiguang-ai
```
## 定时任务 (Crontab)
### Crontab配置
- 用户: ubuntu
- 查看: `crontab -l`
- 编辑: `crontab -e`
### 任务列表
| 时间 | 任务 | 脚本 | TaskKey | 说明 |
|------|------|------|---------|------|
| 01:00 | Task5 更新星值 | `scripts/task5-update-stars.mjs` | task5-update-stars | 更新所有GitHub技能Stars数+日/周/月变化 |
| 03:00 | Task3 工具巡检 | `scripts/task3-check-tools.mjs` | task3-check-tools | 检查网站/Logo可访问性+自动发布合格待审核工具 |
| 04:00 | Task6 评测热门 | `scripts/task6-review-hot.mjs` | task6-review-hot | 评测2个最热未评测GitHub技能(五维评测) |
| 05:00 | Task4 发现技能 | `scripts/daily-discover.mjs` | task4-discover-skills | GitHub搜索≥1000 stars项目→DeepSeek预评测→入库PendingSkill |
| 06:00 | Task1 发现工具 | `scripts/task1-discover-tools.mjs` | task1-discover-tools | 36kr/IT之家/开源中国RSS→DeepSeek提取工具→入库PendingTool |
| 07:30 | AI日报 | `scripts/daily-news.mjs` | daily-news | 多源搜索真实新闻+DeepSeek整理格式→发布日报 |
### 日志目录
- 路径: `/home/ubuntu/zhuiguang-ai/logs/`
- 文件: task1.log ~ task6.log
### 任务执行原理
1. Crontab定时触发脚本执行
2. 脚本调用外部API(GitHub/DeepSeek/网站)
3. 数据写入数据库(Tool/Skill/PendingTool/PendingSkill/DailyReport)
4. TaskLog表记录执行结果(status/duration/result/error)
5. 后台管理页面可视化管理(/admin/tasks)
6. 支持手动触发(POST /api/admin/tasks/run)
## 环境变量
### .env配置
```bash
# 数据库
DATABASE_URL="mysql://mohe001:***@rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com:3306/zhuiguang_ai?charset=utf8mb4"
# NextAuth
NEXTAUTH_URL="http://localhost:8301"
NEXTAUTH_SECRET="zhuiguang-ai-dev-secret"
# GitHub API(可选,提升限流)
GITHUB_TOKEN=""
# DeepSeek API(用于技能评测生成脚本、AI日报整理)
DEEPSEEK_API_KEY="sk-..."
```
## PM2配置
- 进程名: zhuiguang-ai
- 启动命令: `npm run start`
- 端口: 8301
- 重启: `pm2 restart zhuiguang-ai`
## 踩坑记录
- Next.js 14不支持useActionState,登录页用原生表单POST
- Prisma MariaDB adapter需要连接字符串方式初始化
- 图片域名需在next.config.mjs中配置remotePatterns
- 热重载后需清除.next缓存: 删除.next目录后重启
- Chart.js组件需用dynamic import + ssr: false,不支持服务端渲染
- Prisma 7.8需通过PrismaMariaDb适配器初始化,不能直接new PrismaClient()
- 服务器环境SSL证书问题: 运行脚本需设置$env:NODE_TLS_REJECT_UNAUTHORIZED="0"
- Chart.js font.weight只接受bold/normal/bolder/lighter,不接受数字字符串如"500"
+103 -4
View File
@@ -13,11 +13,13 @@
"@prisma/client": "^7.8.0",
"@radix-ui/react-dialog": "^1.1.15",
"@types/bcryptjs": "^2.4.6",
"@types/ioredis": "^4.28.10",
"axios": "^1.15.2",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ioredis": "^5.10.1",
"lucide-react": "^1.11.0",
"next": "14.2.35",
"next-auth": "^4.24.14",
@@ -29,7 +31,8 @@
"react-dom": "^18",
"shadcn": "^4.5.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20",
@@ -887,6 +890,12 @@
}
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2285,6 +2294,15 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/ioredis": {
"version": "4.28.10",
"resolved": "https://registry.npmmirror.com/@types/ioredis/-/ioredis-4.28.10.tgz",
"integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.39.tgz",
@@ -3023,6 +3041,15 @@
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/code-block-writer": {
"version": "13.0.3",
"resolved": "https://registry.npmmirror.com/code-block-writer/-/code-block-writer-13.0.3.tgz",
@@ -4365,6 +4392,30 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmmirror.com/ioredis/-/ioredis-5.10.1.tgz",
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz",
@@ -4741,6 +4792,18 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-6.0.0.tgz",
@@ -6312,6 +6375,27 @@
"node": ">= 4"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/remeda": {
"version": "2.33.4",
"resolved": "https://registry.npmmirror.com/remeda/-/remeda-2.33.4.tgz",
@@ -6631,6 +6715,15 @@
"node": ">=4"
}
},
"node_modules/shadcn/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -6785,6 +6878,12 @@
"node": ">= 0.6"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
@@ -7730,9 +7829,9 @@
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
+4 -1
View File
@@ -14,11 +14,13 @@
"@prisma/client": "^7.8.0",
"@radix-ui/react-dialog": "^1.1.15",
"@types/bcryptjs": "^2.4.6",
"@types/ioredis": "^4.28.10",
"axios": "^1.15.2",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ioredis": "^5.10.1",
"lucide-react": "^1.11.0",
"next": "14.2.35",
"next-auth": "^4.24.14",
@@ -30,7 +32,8 @@
"react-dom": "^18",
"shadcn": "^4.5.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20",
@@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE `daily_reports` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`date` DATE NOT NULL,
`title` VARCHAR(200) NOT NULL,
`summary` TEXT NULL,
`hot_count` INTEGER NOT NULL DEFAULT 0,
`cover_url` VARCHAR(255) NULL,
`status` ENUM('draft', 'published') NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `daily_reports_date_key`(`date`),
INDEX `daily_reports_status_idx`(`status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `daily_report_items` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`report_id` INTEGER NOT NULL,
`sort_order` INTEGER NOT NULL DEFAULT 0,
`category` VARCHAR(20) NOT NULL DEFAULT 'tech',
`title` VARCHAR(200) NOT NULL,
`summary` TEXT NULL,
`detail` TEXT NULL,
`source_url` VARCHAR(500) NULL,
`image_url` VARCHAR(255) NULL,
`hot_score` INTEGER NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `daily_report_items_report_id_sort_order_idx`(`report_id`, `sort_order`),
PRIMARY KEY (`id`),
CONSTRAINT `daily_report_items_report_id_fkey` FOREIGN KEY (`report_id`) REFERENCES `daily_reports`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -1 +0,0 @@
ALTER TABLE `daily_report_items` ADD COLUMN `hot_score` INT NOT NULL DEFAULT 0;
@@ -0,0 +1,16 @@
-- AlterEnum: Map old enum values to new 20-level system
-- Step 1: Change the column to VARCHAR temporarily
ALTER TABLE `users` MODIFY COLUMN `level` VARCHAR(20) NOT NULL DEFAULT 'LV01';
-- Step 2: Map old enum values to new ones
UPDATE `users` SET `level` = 'LV01' WHERE `level` = 'BRONZE';
UPDATE `users` SET `level` = 'LV03' WHERE `level` = 'SILVER';
UPDATE `users` SET `level` = 'LV06' WHERE `level` = 'GOLD';
UPDATE `users` SET `level` = 'LV10' WHERE `level` = 'PLATINUM';
-- AlterTable: Add bio field
ALTER TABLE `users` ADD COLUMN `bio` TEXT NULL;
-- AlterTable: Add parentId for ForumCategory hierarchy
ALTER TABLE `forum_categories` ADD COLUMN `parent_id` INT NULL;
ALTER TABLE `forum_categories` ADD CONSTRAINT `forum_categories_parent_id_fkey` FOREIGN KEY (`parent_id`) REFERENCES `forum_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
+224 -1
View File
@@ -55,7 +55,12 @@ model Tool {
features Json?
tags Json?
viewCount Int @default(0) @map("view_count")
commentCount Int @default(0) @map("comment_count")
avgRating Float @default(0) @map("avg_rating")
status Status @default(draft)
lastCheckedAt DateTime? @map("last_checked_at")
websiteOk Boolean @default(true) @map("website_ok")
logoOk Boolean @default(true) @map("logo_ok")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -64,6 +69,7 @@ model Tool {
browsingHistory BrowsingHistory[]
reviews Review[]
collectionItems CollectionItem[]
comments Comment[]
@@index([categoryId, status])
@@fulltext([name, description])
@@ -84,6 +90,11 @@ model PendingTool {
tags Json?
sourceUrl String? @map("source_url") @db.VarChar(255)
status PendingStatus @default(pending)
checkWebsiteOk Boolean? @map("check_website_ok")
checkLogoOk Boolean? @map("check_logo_ok")
checkDescOk Boolean? @map("check_desc_ok")
checkCategoryOk Boolean? @map("check_category_ok")
checkDetail Json? @map("check_detail")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -115,9 +126,16 @@ model Skill {
reviews Review[]
collectionItems CollectionItem[]
skillReview SkillReview?
comments Comment[]
lastReviewedAt DateTime? @map("last_reviewed_at")
githubStarsHistory Json? @map("github_stars_history")
githubStars Int @default(0) @map("github_stars")
starsChangeDay Int @default(0) @map("stars_change_day")
starsChangeWeek Int @default(0) @map("stars_change_week")
starsChangeMonth Int @default(0) @map("stars_change_month")
commentCount Int @default(0) @map("comment_count")
avgRating Float @default(0) @map("avg_rating")
@@index([categoryId, status])
@@map("skills")
@@ -175,10 +193,13 @@ model User {
passwordHash String? @db.VarChar(255)
name String? @db.VarChar(100)
avatarUrl String?
bio String? @db.Text
oauthProvider String? @db.VarChar(50)
oauthId String? @unique @db.VarChar(255)
role String @default("user") @db.VarChar(20)
roleId Int? @map("role_id")
points Int @default(0)
level UserLevel @default(LV01)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -187,11 +208,55 @@ model User {
browsingHistory BrowsingHistory[]
reviews Review[]
collections Collection[]
comments Comment[]
commentLikes CommentLike[]
notifications Notification[]
forumTopics ForumTopic[]
forumPosts ForumPost[]
pointsHistory PointsHistory[]
@@unique([oauthProvider, oauthId])
@@map("users")
}
model PointsHistory {
id Int @id @default(autoincrement())
userId Int @map("user_id")
points Int
reason String @db.VarChar(50)
detail String? @db.VarChar(200)
refType String? @map("ref_type") @db.VarChar(50)
refId Int? @map("ref_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@map("points_history")
}
enum UserLevel {
LV01
LV02
LV03
LV04
LV05
LV06
LV07
LV08
LV09
LV10
LV11
LV12
LV13
LV14
LV15
LV16
LV17
LV18
LV19
LV20
}
model Role {
id Int @id @default(autoincrement())
name String @db.VarChar(50)
@@ -402,3 +467,161 @@ enum ReviewStatus {
OUTDATED
}
model TaskLog {
id Int @id @default(autoincrement())
taskKey String @map("task_key") @db.VarChar(50)
taskName String @map("task_name") @db.VarChar(100)
status String @db.VarChar(20)
startedAt DateTime @map("started_at")
finishedAt DateTime? @map("finished_at")
duration Int?
result Json?
error String? @db.Text
triggerBy String @default("cron") @map("trigger_by") @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at")
@@index([taskKey, startedAt])
@@map("task_logs")
}
model TaskConfig {
id Int @id @default(autoincrement())
taskKey String @unique @map("task_key") @db.VarChar(50)
taskName String @map("task_name") @db.VarChar(100)
description String? @db.VarChar(255)
enabled Boolean @default(true)
cronExpr String? @map("cron_expr") @db.VarChar(50)
config Json?
updatedAt DateTime @updatedAt @map("updated_at")
@@map("task_configs")
}
model Comment {
id Int @id @default(autoincrement())
userId Int @map("user_id")
toolId Int? @map("tool_id")
skillId Int? @map("skill_id")
parentId Int? @map("parent_id")
content String @db.Text
rating Int?
likeCount Int @default(0) @map("like_count")
isPinned Boolean @default(false) @map("is_pinned")
status CommentStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
tool Tool? @relation(fields: [toolId], references: [id], onDelete: Cascade)
skill Skill? @relation(fields: [skillId], references: [id], onDelete: Cascade)
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReplies")
likes CommentLike[]
@@index([toolId, createdAt])
@@index([skillId, createdAt])
@@index([userId])
@@map("comments")
}
enum CommentStatus {
ACTIVE
HIDDEN
DELETED
}
model CommentLike {
id Int @id @default(autoincrement())
commentId Int @map("comment_id")
userId Int @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([commentId, userId])
@@map("comment_likes")
}
model Notification {
id Int @id @default(autoincrement())
userId Int @map("user_id")
type NotifType
title String @db.VarChar(100)
content String? @db.VarChar(255)
link String? @db.VarChar(255)
isRead Boolean @default(false) @map("is_read")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, isRead, createdAt])
@@map("notifications")
}
enum NotifType {
COMMENT_REPLY
COMMENT_LIKE
TOPIC_REPLY
SYSTEM
}
model ForumCategory {
id Int @id @default(autoincrement())
name String @db.VarChar(30)
slug String @unique @db.VarChar(30)
description String? @db.VarChar(100)
icon String? @db.VarChar(30)
parentId Int? @map("parent_id")
sortOrder Int @default(0) @map("sort_order")
topicCount Int @default(0) @map("topic_count")
createdAt DateTime @default(now()) @map("created_at")
parent ForumCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children ForumCategory[] @relation("CategoryHierarchy")
topics ForumTopic[]
@@map("forum_categories")
}
model ForumTopic {
id Int @id @default(autoincrement())
categoryId Int @map("category_id")
userId Int @map("user_id")
title String @db.VarChar(100)
slug String @unique @db.VarChar(120)
content String @db.Text
isPinned Boolean @default(false) @map("is_pinned")
isLocked Boolean @default(false) @map("is_locked")
viewCount Int @default(0) @map("view_count")
replyCount Int @default(0) @map("reply_count")
lastReplyAt DateTime? @map("last_reply_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
category ForumCategory @relation(fields: [categoryId], references: [id])
user User @relation(fields: [userId], references: [id])
posts ForumPost[]
@@index([categoryId, lastReplyAt])
@@index([userId])
@@fulltext([title])
@@map("forum_topics")
}
model ForumPost {
id Int @id @default(autoincrement())
topicId Int @map("topic_id")
userId Int @map("user_id")
content String @db.Text
isAnswer Boolean @default(false) @map("is_answer")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
topic ForumTopic @relation(fields: [topicId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@index([topicId, createdAt])
@@map("forum_posts")
}
+26
View File
@@ -0,0 +1,26 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL;
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
async function main() {
const result = await prisma.$queryRawUnsafe("SELECT migration_name, finished_at FROM _prisma_migrations ORDER BY started_at");
console.log("Applied migrations:");
for (const row of result) {
console.log(` ${row.migration_name} (${row.finished_at})`);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
cd /home/ubuntu/zhuiguang-ai
export $(grep -v '^#' .env | xargs)
export NODE_TLS_REJECT_UNAUTHORIZED=0
node scripts/task1-discover-tools.mjs >> /home/ubuntu/zhuiguang-ai/logs/task1-discover-tools.log 2>&1
+68 -29
View File
@@ -3,6 +3,7 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { Octokit } from "octokit";
import OpenAI from "openai";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const sep = base.includes("?") ? "&" : "?";
@@ -165,13 +166,17 @@ function genSlug(name) {
async function searchRepos(query, minStars, max) {
const q = `${query} stars:>${minStars} pushed:>2024-01-01`;
const { data } = await octokit.rest.search.repos({ q, sort: "stars", order: "desc", per_page: max });
const { data } = await withRetry(async () => {
return await octokit.rest.search.repos({ q, sort: "stars", order: "desc", per_page: max });
}, { maxRetries: 3, label: `GitHub Search "${query}"` });
return data.items;
}
async function getReadme(owner, repo) {
try {
const { data } = await octokit.rest.repos.getReadme({ owner, repo });
const { data } = await withRetry(async () => {
return await octokit.rest.repos.getReadme({ owner, repo });
}, { maxRetries: 3, label: `GitHub README ${owner}/${repo}` });
return Buffer.from(data.content, "base64").toString("utf-8");
} catch { return ""; }
}
@@ -181,9 +186,11 @@ async function genReview(repoName, desc, stars, license, readme) {
.replace("{repoName}", repoName).replace("{description}", desc || "暂无描述")
.replace("{stars}", stars.toString()).replace("{license}", license || "未知")
.replace("{readmeSummary}", readme.substring(0, 1500));
const { choices } = await openai.chat.completions.create({
const { choices } = await withRetry(async () => {
return await openai.chat.completions.create({
model: "deepseek-v4-pro", messages: [{ role: "user", content: prompt }], temperature: 0.3,
});
}, { maxRetries: 3, label: "DeepSeek Review API" });
const content = choices[0]?.message?.content;
if (!content) throw new Error("Empty response");
let review = extractJSON(content);
@@ -199,15 +206,16 @@ async function genReview(repoName, desc, stars, license, readme) {
}
async function main() {
console.log(`🚀 [Daily Discover] ${new Date().toISOString().split("T")[0]} 开始...\n`);
const DAILY_LIMIT = 10;
const startTime = new Date();
console.log(`🚀 [Task4] ${new Date().toISOString().split("T")[0]} 发现新AI技能...\n`);
const DAILY_LIMIT = 30;
let discovered = 0, skipped = 0, reviewed = 0, failed = 0;
for (const query of SEARCH_QUERIES) {
if (discovered >= DAILY_LIMIT) break;
console.log(`🔍 "${query}"`);
let repos;
try { repos = await searchRepos(query, 500, 5); } catch (e) { console.error(` ❌ 搜索失败: ${e.message}`); failed++; continue; }
try { repos = await searchRepos(query, 1000, 5); } catch (e) { console.error(` ❌ 搜索失败: ${e.message}`); failed++; continue; }
for (const repo of repos) {
if (discovered >= DAILY_LIMIT) break;
@@ -225,36 +233,31 @@ async function main() {
const [owner, name] = repo.full_name.split("/");
const readme = await getReadme(owner, name);
let reviewData = null;
try { reviewData = await genReview(repo.full_name, repo.description || "", repo.stargazers_count, repo.license?.spdx_id || "Unknown", readme); console.log(` ✅ 评测: ${reviewData.overall}`); reviewed++; }
catch (e) { console.log(` ⚠️ 评测失败: ${e.message}`); }
try { reviewData = await genReview(repo.full_name, repo.description || "", repo.stargazers_count, repo.license?.spdx_id || "Unknown", readme); console.log(` ✅ 预评测: ${reviewData.overall}`); reviewed++; }
catch (e) { console.log(` ⚠️ 预评测失败: ${e.message}`); }
const categoryId = await guessCategory(repo.full_name, repo.description || "", reviewData?.tags || []);
const skill = await prisma.skill.create({
const pendingSkill = await prisma.pendingSkill.create({
data: {
name: repo.full_name.substring(0, 100), slug, categoryId,
description: repo.description || "", sourceUrl: repo.html_url, sourceType: "github",
rating: reviewData?.overall || 0,
features: { forks: repo.forks_count, language: repo.language, license: repo.license?.spdx_id || null, topics: repo.topics || [] },
tags: reviewData?.tags || [], status: "draft",
lastReviewedAt: reviewData ? new Date() : null,
githubStarsHistory: [{ date: new Date().toISOString().split("T")[0], stars: repo.stargazers_count }],
features: {
forks: repo.forks_count,
language: repo.language,
license: repo.license?.spdx_id || null,
topics: repo.topics || [],
stars: repo.stargazers_count,
reviewData: reviewData || null,
},
tags: reviewData?.tags || [],
status: "pending",
},
});
console.log(` ✅ Skill #${skill.id}`);
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: reviewData, status: "DRAFT",
},
});
}
console.log(` ✅ PendingSkill #${pendingSkill.id}`);
await prisma.reviewGenerationLog.create({
data: { skillId: skill.id, status: "SUCCESS", prompt: `daily-discover: ${repo.full_name}`, response: reviewData ? JSON.stringify(reviewData) : null },
data: { skillId: pendingSkill.id, status: "SUCCESS", prompt: `task4-discover: ${repo.full_name}`, response: reviewData ? JSON.stringify(reviewData) : null },
});
discovered++;
} catch (e) {
@@ -262,14 +265,50 @@ async function main() {
failed++;
try {
await prisma.reviewGenerationLog.create({
data: { skillId: 0, status: "FAILED", prompt: `daily-discover: ${repo.full_name}`, error: e.message?.substring(0, 500) || "Unknown" },
data: { skillId: 0, status: "FAILED", prompt: `task4-discover: ${repo.full_name}`, error: e.message?.substring(0, 500) || "Unknown" },
});
} catch {}
}
}
}
console.log(`\n🏁 [Daily Discover] 完成: 发现${discovered} / 跳过${skipped} / 评测${reviewed} / 失败${failed}`);
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
const result = { discovered, skipped, reviewed, failed, duration: `${(duration / 1000).toFixed(1)}s` };
console.log(`\n🏁 [Task4] 完成: 发现${discovered} / 跳过${skipped} / 评测${reviewed} / 失败${failed}`);
await prisma.taskLog.create({
data: {
taskKey: "task4-discover-skills",
taskName: "发现AI技能",
status: "completed",
startedAt: startTime,
finishedAt: endTime,
duration,
result,
error: failed > discovered * 2 ? `失败率过高: ${failed}/${discovered}` : null,
},
});
}
main().catch((e) => { console.error("💥 异常:", e); process.exit(1); }).finally(() => prisma.$disconnect());
main().catch(async (e) => {
console.error("💥 异常:", e);
if (prisma) {
try {
await prisma.taskLog.create({
data: {
taskKey: "task4-discover-skills",
taskName: "发现AI技能",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
duration: 0,
error: e.message,
},
});
} catch {}
await prisma.$disconnect();
}
process.exit(1);
}).finally(() => prisma.$disconnect());
+34 -4
View File
@@ -244,13 +244,16 @@ function extractJSON(str) {
}
async function generateDailyNews() {
const today = new Date();
const dateStr = today.toLocaleDateString("zh-CN", {
const now = new Date();
const cnOffset = 8 * 60 * 60 * 1000;
const cnTime = new Date(now.getTime() + cnOffset);
const dateStr = cnTime.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "Asia/Shanghai",
});
const dateISO = today.toISOString().split("T")[0];
const dateISO = cnTime.toISOString().split("T")[0];
console.log(`📰 开始生成 ${dateStr} 的AI日报...`);
@@ -344,10 +347,37 @@ async function generateDailyNews() {
}
async function main() {
const startTime = new Date();
try {
await generateDailyNews();
const report = await generateDailyNews();
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
await prisma.taskLog.create({
data: {
taskKey: "daily-news",
taskName: "AI日报生成",
status: "completed",
startedAt: startTime,
finishedAt: endTime,
duration,
result: { reportId: report.id, itemCount: report.items.length, title: report.title },
},
});
} catch (error) {
console.error("❌ 日报生成失败:", error.message);
try {
await prisma.taskLog.create({
data: {
taskKey: "daily-news",
taskName: "AI日报生成",
status: "failed",
startedAt: startTime,
finishedAt: new Date(),
duration: new Date().getTime() - startTime.getTime(),
error: error.message,
},
});
} catch {}
process.exit(1);
} finally {
await prisma.$disconnect();
+124
View File
@@ -0,0 +1,124 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL;
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const CATEGORIES = [
{
name: "综合讨论区", slug: "general",
children: [
{ name: "新手入门", slug: "newbie" },
{ name: "AI资讯", slug: "ai-news" },
{ name: "观点讨论", slug: "discuss" },
],
},
{
name: "AI工具", slug: "ai-tools",
children: [
{ name: "聊天写作", slug: "chat-writing" },
{ name: "图像创作", slug: "image-gen" },
{ name: "视频音频", slug: "video-audio" },
{ name: "代码编程", slug: "code-tools" },
{ name: "效率办公", slug: "productivity" },
{ name: "数据分析", slug: "data-analysis" },
{ name: "教育学习", slug: "edu-tools" },
],
},
{
name: "开源技能", slug: "skills",
children: [
{ name: "LLM框架", slug: "llm-framework" },
{ name: "Agent框架", slug: "agent-framework" },
{ name: "RAG技术", slug: "rag" },
{ name: "模型训练", slug: "model-training" },
{ name: "部署推理", slug: "deploy-infer" },
{ name: "多模态", slug: "multimodal" },
{ name: "数据工程", slug: "data-eng" },
],
},
{
name: "技术深度", slug: "tech",
children: [
{ name: "算法原理", slug: "algorithms" },
{ name: "模型评测", slug: "model-review" },
{ name: "安全对齐", slug: "safety" },
{ name: "硬件加速", slug: "hardware" },
],
},
{
name: "教程与分享", slug: "tutorials",
children: [
{ name: "入门教程", slug: "beginner-guide" },
{ name: "实战项目", slug: "projects" },
{ name: "最佳实践", slug: "best-practice" },
],
},
{
name: "社区互动", slug: "social",
children: [
{ name: "求助问答", slug: "qa" },
{ name: "求职招聘", slug: "jobs" },
{ name: "闲聊灌水", slug: "random" },
{ name: "反馈建议", slug: "feedback" },
],
},
];
async function main() {
console.log("Fixing forum category parent-child relationships...\n");
for (const cat of CATEGORIES) {
const parent = await prisma.forumCategory.findUnique({
where: { slug: cat.slug },
});
if (!parent) {
console.log(` ✗ Parent "${cat.name}" not found, skipping`);
continue;
}
console.log(` Parent: ${cat.name} (id=${parent.id})`);
for (const child of cat.children) {
const existing = await prisma.forumCategory.findUnique({
where: { slug: child.slug },
});
if (!existing) {
await prisma.forumCategory.create({
data: {
name: child.name,
slug: child.slug,
parentId: parent.id,
},
});
console.log(` + Created: ${child.name}`);
} else if (existing.parentId !== parent.id) {
await prisma.forumCategory.update({
where: { id: existing.id },
data: { parentId: parent.id },
});
console.log(` ✓ Fixed: ${child.name} (parentId: ${existing.parentId} → ${parent.id})`);
} else {
console.log(` ✓ OK: ${child.name}`);
}
}
console.log("");
}
console.log("Done! All category relationships are correct.");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+18
View File
@@ -0,0 +1,18 @@
async function withRetry(fn, { maxRetries = 3, baseDelay = 1000, label = "operation" } = {}) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt < maxRetries) {
const delay = baseDelay * Math.pow(2, attempt - 1);
console.warn(` ⚠ ${label} 第${attempt}次失败,${delay / 1000}s后重试...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
export { withRetry };
+28
View File
@@ -0,0 +1,28 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL;
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
async function main() {
console.log("Updating user levels from old enum to new...");
const result = await prisma.$executeRawUnsafe(
"UPDATE users SET level = 'LV01' WHERE level NOT REGEXP '^LV[0-9][0-9]$'"
);
console.log(`Updated: ${result} rows`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+93
View File
@@ -0,0 +1,93 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL;
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
async function main() {
try {
console.log('Starting manual migration...');
// Step 1: Change level column to VARCHAR
await prisma.$executeRawUnsafe(
"ALTER TABLE `users` MODIFY COLUMN `level` VARCHAR(20) NOT NULL DEFAULT 'LV01'"
);
console.log('✓ Step 1: level column changed to VARCHAR');
// Step 2: Map old enum values
await prisma.$executeRawUnsafe("UPDATE `users` SET `level` = 'LV01' WHERE `level` = 'BRONZE'");
await prisma.$executeRawUnsafe("UPDATE `users` SET `level` = 'LV03' WHERE `level` = 'SILVER'");
await prisma.$executeRawUnsafe("UPDATE `users` SET `level` = 'LV06' WHERE `level` = 'GOLD'");
await prisma.$executeRawUnsafe("UPDATE `users` SET `level` = 'LV10' WHERE `level` = 'PLATINUM'");
console.log('✓ Step 2: Old enum values mapped to new levels');
// Step 3: Add bio column (if not exists)
try {
await prisma.$executeRawUnsafe("ALTER TABLE `users` ADD COLUMN `bio` TEXT NULL");
console.log('✓ Step 3: bio column added');
} catch (e) {
console.log('✓ Step 3: bio column may already exist, skipping');
}
// Step 4: Add parent_id column (if not exists)
try {
await prisma.$executeRawUnsafe("ALTER TABLE `forum_categories` ADD COLUMN `parent_id` INT NULL");
console.log('✓ Step 4: parent_id column added');
} catch (e) {
console.log('✓ Step 4: parent_id column may already exist, skipping');
}
// Step 5: Add foreign key (if not exists)
try {
await prisma.$executeRawUnsafe(
"ALTER TABLE `forum_categories` ADD CONSTRAINT `forum_categories_parent_id_fkey` " +
"FOREIGN KEY (`parent_id`) REFERENCES `forum_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE"
);
console.log('✓ Step 5: Foreign key added');
} catch (e) {
console.log('✓ Step 5: Foreign key may already exist, skipping');
}
// Step 6: Add points_history table (if not exists)
try {
await prisma.$executeRawUnsafe(
`CREATE TABLE IF NOT EXISTS \`points_history\` (
\`id\` INT AUTO_INCREMENT PRIMARY KEY,
\`user_id\` INT NOT NULL,
\`points\` INT NOT NULL,
\`reason\` VARCHAR(50) NOT NULL,
\`detail\` VARCHAR(200) NULL,
\`ref_type\` VARCHAR(50) NULL,
\`ref_id\` INT NULL,
\`created_at\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX \`idx_user_created\` (\`user_id\`, \`created_at\`),
FOREIGN KEY (\`user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
);
console.log('✓ Step 6: points_history table created');
} catch (e) {
console.log('✓ Step 6: points_history table may already exist, skipping');
}
// Step 7: Add is_answer column to forum_posts (if not exists)
try {
await prisma.$executeRawUnsafe("ALTER TABLE `forum_posts` ADD COLUMN `is_answer` BOOLEAN NOT NULL DEFAULT false");
console.log('✓ Step 7: is_answer column added');
} catch (e) {
console.log('✓ Step 7: is_answer column may already exist, skipping');
}
console.log('\n✓ Migration completed successfully!');
} catch (e) {
console.error('Error:', e.message);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();
+130
View File
@@ -0,0 +1,130 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL;
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const CATEGORIES = [
{
name: "综合讨论区", slug: "general", icon: "💬", sortOrder: 1,
description: "AI话题综合交流",
children: [
{ name: "新手入门", slug: "newbie", icon: "🌱", description: "新人报到,快速上手" },
{ name: "AI资讯", slug: "ai-news", icon: "📰", description: "行业动态与新闻" },
{ name: "观点讨论", slug: "discuss", icon: "💡", description: "AI发展趋势讨论" },
],
},
{
name: "AI工具", slug: "ai-tools", icon: "🤖", sortOrder: 2,
description: "AI工具推荐、测评与讨论",
children: [
{ name: "聊天写作", slug: "chat-writing", icon: "✍️", description: "ChatGPT/Claude/文心一言等" },
{ name: "图像创作", slug: "image-gen", icon: "🎨", description: "Midjourney/Stable Diffusion等" },
{ name: "视频音频", slug: "video-audio", icon: "🎬", description: "Sora/Runway/Suno等" },
{ name: "代码编程", slug: "code-tools", icon: "💻", description: "GitHub Copilot/Cursor等" },
{ name: "效率办公", slug: "productivity", icon: "📊", description: "Notion AI/办公自动化等" },
{ name: "数据分析", slug: "data-analysis", icon: "📈", description: "商业智能与数据分析工具" },
{ name: "教育学习", slug: "edu-tools", icon: "📚", description: "在线教育与学习辅助" },
],
},
{
name: "开源技能", slug: "skills", icon: "⚡", sortOrder: 3,
description: "开源AI框架、模型与工具技能",
children: [
{ name: "LLM框架", slug: "llm-framework", icon: "🧠", description: "LangChain/LlamaIndex等" },
{ name: "Agent框架", slug: "agent-framework", icon: "🤖", description: "AutoGPT/CrewAI/MetaGPT等" },
{ name: "RAG技术", slug: "rag", icon: "📖", description: "向量数据库/检索增强生成" },
{ name: "模型训练", slug: "model-training", icon: "🔬", description: "LoRA/Fine-tune/PEFT等" },
{ name: "部署推理", slug: "deploy-infer", icon: "🚀", description: "vLLM/Ollama/TensorRT等" },
{ name: "多模态", slug: "multimodal", icon: "🎯", description: "视觉/语音/多模态技术" },
{ name: "数据工程", slug: "data-eng", icon: "🗃️", description: "数据清洗/标注/管理工具" },
],
},
{
name: "技术深度", slug: "tech", icon: "🔧", sortOrder: 4,
description: "AI技术深度探讨",
children: [
{ name: "算法原理", slug: "algorithms", icon: "🧮", description: "Transformer/注意力机制等" },
{ name: "模型评测", slug: "model-review", icon: "📋", description: "Benchmark/对比评测" },
{ name: "安全对齐", slug: "safety", icon: "🛡️", description: "RLHF/安全/伦理" },
{ name: "硬件加速", slug: "hardware", icon: "🔌", description: "GPU/TPU/NPU优化" },
],
},
{
name: "教程与分享", slug: "tutorials", icon: "📚", sortOrder: 5,
description: "教程、指南与经验分享",
children: [
{ name: "入门教程", slug: "beginner-guide", icon: "🌟", description: "从零开始学AI" },
{ name: "实战项目", slug: "projects", icon: "🏗️", description: "完整项目实战" },
{ name: "最佳实践", slug: "best-practice", icon: "✅", description: "经验分享与踩坑记录" },
],
},
{
name: "社区互动", slug: "social", icon: "🎪", sortOrder: 6,
description: "社区活动与交流",
children: [
{ name: "求助问答", slug: "qa", icon: "❓", description: "有问题来这里" },
{ name: "求职招聘", slug: "jobs", icon: "💼", description: "AI领域求职招聘" },
{ name: "闲聊灌水", slug: "random", icon: "🎲", description: "轻松交流区" },
{ name: "反馈建议", slug: "feedback", icon: "📢", description: "对追光AI的建议" },
],
},
];
async function main() {
console.log("Seeding forum categories (2-level)...");
for (const cat of CATEGORIES) {
let parent = await prisma.forumCategory.findUnique({
where: { slug: cat.slug },
include: { children: true },
});
if (!parent) {
parent = await prisma.forumCategory.create({
data: {
name: cat.name,
slug: cat.slug,
icon: cat.icon,
description: cat.description,
sortOrder: cat.sortOrder,
},
include: { children: true },
});
console.log(` + ${cat.name}`);
} else {
console.log(` ✓ ${cat.name} (exists, checking children)`);
}
const existingSlugs = new Set(parent.children.map(c => c.slug));
for (const child of cat.children) {
if (existingSlugs.has(child.slug)) {
console.log(` - ${child.name} (exists)`);
} else {
await prisma.forumCategory.create({
data: {
...child,
parentId: parent.id,
},
});
console.log(` + ${child.name}`);
}
}
}
console.log("Done!");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+109
View File
@@ -0,0 +1,109 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const TASK_DEFS = [
{
taskKey: "task1-discover-tools",
taskName: "发现AI工具",
enabled: true,
cronExpr: "0 6 * * *",
description: "每天早上6点从36kr/IT之家/开源中国RSS抓取新闻,由DeepSeek提取AI工具信息并入库待审核",
config: { sources: ["36kr", "IT之家", "开源中国"], model: "deepseek-v4-pro" },
},
{
taskKey: "task3-check-tools",
taskName: "工具巡检",
enabled: true,
cronExpr: "0 3 * * *",
description: "每天凌晨3点检查所有已发布工具的网站可访问性和Logo有效性,自动发布通过检查的待审核工具",
config: { checkWebsite: true, checkLogo: true, autoPublish: true },
},
{
taskKey: "task4-discover-skills",
taskName: "发现AI技能",
enabled: true,
cronExpr: "0 5 * * *",
description: "每天凌晨5点从GitHub搜索高星AI项目(stars≥1000),DeepSeek预评测后入库PendingSkill待审核",
config: { minStars: 1000, dailyLimit: 30, model: "deepseek-v4-pro" },
},
{
taskKey: "task5-update-stars",
taskName: "更新技能星值",
enabled: true,
cronExpr: "0 1 * * *",
description: "每天凌晨1点更新所有GitHub技能的最新Stars数,记录日/周/月变化趋势",
config: { updateAll: true, historyDays: 90 },
},
{
taskKey: "task6-review-hot",
taskName: "评测热门技能",
enabled: true,
cronExpr: "0 4 * * *",
description: "每天凌晨4点对2个最热门且未评测的GitHub技能进行五维深度评测,自动生成SkillReview",
config: { dailyLimit: 2, model: "deepseek-v4-pro" },
},
{
taskKey: "daily-news",
taskName: "AI日报生成",
enabled: true,
cronExpr: "30 7 * * *",
description: "每天早上7:30从多源RSS搜索真实AI新闻,由DeepSeek整理格式生成当日日报",
config: { sources: ["36kr", "IT之家", "开源中国", "GoogleNews", "HackerNews"], model: "deepseek-v4-pro" },
},
];
async function main() {
console.log("🌱 初始化任务配置种子数据...\n");
let created = 0;
let updated = 0;
for (const def of TASK_DEFS) {
const existing = await prisma.taskConfig.findUnique({
where: { taskKey: def.taskKey },
});
if (existing) {
await prisma.taskConfig.update({
where: { taskKey: def.taskKey },
data: {
taskName: def.taskName,
cronExpr: def.cronExpr,
description: def.description,
config: def.config,
},
});
updated++;
console.log(` 🔄 更新: ${def.taskName} (${def.taskKey})`);
} else {
await prisma.taskConfig.create({
data: {
taskKey: def.taskKey,
taskName: def.taskName,
enabled: def.enabled,
cronExpr: def.cronExpr,
description: def.description,
config: def.config,
},
});
created++;
console.log(` ✅ 创建: ${def.taskName} (${def.taskKey})`);
}
}
console.log(`\n🏁 完成: 创建${created}个, 更新${updated}个`);
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error("❌ 种子数据初始化失败:", e.message);
if (prisma) await prisma.$disconnect();
process.exit(1);
});
+231
View File
@@ -0,0 +1,231 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import OpenAI from "openai";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1" });
const EXTRACT_PROMPT = `你是一个AI工具信息提取助手。从以下科技新闻内容中,提取出现的AI工具/产品信息。只提取明确提到的AI软件工具、AI平台、AI产品,不要提取开源项目库(GitHub仓库)、不要提取硬件、芯片。每个工具返回以下信息(如果信息不足则跳过该工具):
新闻内容:
{content}
返回纯JSON数组:
[{
"name": "工具名称",
"description": "一句话简介(20字以内)",
"websiteUrl": "官网或产品主页URL",
"category": "分类关键词(只能从以下选一):文本生成/图像生成/视频生成/语音音频/代码编程/办公效率/营销创作/数据AI/教育学习/设计制作"
}]`;
function extractJSON(str) {
const trimmed = str.trim();
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
let jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : trimmed;
const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
if (arrayMatch) {
try { return JSON.parse(arrayMatch[0]); } catch {}
}
return JSON.parse(jsonStr);
}
async function fetchRSS(url) {
try {
const res = await withRetry(async () => {
return await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; ToolBot/1.0)" },
signal: AbortSignal.timeout(10000),
});
}, { maxRetries: 3, label: `RSS ${url}` });
if (!res.ok) return [];
const xml = await res.text();
const items = [];
const regex = /<item>([\s\S]*?)<\/item>/g;
let m;
while ((m = regex.exec(xml)) !== null) {
const b = m[1];
const t = (b.match(/<title><!\[CDATA\[([\s\S]*?)\]\]><\/title>/) || b.match(/<title>([\s\S]*?)<\/title>/))?.[1] || "";
const d = (b.match(/<description><!\[CDATA\[([\s\S]*?)\]\]><\/description>/) || b.match(/<description>([\s\S]*?)<\/description>/))?.[1] || "";
items.push({ title: t.trim(), content: d.replace(/<[^>]+>/g, "").trim().substring(0, 500) });
}
return items.slice(0, 20);
} catch { return []; }
}
async function checkWebsite(url) {
try {
const res = await withRetry(async () => {
return await fetch(url, { method: "GET", signal: AbortSignal.timeout(8000), redirect: "follow" });
}, { maxRetries: 2, label: `Website ${url}` });
return res.ok;
} catch { return false; }
}
async function checkLogo(logoUrl) {
if (!logoUrl) return false;
if (logoUrl.startsWith("/logos/") || logoUrl.startsWith("data:")) return true;
try {
const res = await withRetry(async () => {
return await fetch(logoUrl, { method: "HEAD", signal: AbortSignal.timeout(5000) });
}, { maxRetries: 2, label: `Logo ${logoUrl}` });
return res.ok;
} catch { return false; }
}
async function main() {
const startTime = new Date();
console.log(`🔍 [Task1] ${startTime.toISOString().split("T")[0]} 发现新AI工具...`);
const rssSources = [
{ url: "https://36kr.com/feed", name: "36kr" },
{ url: "https://www.ithome.com/rss/", name: "IT之家" },
{ url: "https://www.oschina.net/news/rss", name: "开源中国" },
];
let totalNews = 0;
let foundTools = 0;
let importedTools = 0;
const errors: string[] = [];
const existingNames = new Set([
...(await prisma.tool.findMany({ select: { name: true } })).map((t) => t.name.toLowerCase()),
...(await prisma.pendingTool.findMany({ select: { name: true } })).map((t) => t.name.toLowerCase()),
]);
const existingUrls = new Set([
...(await prisma.tool.findMany({ select: { websiteUrl: true } })).map((t) => t.websiteUrl || ""),
...(await prisma.pendingTool.findMany({ select: { websiteUrl: true } })).map((t) => t.websiteUrl || ""),
]);
const categoryMap: Record<string, number> = {};
const categories = await prisma.category.findMany({ select: { id: true, name: true, slug: true } });
for (const c of categories) {
const name = c.name.toLowerCase();
const slugLower = c.slug.toLowerCase();
if (name.includes("文本") || slugLower.includes("text")) categoryMap["文本生成"] = c.id;
else if (name.includes("图像") || slugLower.includes("image")) categoryMap["图像生成"] = c.id;
else if (name.includes("视频") || slugLower.includes("video")) categoryMap["视频生成"] = c.id;
else if (name.includes("语音") || name.includes("音频") || slugLower.includes("audio")) categoryMap["语音音频"] = c.id;
else if (name.includes("编程") || name.includes("代码") || slugLower.includes("code")) categoryMap["代码编程"] = c.id;
else if (name.includes("办公") || slugLower.includes("office")) categoryMap["办公效率"] = c.id;
else if (name.includes("营销") || slugLower.includes("marketing")) categoryMap["营销创作"] = c.id;
else if (name.includes("数据") || name.includes("AI") || slugLower.includes("data")) categoryMap["数据AI"] = c.id;
else if (name.includes("教育") || slugLower.includes("education")) categoryMap["教育学习"] = c.id;
else if (name.includes("设计") || slugLower.includes("design")) categoryMap["设计制作"] = c.id;
}
for (const src of rssSources) {
console.log(` 📡 ${src.name}...`);
const news = await fetchRSS(src.url);
totalNews += news.length;
console.log(` 获取 ${news.length} 条`);
for (const item of news) {
const text = `${item.title} ${item.content}`;
try {
const completion = await withRetry(async () => {
return await openai.chat.completions.create({
model: "deepseek-v4-pro",
messages: [{ role: "user", content: EXTRACT_PROMPT.replace("{content}", text.substring(0, 1500)) }],
temperature: 0.2,
});
}, { maxRetries: 3, label: "DeepSeek API" });
const content = completion.choices[0]?.message?.content;
if (!content) continue;
let tools;
try { tools = extractJSON(content); } catch { continue; }
if (!Array.isArray(tools)) continue;
foundTools += tools.length;
for (const tool of tools) {
if (!tool.name || !tool.websiteUrl) continue;
if (existingNames.has(tool.name.toLowerCase()) || existingUrls.has(tool.websiteUrl)) continue;
const websiteOk = await checkWebsite(tool.websiteUrl);
const logoOk = await checkLogo(tool.logoUrl || "");
const catId = categoryMap[tool.category] || categoryMap["数据AI"] || categories[0]?.id || 1;
await prisma.pendingTool.create({
data: {
name: tool.name,
description: tool.description || "",
websiteUrl: tool.websiteUrl,
logoUrl: tool.logoUrl,
categoryId: catId,
sourceUrl: "AI自动发现",
status: "pending",
checkWebsiteOk: websiteOk,
checkLogoOk: logoOk,
checkDescOk: true,
checkCategoryOk: true,
checkDetail: { source: src.name, extractedAt: new Date().toISOString() },
},
});
existingNames.add(tool.name.toLowerCase());
existingUrls.add(tool.websiteUrl);
importedTools++;
console.log(` ✅ ${tool.name} (网站:${websiteOk ? "OK" : "FAIL"}, Logo:${logoOk ? "OK" : "FAIL"})`);
}
} catch (e) {
errors.push(`${src.name}/${item.title}: ${e.message}`);
}
await new Promise((r) => setTimeout(r, 300));
}
}
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
const result = {
totalNews,
foundTools,
importedTools,
duration: `${(duration / 1000).toFixed(1)}s`,
};
console.log(`\n🏁 [Task1] 完成: 新闻${totalNews}条, 发现${foundTools}个, 入库${importedTools}个, 耗时${result.duration}`);
if (errors.length > 0) console.log(` 错误: ${errors.slice(0, 3).join("; ")}`);
await prisma.taskLog.create({
data: {
taskKey: "task1-discover-tools",
taskName: "发现AI工具",
status: errors.length > 0 && importedTools === 0 ? "failed" : "completed",
startedAt: startTime,
finishedAt: endTime,
duration,
result,
error: errors.length > 0 ? errors.slice(0, 5).join("\n") : null,
},
});
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error("❌ 任务一失败:", e.message);
if (prisma) {
try {
await prisma.taskLog.create({
data: {
taskKey: "task1-discover-tools",
taskName: "发现AI工具",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
duration: 0,
error: e.message,
},
});
} catch {}
await prisma.$disconnect();
}
process.exit(1);
});
+186
View File
@@ -0,0 +1,186 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
async function checkWebsite(url) {
try {
const res = await withRetry(async () => {
return await fetch(url, {
method: "GET",
signal: AbortSignal.timeout(8000),
redirect: "follow",
headers: { "User-Agent": "Mozilla/5.0 (compatible; CheckBot/1.0)" },
});
}, { maxRetries: 2, label: `Website ${url}` });
return res.status >= 200 && res.status < 400;
} catch {
return false;
}
}
async function checkLogo(logoUrl) {
if (!logoUrl) return false;
if (logoUrl.startsWith("/logos/")) return true;
if (logoUrl.startsWith("data:")) return true;
try {
const res = await withRetry(async () => {
return await fetch(logoUrl, { method: "HEAD", signal: AbortSignal.timeout(5000) });
}, { maxRetries: 2, label: `Logo ${logoUrl}` });
return res.ok;
} catch {
return false;
}
}
async function main() {
const startTime = new Date();
const now = new Date();
const cnOffset = 8 * 60 * 60 * 1000;
const cnTime = new Date(now.getTime() + cnOffset);
const today = cnTime.toISOString().split("T")[0];
console.log(`🔍 [Task3] ${today} 巡检已入库工具...`);
const tools = await prisma.tool.findMany({
select: { id: true, name: true, websiteUrl: true, logoUrl: true, status: true },
});
const pendingTools = await prisma.pendingTool.findMany({
where: { status: "pending" },
select: { id: true, name: true, websiteUrl: true, logoUrl: true, checkWebsiteOk: true, checkLogoOk: true },
});
console.log(` 已发布工具: ${tools.length}, 待审核: ${pendingTools.length}`);
let checked = 0;
let websiteFails = 0;
let logoFails = 0;
let autoPublished = 0;
const errors = [];
for (const tool of tools) {
try {
const websiteOk = await checkWebsite(tool.websiteUrl);
const logoOk = await checkLogo(tool.logoUrl);
await prisma.tool.update({
where: { id: tool.id },
data: { lastCheckedAt: new Date(), websiteOk, logoOk },
});
if (!websiteOk) websiteFails++;
if (!logoOk) logoFails++;
checked++;
} catch (e) {
errors.push(`Tool ${tool.name}: ${e.message}`);
}
if (checked % 10 === 0) {
console.log(` 进度: ${checked}/${tools.length}`);
}
}
console.log(`\n 已发布工具巡检完成: ${checked}个, 网站失效${websiteFails}个, Logo异常${logoFails}个`);
checked = 0;
for (const pt of pendingTools) {
try {
const websiteOk = pt.websiteUrl ? await checkWebsite(pt.websiteUrl) : false;
const logoOk = await checkLogo(pt.logoUrl);
await prisma.pendingTool.update({
where: { id: pt.id },
data: { checkWebsiteOk: websiteOk, checkLogoOk: logoOk, checkDescOk: true, checkCategoryOk: true },
});
if (websiteOk && logoOk) {
const slug = pt.slug || pt.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-" + Date.now().toString(36);
const existingSlug = await prisma.tool.findUnique({ where: { slug } });
if (!existingSlug) {
await prisma.tool.create({
data: {
name: pt.name,
slug,
categoryId: pt.categoryId || 1,
description: pt.description || "",
logoUrl: pt.logoUrl,
websiteUrl: pt.websiteUrl || "",
pricingModel: pt.pricingModel || "freemium",
pricingDetail: pt.pricingDetail,
features: pt.features,
tags: pt.tags,
status: "published",
websiteOk: true,
logoOk: true,
},
});
await prisma.pendingTool.delete({ where: { id: pt.id } });
autoPublished++;
console.log(` ✅ 自动发布: ${pt.name}`);
}
}
checked++;
} catch (e) {
errors.push(`PendingTool ${pt.name}: ${e.message}`);
}
if (checked % 5 === 0) {
console.log(` 待审核进度: ${checked}/${pendingTools.length}`);
}
}
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
const result = {
totalTools: tools.length,
totalPending: pendingTools.length,
websiteFails,
logoFails,
autoPublished,
duration: `${(duration / 1000).toFixed(1)}s`,
};
console.log(`\n🏁 [Task3] 完成: 巡检${tools.length + pendingTools.length}个, 自动发布${autoPublished}个, 网站失效${websiteFails}个`);
await prisma.taskLog.create({
data: {
taskKey: "task3-check-tools",
taskName: "工具巡检",
status: errors.length > 0 ? "completed" : "completed",
startedAt: startTime,
finishedAt: endTime,
duration,
result,
error: errors.length > 0 ? errors.slice(0, 5).join("\n") : null,
},
});
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error("❌ 任务三失败:", e.message);
if (prisma) {
try {
await prisma.taskLog.create({
data: {
taskKey: "task3-check-tools",
taskName: "工具巡检",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
duration: 0,
error: e.message,
},
});
} catch {}
await prisma.$disconnect();
}
process.exit(1);
});
+148
View File
@@ -0,0 +1,148 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { Octokit } from "octokit";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
function extractOwnerRepo(sourceUrl) {
const match = sourceUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
if (!match) return null;
return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
}
async function main() {
const startTime = new Date();
const now = new Date();
const cnOffset = 8 * 60 * 60 * 1000;
const cnTime = new Date(now.getTime() + cnOffset);
const today = cnTime.toISOString().split("T")[0];
console.log(`📈 [Task5] ${today} 更新GitHub星值...`);
const skills = await prisma.skill.findMany({
where: { sourceUrl: { contains: "github.com" } },
select: { id: true, name: true, sourceUrl: true, githubStars: true, githubStarsHistory: true },
});
console.log(` 共 ${skills.length} 个GitHub技能需要更新`);
let updated = 0;
let failed = 0;
let skipped = 0;
const errors = [];
for (const skill of skills) {
const parsed = extractOwnerRepo(skill.sourceUrl);
if (!parsed) {
skipped++;
console.log(` ⏭️ ${skill.name} - 无法解析GitHub URL`);
continue;
}
try {
const { data } = await octokit.rest.repos.get({ owner: parsed.owner, repo: parsed.repo });
const currentStars = data.stargazers_count;
const history = Array.isArray(skill.githubStarsHistory) ? [...skill.githubStarsHistory] : [];
const prevEntry = history.length > 0 ? history[history.length - 1] : null;
const prevStars = prevEntry ? prevEntry.stars : currentStars;
history.push({ date: today, stars: currentStars });
if (history.length > 90) history.splice(0, history.length - 90);
const dayChange = currentStars - prevStars;
const weekAgo = history.find((h) => {
const d = new Date(h.date);
const diff = (cnTime.getTime() - d.getTime()) / (24 * 60 * 60 * 1000);
return diff >= 6 && diff <= 8;
});
const weekChange = weekAgo ? currentStars - weekAgo.stars : dayChange * 7;
const monthAgo = history.find((h) => {
const d = new Date(h.date);
const diff = (cnTime.getTime() - d.getTime()) / (24 * 60 * 60 * 1000);
return diff >= 28 && diff <= 32;
});
const monthChange = monthAgo ? currentStars - monthAgo.stars : weekChange * 4;
await prisma.skill.update({
where: { id: skill.id },
data: {
githubStars: currentStars,
starsChangeDay: dayChange,
starsChangeWeek: weekChange,
starsChangeMonth: monthChange,
githubStarsHistory: history,
},
});
updated++;
const arrow = dayChange >= 0 ? "↑" : "↓";
console.log(` ✅ ${skill.name}: ${currentStars}⭐ (${arrow}${Math.abs(dayChange)}/日)`);
} catch (e) {
failed++;
errors.push(`${skill.name}: ${e.message}`);
console.log(` ❌ ${skill.name}: ${e.message}`);
}
if (updated % 5 === 0) {
await new Promise((r) => setTimeout(r, 1000));
}
}
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
const result = {
total: skills.length,
updated,
failed,
skipped,
duration: `${(duration / 1000).toFixed(1)}s`,
};
console.log(`\n🏁 [Task5] 完成: 更新${updated} / 失败${failed}`);
await prisma.taskLog.create({
data: {
taskKey: "task5-update-stars",
taskName: "更新技能星值",
status: failed > skills.length * 0.5 ? "failed" : "completed",
startedAt: startTime,
finishedAt: endTime,
duration,
result,
error: errors.length > 0 ? errors.slice(0, 5).join("\n") : null,
},
});
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error("❌ 任务五失败:", e.message);
if (prisma) {
try {
await prisma.taskLog.create({
data: {
taskKey: "task5-update-stars",
taskName: "更新技能星值",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
duration: 0,
error: e.message,
},
});
} catch {}
await prisma.$disconnect();
}
process.exit(1);
});
+228
View File
@@ -0,0 +1,228 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { Octokit } from "octokit";
import OpenAI from "openai";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1" });
const REVIEW_PROMPT = `你是AI技术资深评测师。请对以下AI开源项目进行五维评测,每个维度1-5分。
评测维度:
- capability:场景与能力(功能覆盖度、场景适配性、输出质量)
- devExp:开发体验(安装便捷度、文档质量、API设计、调试友好度)
- costLicense:成本与许可(开源协议友好度、部署成本、资源消耗)
- community:社区健康(Stars趋势、贡献者数量、Issue响应速度、更新频率)
- performance:性能稳定(推理速度、并发能力、稳定性、可扩展性)
项目信息:
名称:{name}
描述:{description}
GitHub Stars:{stars}
README摘要:{readme}
返回纯JSON:
{
"capability": 4.5,
"devExp": 4.0,
"costLicense": 5.0,
"community": 4.5,
"performance": 4.0,
"overall": 4.4,
"reviews": {
"capability": {"score": 4.5, "summary": "一句话总结", "detail": "详细分析"},
"devExp": {"score": 4.0, "summary": "一句话总结", "detail": "详细分析"},
"costLicense": {"score": 5.0, "summary": "一句话总结", "detail": "详细分析"},
"community": {"score": 4.5, "summary": "一句话总结", "detail": "详细分析"},
"performance": {"score": 4.0, "summary": "一句话总结", "detail": "详细分析"}
},
"tags": ["标签1", "标签2"]
}`;
function extractJSON(str) {
const trimmed = str.trim();
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
let jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : trimmed;
const braceMatch = jsonStr.match(/\{[\s\S]*\}/);
if (braceMatch) { try { return JSON.parse(braceMatch[0]); } catch {} }
return JSON.parse(jsonStr);
}
function extractOwnerRepo(sourceUrl) {
const match = sourceUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
if (!match) return null;
return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
}
async function getReadme(owner, repo) {
try {
const { data } = await octokit.rest.repos.getReadme({ owner, repo });
const content = Buffer.from(data.content, "base64").toString("utf-8");
return content.substring(0, 3000);
} catch {
return "(无法获取README)";
}
}
async function main() {
const startTime = new Date();
const now = new Date();
const cnOffset = 8 * 60 * 60 * 1000;
const cnTime = new Date(now.getTime() + cnOffset);
const today = cnTime.toISOString().split("T")[0];
console.log(`🔥 [Task6] ${today} 评测最热工具...`);
const unreviewed = await prisma.skill.findMany({
where: {
status: "published",
sourceUrl: { contains: "github.com" },
skillReview: null,
},
orderBy: [{ githubStars: "desc" }, { viewCount: "desc" }],
take: 2,
select: { id: true, name: true, description: true, sourceUrl: true, githubStars: true },
});
if (unreviewed.length === 0) {
console.log(" 所有热门技能已有评测,跳过");
await prisma.taskLog.create({
data: {
taskKey: "task6-review-hot",
taskName: "评测热门技能",
status: "completed",
startedAt: startTime,
finishedAt: new Date(),
duration: new Date().getTime() - startTime.getTime(),
result: { reviewed: 0, message: "所有热门技能已有评测" },
},
});
await prisma.$disconnect();
return;
}
console.log(` 待评测: ${unreviewed.map((s) => s.name).join(", ")}`);
let reviewed = 0;
let failed = 0;
const errors = [];
for (const skill of unreviewed) {
console.log(`\n 📦 评测: ${skill.name} (${skill.githubStars}⭐)`);
const parsed = extractOwnerRepo(skill.sourceUrl);
let readme = "(无README)";
let stars = skill.githubStars;
if (parsed) {
readme = await getReadme(parsed.owner, parsed.repo);
try {
const { data } = await octokit.rest.repos.get({ owner: parsed.owner, repo: parsed.repo });
stars = data.stargazers_count;
} catch {}
}
const prompt = REVIEW_PROMPT
.replace("{name}", skill.name)
.replace("{description}", skill.description || "")
.replace("{stars}", String(stars))
.replace("{readme}", readme.substring(0, 2000));
try {
const completion = await openai.chat.completions.create({
model: "deepseek-v4-pro",
messages: [{ role: "user", content: prompt }],
temperature: 0.3,
});
const content = completion.choices[0]?.message?.content;
if (!content) throw new Error("DeepSeek返回为空");
const parsed_review = extractJSON(content);
const scores = [parsed_review.capability, parsed_review.devExp, parsed_review.costLicense, parsed_review.community, parsed_review.performance].filter((s) => typeof s === "number");
const overall = parsed_review.overall || (scores.length > 0 ? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10 : 3.0);
await prisma.skillReview.create({
data: {
skillId: skill.id,
capability: parsed_review.capability || 3.0,
devExp: parsed_review.devExp || 3.0,
costLicense: parsed_review.costLicense || 3.0,
community: parsed_review.community || 3.0,
performance: parsed_review.performance || 3.0,
overall,
reviews: parsed_review.reviews || {},
tags: parsed_review.tags || [],
status: "PUBLISHED",
reviewedAt: new Date(),
},
});
await prisma.skill.update({
where: { id: skill.id },
data: { lastReviewedAt: new Date() },
});
reviewed++;
console.log(` ✅ 评测完成: overall=${overall}`);
} catch (e) {
failed++;
errors.push(`${skill.name}: ${e.message}`);
console.error(` ❌ 评测失败: ${e.message}`);
}
}
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
const result = {
total: unreviewed.length,
reviewed,
failed,
duration: `${(duration / 1000).toFixed(1)}s`,
};
console.log(`\n🏁 [Task6] 完成: 评测${reviewed}个, 失败${failed}个`);
await prisma.taskLog.create({
data: {
taskKey: "task6-review-hot",
taskName: "评测热门技能",
status: "completed",
startedAt: startTime,
finishedAt: endTime,
duration,
result,
error: errors.length > 0 ? errors.slice(0, 5).join("\n") : null,
},
});
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error("❌ 任务六失败:", e.message);
if (prisma) {
try {
await prisma.taskLog.create({
data: {
taskKey: "task6-review-hot",
taskName: "评测热门技能",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
duration: 0,
error: e.message,
},
});
} catch {}
await prisma.$disconnect();
}
process.exit(1);
});
+50 -5
View File
@@ -32,6 +32,7 @@ const emptyForm = {
export default function CategoriesAdminPage() {
const [categories, setCategories] = useState<CategoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState("");
const [editCat, setEditCat] = useState<CategoryItem | null>(null);
const [isNew, setIsNew] = useState(false);
@@ -40,12 +41,13 @@ export default function CategoriesAdminPage() {
const [error, setError] = useState("");
const fetchCategories = useCallback(async () => {
setFetchError("");
try {
const res = await fetch("/api/admin/categories");
const data = await res.json();
setCategories(data);
} catch {
console.error("Fetch categories failed");
setFetchError("加载分类失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -199,11 +201,54 @@ export default function CategoriesAdminPage() {
</Button>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
{fetchError ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{fetchError}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchCategories()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
{Array.from({ length: 6 }).map((_, i) => (
<th key={i} className="px-5 py-3.5">
<div className="h-3 w-14 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-5 py-3.5">
<div className="flex items-center gap-2.5">
<div className="h-7 w-7 animate-pulse rounded-lg bg-muted/30" />
<div className="h-3 w-20 animate-pulse rounded bg-muted/20" />
</div>
</td>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-5 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/20" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : categories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
+29
View File
@@ -0,0 +1,29 @@
"use client"
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4 text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-red-50 flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h2 className="text-lg font-semibold text-foreground">页面加载出错</h2>
<p className="text-sm text-muted-foreground">{error.message || "请稍后重试"}</p>
<button
onClick={reset}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
重新加载
</button>
</div>
</div>
)
}
+16
View File
@@ -116,6 +116,22 @@ const menuGroups: MenuGroup[] = [
},
],
},
{
title: "任务调度",
items: [
{
label: "任务中心",
href: "/admin/tasks",
icon: "M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664zM21 12a9 9 0 11-18 0 9 9 0 0118 0z",
badge: null,
},
{
label: "任务日志",
href: "/admin/tasks/logs",
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01",
},
],
},
];
export default function AdminLayout({
+10
View File
@@ -0,0 +1,10 @@
export default function AdminLoading() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground text-sm">加载中...</p>
</div>
</div>
)
}
+20 -4
View File
@@ -1,15 +1,23 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, Suspense } from "react";
import { getCsrfToken } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
export default function AdminLoginPage() {
function LoginForm() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/admin/pending";
const errorParam = searchParams.get("error");
const [csrfToken, setCsrfToken] = useState("");
const [ready, setReady] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (errorParam === "unauthorized") {
setError("您的账号没有管理员权限");
}
const fetchToken = async () => {
try {
const token = await getCsrfToken();
@@ -42,7 +50,7 @@ export default function AdminLoginPage() {
}
};
fetchToken();
}, []);
}, [errorParam]);
return (
<div className="relative flex min-h-screen items-center justify-center px-4 overflow-hidden">
@@ -77,7 +85,7 @@ export default function AdminLoginPage() {
className="space-y-4"
>
<input name="csrfToken" type="hidden" value={csrfToken} />
<input name="callbackUrl" type="hidden" value="/admin/pending" />
<input name="callbackUrl" type="hidden" value={callbackUrl} />
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
@@ -127,3 +135,11 @@ export default function AdminLoginPage() {
</div>
);
}
export default function AdminLoginPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" /></div>}>
<LoginForm />
</Suspense>
);
}
+51 -5
View File
@@ -32,6 +32,7 @@ interface Category {
export default function PendingSkillsPage() {
const [skills, setSkills] = useState<PendingSkill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [categories, setCategories] = useState<Category[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
@@ -55,13 +56,14 @@ export default function PendingSkillsPage() {
const [saving, setSaving] = useState(false);
const fetchSkills = useCallback(async () => {
setError("");
try {
const res = await fetch(`/api/admin/pending-skills?page=${page}&pageSize=${pageSize}`);
const data = await res.json();
setSkills(data.skills || []);
setTotal(data.total || 0);
} catch {
console.error("Failed to fetch skills");
setError("加载待审核技能失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -421,11 +423,54 @@ export default function PendingSkillsPage() {
</div>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchSkills()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
<th className="px-4 py-3.5 w-10">
<div className="h-4 w-4 animate-pulse rounded bg-muted/30 mx-auto" />
</th>
{Array.from({ length: 7 }).map((_, i) => (
<th key={i} className="px-4 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-4 py-3">
<div className="h-4 w-4 animate-pulse rounded bg-muted/20 mx-auto" />
</td>
{Array.from({ length: 7 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-3 animate-pulse rounded bg-muted/20" style={{ width: `${60 + Math.floor(Math.random() * 60)}%` }} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
@@ -435,6 +480,7 @@ export default function PendingSkillsPage() {
</svg>
</div>
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无待审核技能</p>
<p className="mt-1 text-xs text-muted-foreground/60">在探索技能页面发现新技能后即可在此审核</p>
</div>
) : (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
+139 -5
View File
@@ -24,6 +24,11 @@ interface PendingTool {
sourceUrl: string | null;
createdAt: string;
category: { id: number; name: string } | null;
checkWebsiteOk: boolean | null;
checkLogoOk: boolean | null;
checkDescOk: boolean | null;
checkCategoryOk: boolean | null;
checkDetail: unknown;
}
interface Category {
@@ -93,6 +98,7 @@ export default function PendingPage() {
const [tools, setTools] = useState<PendingTool[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [categories, setCategories] = useState<Category[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [logoFilter, setLogoFilter] = useState<"all" | "has" | "missing">("all");
@@ -119,13 +125,14 @@ export default function PendingPage() {
const [saving, setSaving] = useState(false);
const fetchTools = useCallback(async () => {
setError("");
try {
const res = await fetch(`/api/admin/pending?page=${page}&pageSize=${pageSize}`);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
console.error("Failed to fetch tools");
setError("加载待审核工具失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -229,6 +236,37 @@ export default function PendingPage() {
}
};
const handlePublishAllQualified = async () => {
const qualified = tools.filter(
(t) => t.checkWebsiteOk && t.checkLogoOk && t.checkDescOk && t.checkCategoryOk
);
if (qualified.length === 0) {
setMessage("没有全部检查合格的工具可一键通过");
return;
}
if (!confirm(`确定一键通过全部 ${qualified.length} 个检查合格的工具?`)) return;
setBatchPublishing(true);
try {
const res = await fetch("/api/admin/pending/batch-publish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: qualified.map((t) => t.id) }),
});
const data = await res.json();
if (data.success) {
setMessage(`一键通过完成:成功 ${data.published} 个,跳过 ${data.skipped} 个`);
fetchTools();
setSelectedIds(new Set());
} else {
setMessage("一键通过失败: " + (data.error || "未知错误"));
}
} catch {
setMessage("一键通过失败");
} finally {
setBatchPublishing(false);
}
};
const handleFetchLogos = async (refetch = false) => {
setFetchingLogos(true);
setMessage(refetch ? "正在重新抓取所有Logo..." : "正在抓取缺失Logo...");
@@ -480,6 +518,19 @@ export default function PendingPage() {
<span className="text-sm font-medium text-foreground">
已选择 <span className="text-primary">{selectedIds.size}</span> 个工具
</span>
{tools.filter((t) => t.checkWebsiteOk && t.checkLogoOk && t.checkDescOk && t.checkCategoryOk).length > 0 && (
<Button
size="sm"
onClick={handlePublishAllQualified}
disabled={batchPublishing || batchRejecting}
className="gap-1.5 rounded-lg text-xs h-8 px-3 bg-violet-600 hover:bg-violet-500"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
一键通过全部合格 ({tools.filter((t) => t.checkWebsiteOk && t.checkLogoOk && t.checkDescOk && t.checkCategoryOk).length})
</Button>
)}
<div className="flex-1" />
<Button
size="sm"
@@ -527,11 +578,54 @@ export default function PendingPage() {
</div>
)}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-100">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchTools()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
<th className="px-4 py-3.5 w-10">
<div className="h-4 w-4 animate-pulse rounded bg-muted/30 mx-auto" />
</th>
{Array.from({ length: 13 }).map((_, i) => (
<th key={i} className="px-4 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-4 py-3">
<div className="h-4 w-4 animate-pulse rounded bg-muted/20 mx-auto" />
</td>
{Array.from({ length: 13 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-3 animate-pulse rounded bg-muted/20" style={{ width: `${60 + Math.floor(Math.random() * 60)}%` }} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
@@ -573,6 +667,10 @@ export default function PendingPage() {
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">简介</th>
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">网址</th>
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">来源</th>
<th className="px-4 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">网站</th>
<th className="px-4 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">Logo</th>
<th className="px-4 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">描述</th>
<th className="px-4 py-3.5 text-center text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">分类</th>
<th className="px-4 py-3.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">发现时间</th>
<th className="px-4 py-3.5 text-right text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">操作</th>
</tr>
@@ -668,6 +766,42 @@ export default function PendingPage() {
AI发现
</span>
</td>
<td className="px-4 py-3 text-center">
{tool.checkWebsiteOk === true ? (
<span className="inline-flex rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">✓</span>
) : tool.checkWebsiteOk === false ? (
<span className="inline-flex rounded-md bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-600">✗</span>
) : (
<span className="inline-flex rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-center">
{tool.checkLogoOk === true ? (
<span className="inline-flex rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">✓</span>
) : tool.checkLogoOk === false ? (
<span className="inline-flex rounded-md bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-600">✗</span>
) : (
<span className="inline-flex rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-center">
{tool.checkDescOk === true ? (
<span className="inline-flex rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">✓</span>
) : tool.checkDescOk === false ? (
<span className="inline-flex rounded-md bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-600">✗</span>
) : (
<span className="inline-flex rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-center">
{tool.checkCategoryOk === true ? (
<span className="inline-flex rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">✓</span>
) : tool.checkCategoryOk === false ? (
<span className="inline-flex rounded-md bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-600">✗</span>
) : (
<span className="inline-flex rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3">
<span className="text-[11px] text-muted-foreground">
{new Date(tool.createdAt).toLocaleDateString("zh-CN")}
+129 -1
View File
@@ -26,6 +26,7 @@ export default function RolesPage() {
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [newName, setNewName] = useState("");
const [newSlug, setNewSlug] = useState("");
@@ -44,7 +45,10 @@ export default function RolesPage() {
}, []);
useEffect(() => {
Promise.all([fetchRoles(), fetchPermissions()]).finally(() => setLoading(false));
setError("");
Promise.all([fetchRoles(), fetchPermissions()])
.catch(() => setError("加载角色数据失败,请稍后重试"))
.finally(() => setLoading(false));
}, [fetchRoles, fetchPermissions]);
const selectRole = (role: Role) => {
@@ -132,6 +136,130 @@ export default function RolesPage() {
const groups = Array.from(new Set(permissions.map((p) => p.groupName)));
const selectedRole = roles.find((r) => r.id === selectedRoleId);
if (error) {
return (
<div className="flex items-center justify-center h-[calc(100vh-4rem)]">
<div className="flex flex-col items-center">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<button
onClick={() => { setError(""); setLoading(true); Promise.all([fetchRoles(), fetchPermissions()]).catch(() => setError("加载角色数据失败,请稍后重试")).finally(() => setLoading(false)); }}
className="mt-4 rounded-xl border border-border/50 px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
重新加载
</button>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex h-[calc(100vh-4rem)]">
<div className="w-64 shrink-0 border-r border-border/50 bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<div className="h-5 w-16 animate-pulse rounded bg-muted/30" />
<div className="h-7 w-14 animate-pulse rounded-lg bg-muted/30" />
</div>
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg px-3 py-2.5 space-y-1.5">
<div className="h-3 w-24 animate-pulse rounded bg-muted/20" />
<div className="h-2.5 w-32 animate-pulse rounded bg-muted/20" />
</div>
))}
</div>
</div>
<div className="flex-1 p-6">
<div className="space-y-4">
<div className="h-6 w-48 animate-pulse rounded bg-muted/30" />
<div className="h-4 w-64 animate-pulse rounded bg-muted/20" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/50 bg-white p-4 space-y-3">
<div className="h-4 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid grid-cols-3 gap-2">
{Array.from({ length: 6 }).map((_, j) => (
<div key={j} className="h-9 animate-pulse rounded-lg bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (roles.length === 0) {
return (
<div className="flex h-[calc(100vh-4rem)]">
<div className="w-64 shrink-0 border-r border-border/50 bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground">角色列表</h2>
<button
onClick={() => { setShowCreate(true); setSelectedRoleId(null); }}
className="rounded-lg bg-primary px-3 py-1 text-xs font-medium text-white hover:bg-primary/90 transition-colors"
>
+ 新建
</button>
</div>
{showCreate && (
<div className="mb-3 rounded-xl border border-border/50 bg-muted/30 p-3 space-y-2">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="角色名称"
className="h-9 w-full rounded-lg border border-border/50 bg-white px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/15"
/>
<input
value={newSlug}
onChange={(e) => setNewSlug(e.target.value.replace(/[^a-z0-9_]/g, ""))}
placeholder="标识 (英文)"
className="h-9 w-full rounded-lg border border-border/50 bg-white px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/15"
/>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={saving || !newName.trim() || !newSlug.trim()}
className="rounded-lg bg-primary px-3 py-1 text-xs font-medium text-white hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
创建
</button>
<button
onClick={() => { setShowCreate(false); setNewName(""); setNewSlug(""); }}
className="rounded-lg border border-border/50 px-3 py-1 text-xs text-muted-foreground hover:bg-muted transition-colors"
>
取消
</button>
</div>
</div>
)}
<div className="flex flex-col items-center justify-center py-16">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<p className="mt-4 text-sm font-medium text-muted-foreground">暂无角色</p>
<p className="mt-1 text-xs text-muted-foreground/60">点击上方按钮创建第一个角色</p>
</div>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<svg className="mx-auto mb-4 h-16 w-16 text-muted-foreground/30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
<p className="text-sm font-medium text-foreground">选择一个角色查看权限配置</p>
<p className="mt-1 text-xs text-muted-foreground">从左侧列表选择角色,查看和编辑其权限</p>
</div>
</div>
</div>
);
}
return (
<div className="flex h-[calc(100vh-4rem)]">
<div className="w-64 shrink-0 border-r border-border/50 bg-white p-4 overflow-y-auto">
@@ -32,6 +32,7 @@ const emptyForm = {
export default function SkillCategoriesAdminPage() {
const [categories, setCategories] = useState<SkillCategoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState("");
const [editCat, setEditCat] = useState<SkillCategoryItem | null>(null);
const [isNew, setIsNew] = useState(false);
@@ -40,12 +41,13 @@ export default function SkillCategoriesAdminPage() {
const [error, setError] = useState("");
const fetchCategories = useCallback(async () => {
setFetchError("");
try {
const res = await fetch("/api/admin/skill-categories");
const data = await res.json();
setCategories(data);
} catch {
console.error("Fetch skill categories failed");
setFetchError("加载技能分类失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -207,11 +209,54 @@ export default function SkillCategoriesAdminPage() {
</Button>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
{fetchError ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{fetchError}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchCategories()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
{Array.from({ length: 6 }).map((_, i) => (
<th key={i} className="px-5 py-3.5">
<div className="h-3 w-14 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-5 py-3.5">
<div className="flex items-center gap-2.5">
<div className="h-7 w-7 animate-pulse rounded-lg bg-muted/30" />
<div className="h-3 w-20 animate-pulse rounded bg-muted/20" />
</div>
</td>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-5 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/20" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : categories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
+53 -5
View File
@@ -30,6 +30,7 @@ interface Skill {
export default function SkillsPage() {
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [total, setTotal] = useState(0);
const [categories, setCategories] = useState<{ id: number; name: string }[]>([]);
const [page, setPage] = useState(1);
@@ -49,13 +50,14 @@ export default function SkillsPage() {
const [saving, setSaving] = useState(false);
const fetchSkills = useCallback(async () => {
setError("");
try {
const res = await fetch(`/api/admin/skills?page=${page}&pageSize=${pageSize}`);
const data = await res.json();
setSkills(data.skills || []);
setTotal(data.total || 0);
} catch {
console.error("Failed to fetch skills");
setError("加载技能列表失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -204,11 +206,57 @@ export default function SkillsPage() {
</div>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchSkills()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
{Array.from({ length: 6 }).map((_, i) => (
<th key={i} className="px-5 py-3.5">
<div className="h-3 w-14 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="h-9 w-9 animate-pulse rounded-lg bg-muted/30" />
<div className="space-y-1.5">
<div className="h-3 w-24 animate-pulse rounded bg-muted/20" />
<div className="h-2.5 w-40 animate-pulse rounded bg-muted/20" />
</div>
</div>
</td>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-5 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/20" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
@@ -0,0 +1,189 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
interface TaskLogItem {
id: number;
taskKey: string;
status: string;
startedAt: string;
finishedAt: string | null;
duration: number | null;
result: Record<string, unknown> | null;
error: string | null;
}
const TASK_META: Record<string, { name: string; desc: string; cron: string; metrics: string[] }> = {
"task1-discover-tools": { name: "发现AI工具", desc: "每天从多源搜索新AI工具,过滤去重后入库等待审核", cron: "0 6 * * *", metrics: ["发现工具数", "通过检查数", "入库数"] },
"task3-check-tools": { name: "工具巡检", desc: "检查所有已入库工具的网站可用性、Logo完整性,自动发布合格工具", cron: "0 3 * * *", metrics: ["巡检总数", "网站失效数", "Logo异常数", "自动发布数"] },
"task4-discover-skills": { name: "发现AI技能", desc: "从GitHub搜索热门AI开源项目,获取README并入库等待审核", cron: "0 2 * * *", metrics: ["搜索关键词数", "搜索结果数", "新增入库数"] },
"task5-update-stars": { name: "更新星值", desc: "更新所有GitHub技能的最新Stars数,计算日/周/月变化趋势", cron: "0 1 * * *", metrics: ["总项目数", "成功更新数", "失败数"] },
"task6-review-hot": { name: "评测热门", desc: "对未评测的最热门2个AI技能进行五维深度评测并自动发布", cron: "0 4 * * *", metrics: ["候选技能数", "评测完成数"] },
"daily-news": { name: "AI日报生成", desc: "搜索真实新闻素材,AI整理格式生成当日AI日报", cron: "30 7 * * *", metrics: ["新闻源数", "获取素材数", "最终条目数"] },
};
export default function TaskDetailPage() {
const params = useParams();
const taskKey = params.taskKey as string;
const meta = TASK_META[taskKey] || { name: taskKey, desc: "", cron: "", metrics: [] };
const [logs, setLogs] = useState<TaskLogItem[]>([]);
const [config, setConfig] = useState<{ enabled: boolean; cronExpr: string }>({ enabled: true, cronExpr: meta.cron });
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [lRes, cRes] = await Promise.all([
fetch(`/api/admin/tasks/logs?taskKey=${taskKey}&pageSize=30`),
fetch("/api/admin/tasks/configs"),
]);
if (lRes.ok) setLogs((await lRes.json()).logs || []);
if (cRes.ok) {
const tasks = (await cRes.json()).tasks || [];
const t = tasks.find((x: { taskKey: string }) => x.taskKey === taskKey);
if (t) setConfig({ enabled: t.enabled, cronExpr: t.cronExpr });
}
} catch {}
setLoading(false);
}, [taskKey]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleRun = async () => {
setRunning(true);
try {
const res = await fetch("/api/admin/tasks/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskKey }),
});
const data = await res.json();
alert(`执行${data.status === "completed" ? "完成" : "失败"}\n${(data.output || "").slice(-300)}`);
} catch (e: unknown) {
alert(`错误: ${(e as Error).message}`);
}
setRunning(false);
fetchData();
};
const successCount = logs.filter((l) => l.status === "completed").length;
const failCount = logs.filter((l) => l.status === "failed").length;
return (
<div className="p-6 lg:p-8">
<div className="mb-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 ${config.enabled ? "bg-emerald-100" : "bg-gray-100"}`}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={config.enabled ? "text-emerald-600" : "text-gray-400"}>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
{meta.name}
<span className={`text-xs px-2 py-0.5 rounded-full ${config.enabled ? "bg-emerald-50 text-emerald-700" : "bg-gray-50 text-gray-500"}`}>
{config.enabled ? "已启用" : "已停用"}
</span>
</h1>
<p className="mt-1 text-sm text-muted-foreground">{meta.desc}</p>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<code className="bg-muted/50 px-2 py-0.5 rounded">{config.cronExpr || "未配置"}</code>
<button
onClick={handleRun}
disabled={running}
className="px-3 py-1.5 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{running ? "执行中..." : "手动执行"}
</button>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-foreground">{logs.length}</div>
<div className="text-[11px] text-muted-foreground mt-1">总执行次数</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-emerald-600">{successCount}</div>
<div className="text-[11px] text-muted-foreground mt-1">成功</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-red-600">{failCount}</div>
<div className="text-[11px] text-muted-foreground mt-1">失败</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-blue-600">{Math.round((successCount / Math.max(logs.length, 1)) * 100)}%</div>
<div className="text-[11px] text-muted-foreground mt-1">成功率</div>
</CardContent>
</Card>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-muted/20 rounded-lg animate-pulse" />
))}
</div>
) : logs.length === 0 ? (
<div className="text-center py-16">
<p className="text-muted-foreground">暂无执行记录,点击"手动执行"触发首次运行</p>
</div>
) : (
<div className="space-y-2">
{logs.map((log) => {
const isSuccess = log.status === "completed";
const isFailed = log.status === "failed";
return (
<details key={log.id} className="group">
<summary className="list-none cursor-pointer">
<Card className="border-border/60 bg-white shadow-sm hover:shadow-md transition-shadow">
<CardContent className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${isSuccess ? "bg-emerald-500" : isFailed ? "bg-red-500" : "bg-amber-500"}`} />
<span className="text-xs font-medium text-foreground">
{isSuccess ? "成功" : isFailed ? "失败" : log.status}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
<span>{new Date(log.startedAt).toLocaleString("zh-CN")}</span>
{log.duration && <span>耗时: {(log.duration / 1000).toFixed(1)}s</span>}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="group-open:rotate-180 transition-transform">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
</CardContent>
</Card>
</summary>
{log.result && (
<div className="mx-1 mt-1 p-3 bg-slate-50 border border-border/30 rounded-lg">
<pre className="text-[11px] text-muted-foreground whitespace-pre-wrap break-all max-h-80 overflow-auto">
{typeof log.result === "string" ? log.result : JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{log.error && (
<div className="mx-1 mt-1 p-3 bg-red-50 border border-red-100 rounded-lg">
<p className="text-[11px] text-red-700 whitespace-pre-wrap">{log.error}</p>
</div>
)}
</details>
);
})}
</div>
)}
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
interface TaskLogItem {
id: number;
taskKey: string;
taskName: string;
status: string;
startedAt: string;
finishedAt: string | null;
duration: number | null;
result: Record<string, unknown> | null;
error: string | null;
triggerBy: string;
}
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
completed: { label: "已完成", color: "text-emerald-700", bg: "bg-emerald-50" },
failed: { label: "失败", color: "text-red-700", bg: "bg-red-50" },
running: { label: "运行中", color: "text-blue-700", bg: "bg-blue-50" },
completed_with_errors: { label: "部分失败", color: "text-amber-700", bg: "bg-amber-50" },
};
const TASK_LABELS: Record<string, string> = {
"task1-discover-tools": "发现AI工具",
"task3-check-tools": "工具巡检",
"task4-discover-skills": "发现AI技能",
"task5-update-stars": "更新星值",
"task6-review-hot": "评测热门",
"daily-news": "AI日报",
};
export default function TaskLogsPage() {
const [logs, setLogs] = useState<TaskLogItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [taskFilter, setTaskFilter] = useState("");
const [expandedId, setExpandedId] = useState<number | null>(null);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), pageSize: "20" });
if (taskFilter) params.set("taskKey", taskFilter);
const res = await fetch(`/api/admin/tasks/logs?${params}`);
if (res.ok) {
const data = await res.json();
setLogs(data.logs || []);
setTotal(data.total || 0);
}
} catch {}
setLoading(false);
}, [page, taskFilter]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
return (
<div className="p-6 lg:p-8">
<div className="mb-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-slate-100">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-slate-600">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
任务执行日志
</h1>
<p className="mt-2 text-sm text-muted-foreground">
所有定时任务的历史执行记录,共 {total} 条
</p>
</div>
<div className="mb-4 flex items-center gap-2 flex-wrap">
<select
value={taskFilter}
onChange={(e) => { setTaskFilter(e.target.value); setPage(1); }}
className="px-3 py-1.5 text-sm border border-border/60 rounded-lg bg-white text-muted-foreground"
>
<option value="">全部任务</option>
{Object.entries(TASK_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<span className="text-xs text-muted-foreground ml-auto">共 {total} 条记录</span>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-muted/20 rounded-lg animate-pulse" />
))}
</div>
) : logs.length === 0 ? (
<div className="text-center py-16">
<p className="text-muted-foreground">暂无执行日志</p>
</div>
) : (
<div className="space-y-2">
{logs.map((log) => {
const st = STATUS_MAP[log.status] || { label: log.status, color: "text-gray-600", bg: "bg-gray-50" };
const isExpanded = expandedId === log.id;
return (
<div key={log.id}>
<Card
className="border-border/60 bg-white shadow-sm cursor-pointer hover:shadow-md transition-shadow"
onClick={() => setExpandedId(isExpanded ? null : log.id)}
>
<CardContent className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className={`inline-block px-1.5 py-0.5 text-[10px] rounded ${st.bg} ${st.color}`}>
{st.label}
</span>
<span className="text-xs font-medium text-foreground">{TASK_LABELS[log.taskKey] || log.taskKey}</span>
<span className="text-[10px] text-muted-foreground/50">{log.triggerBy === "manual" ? "手动" : "自动"}</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-muted-foreground flex-shrink-0">
<span>{new Date(log.startedAt).toLocaleString("zh-CN")}</span>
{log.duration && <span>{(log.duration / 1000).toFixed(1)}s</span>}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={`transition-transform ${isExpanded ? "rotate-180" : ""}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
{log.error && !isExpanded && (
<p className="mt-1 text-[10px] text-red-600 truncate">{log.error}</p>
)}
</CardContent>
</Card>
{isExpanded && log.result && (
<div className="mx-1 mt-1 p-3 bg-slate-50 border border-border/30 rounded-lg">
<pre className="text-[11px] text-muted-foreground whitespace-pre-wrap break-all max-h-60 overflow-auto">
{typeof log.result === "string" ? log.result : JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{isExpanded && log.error && (
<div className="mx-1 mt-1 p-3 bg-red-50 border border-red-100 rounded-lg">
<p className="text-[11px] text-red-700 whitespace-pre-wrap">{log.error}</p>
</div>
)}
</div>
);
})}
</div>
)}
{total > 20 && (
<div className="mt-4 flex items-center justify-center gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1.5 text-xs rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40"
>
上一页
</button>
<span className="text-xs text-muted-foreground">{page} / {Math.ceil(total / 20)}</span>
<button
onClick={() => setPage(page + 1)}
disabled={page * 20 >= total}
className="px-3 py-1.5 text-xs rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40"
>
下一页
</button>
</div>
)}
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
interface TaskItem {
taskKey: string;
taskName: string;
cronExpr: string;
enabled: boolean;
description: string;
config: unknown;
updatedAt: string | null;
}
interface TaskLogItem {
id: number;
taskKey: string;
taskName: string;
status: string;
startedAt: string;
finishedAt: string | null;
duration: number | null;
result: Record<string, unknown> | null;
error: string | null;
}
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
completed: { label: "已完成", color: "text-emerald-700", bg: "bg-emerald-50" },
failed: { label: "失败", color: "text-red-700", bg: "bg-red-50" },
running: { label: "运行中", color: "text-blue-700", bg: "bg-blue-50" },
};
function getLastRun(logs: TaskLogItem[] | null, taskKey: string) {
if (!logs) return null;
return logs.find((l) => l.taskKey === taskKey) || null;
}
export default function TasksPage() {
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [logs, setLogs] = useState<TaskLogItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [runningTask, setRunningTask] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setError("");
try {
const [tRes, lRes] = await Promise.all([
fetch("/api/admin/tasks/configs"),
fetch("/api/admin/tasks/logs?pageSize=7"),
]);
if (tRes.ok) setTasks((await tRes.json()).tasks || []);
if (lRes.ok) setLogs((await lRes.json()).logs || []);
} catch {
setError("加载任务数据失败,请稍后重试");
}
setLoading(false);
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const runTask = async (taskKey: string) => {
setRunningTask(taskKey);
try {
const res = await fetch("/api/admin/tasks/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskKey }),
});
const data = await res.json();
alert(`任务 ${taskKey} 执行${data.status === "completed" ? "完成" : "失败"}\n${(data.output || "").slice(-500)}`);
} catch (e: unknown) {
alert(`执行失败: ${(e as Error).message}`);
}
setRunningTask(null);
fetchData();
};
const toggleTask = async (taskKey: string, enabled: boolean) => {
await fetch("/api/admin/tasks/configs", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskKey, enabled }),
});
fetchData();
};
if (error) {
return (
<div className="p-6 lg:p-8">
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<button
onClick={() => fetchData()}
className="mt-4 rounded-xl border border-border/50 px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
重新加载
</button>
</div>
</div>
);
}
if (loading) {
return (
<div className="p-6 lg:p-8">
<div className="mb-8 space-y-1.5">
<div className="h-7 w-44 animate-pulse rounded bg-muted/30" />
<div className="h-4 w-80 animate-pulse rounded bg-muted/20" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/60 bg-white p-3 space-y-2">
<div className="h-3 w-16 animate-pulse rounded bg-muted/20" />
<div className="h-2.5 w-20 animate-pulse rounded bg-muted/20" />
</div>
))}
</div>
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/60 bg-white p-4 space-y-3">
<div className="flex items-center gap-2">
<div className="h-4 w-28 animate-pulse rounded bg-muted/30" />
<div className="h-4 w-20 animate-pulse rounded bg-muted/20" />
</div>
<div className="h-3 w-72 animate-pulse rounded bg-muted/20" />
<div className="h-3 w-48 animate-pulse rounded bg-muted/20" />
</div>
))}
</div>
</div>
);
}
return (
<div className="p-6 lg:p-8">
<div className="mb-8">
<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-blue-100">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-blue-600">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
任务调度中心
</h1>
<p className="mt-2 text-sm text-muted-foreground">
管理所有定时任务,查看执行历史和运行状态,支持手动触发
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{["task1-discover-tools", "task3-check-tools", "task4-discover-skills", "task5-update-stars", "task6-review-hot", "daily-news"].map((key) => {
const t = tasks.find((x) => x.taskKey === key);
const lastLog = getLastRun(logs, key);
const st = lastLog ? STATUS_MAP[lastLog.status] : null;
return (
<Card key={key} className="border-border/60 bg-white shadow-sm">
<CardContent className="p-3">
<div className="text-xs text-muted-foreground mb-1">{t?.taskName || key}</div>
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${t?.enabled ? "bg-emerald-500" : "bg-gray-300"}`} />
<span className="text-xs font-medium">{t?.enabled ? "启用" : "停用"}</span>
{st && <span className={`text-[10px] ml-auto ${st.color}`}>{st.label}</span>}
</div>
</CardContent>
</Card>
);
})}
</div>
<div className="space-y-3">
{tasks.map((task) => {
const lastLog = getLastRun(logs, task.taskKey);
const st = lastLog ? STATUS_MAP[lastLog.status] : null;
return (
<Card key={task.taskKey} className="border-border/60 bg-white shadow-sm hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">{task.taskName}</h3>
<span className="text-[10px] text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">{task.taskKey}</span>
{st && (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${st.bg} ${st.color}`}>
{st.label}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground/70">{task.description}</p>
<div className="mt-2 flex items-center gap-4 text-[11px] text-muted-foreground">
<span>Cron: <code className="text-foreground/70 bg-muted/30 px-1 rounded">{task.cronExpr || "未配置"}</code></span>
{lastLog && (
<>
<span>上次: {new Date(lastLog.startedAt).toLocaleString("zh-CN")}</span>
{lastLog.duration && <span>耗时: {(lastLog.duration / 1000).toFixed(1)}s</span>}
</>
)}
</div>
{lastLog?.error && (
<p className="mt-1 text-[10px] text-red-600 truncate bg-red-50 rounded px-2 py-1">
{lastLog.error}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => toggleTask(task.taskKey, !task.enabled)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
task.enabled
? "bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200"
: "bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200"
}`}
>
{task.enabled ? "启用中" : "已停用"}
</button>
<button
onClick={() => runTask(task.taskKey)}
disabled={runningTask === task.taskKey}
className="px-2.5 py-1 text-xs rounded-md bg-blue-50 text-blue-700 hover:bg-blue-100 border border-blue-200 disabled:opacity-50 transition-colors"
>
{runningTask === task.taskKey ? "执行中..." : "立即执行"}
</button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{tasks.length === 0 && (
<div className="flex flex-col items-center justify-center py-20">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-muted-foreground">暂无任务配置</p>
<p className="mt-1 text-xs text-muted-foreground/60">请检查任务配置是否已初始化</p>
</div>
)}
</div>
);
}
+53 -5
View File
@@ -128,6 +128,7 @@ export default function ToolsAdminPage() {
const [tools, setTools] = useState<Tool[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 20;
@@ -138,13 +139,14 @@ export default function ToolsAdminPage() {
const [saving, setSaving] = useState(false);
const fetchTools = useCallback(async () => {
setError("");
try {
const res = await fetch(`/api/admin/tools?page=${page}&pageSize=${pageSize}`);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
console.error("Fetch tools failed");
setError("加载工具列表失败,请稍后重试");
} finally {
setLoading(false);
}
@@ -314,11 +316,57 @@ export default function ToolsAdminPage() {
</Button>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-2xl bg-muted/20" />
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-red-50 border border-red-100">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-5 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => fetchTools()}
className="mt-4 rounded-xl"
>
重新加载
</Button>
</div>
) : loading ? (
<div className="overflow-x-auto rounded-2xl border border-border/50 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50">
{Array.from({ length: 6 }).map((_, i) => (
<th key={i} className="px-5 py-3.5">
<div className="h-3 w-14 animate-pulse rounded bg-muted/30" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border/40">
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="h-9 w-9 animate-pulse rounded-lg bg-muted/30" />
<div className="space-y-1.5">
<div className="h-3 w-24 animate-pulse rounded bg-muted/20" />
<div className="h-2.5 w-40 animate-pulse rounded bg-muted/20" />
</div>
</div>
</td>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-5 py-3.5">
<div className="h-3 w-12 animate-pulse rounded bg-muted/20" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
+396
View File
@@ -0,0 +1,396 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
}
interface FlatCategory {
id: number;
name: string;
slug: string;
icon: string | null;
parentId: number | null;
toolCount: number;
tools: Tool[];
}
interface ParentCategory {
id: number;
name: string;
slug: string;
icon: string | null;
}
interface HomePageClientProps {
flatCategories: FlatCategory[];
parentCategories: ParentCategory[];
totalTools: number;
totalSkills: number;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border border-emerald-200",
freemium: "bg-blue-50 text-blue-700 border border-blue-200",
paid: "bg-amber-50 text-amber-700 border border-amber-200",
subscription: "bg-purple-50 text-purple-700 border border-purple-200",
};
export default function HomePageClient({
flatCategories: categories,
parentCategories,
totalTools,
totalSkills,
}: HomePageClientProps) {
const router = useRouter();
const [activeSection, setActiveSection] = useState<string>("");
const [search, setSearch] = useState("");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
useEffect(() => {
if (categories.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) setActiveSection(visible[0].target.id);
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
);
Object.values(sectionRefs.current).forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [categories]);
const scrollToSection = useCallback((id: string) => {
const el = sectionRefs.current[id];
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: "smooth" });
}
setMobileNavOpen(false);
}, []);
const handleSearch = () => {
if (search.trim()) {
router.push(`/tools?search=${encodeURIComponent(search.trim())}`);
}
};
const getActiveTab = (parentId: number, children: FlatCategory[]) => {
if (activeTabs[parentId]) return activeTabs[parentId];
return "all";
};
const setActiveTab = (parentId: number, slug: string) => {
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
};
const groupedCategories = parentCategories.map((parent) => {
const ownCat = categories.find((c) => c.id === parent.id);
const children = categories.filter((c) => c.parentId === parent.id);
return { parent, ownCat, children };
});
const isEmpty = categories.length === 0 && parentCategories.length === 0;
if (isEmpty) {
return (
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
))}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-10">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
const renderSidebar = () => (
<nav className="space-y-0.5">
<div className="relative mb-3">
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索工具..."
className="input-field-compact"
/>
</div>
<div className="h-px bg-border/40 my-2" />
{parentCategories.map((p) => {
const isActive = activeSection === `cat-${p.slug}`;
const ownCount = categories.find((c) => c.id === p.id)?.toolCount || 0;
const childCount = categories
.filter((c) => c.parentId === p.id)
.reduce((s, c) => s + c.toolCount, 0);
const totalCount = ownCount + childCount;
return (
<button
key={p.id}
onClick={() => scrollToSection(`cat-${p.slug}`)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
isActive
? "bg-primary/10 font-semibold text-primary shadow-sm"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`}
>
<span className="truncate">{p.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive ? "text-primary/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
);
})}
<div className="h-px bg-border/40 my-2" />
<div className="px-3 py-2">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个分类 · {totalTools} 个工具
</p>
</div>
</nav>
);
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
{renderSidebar()}
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-5 flex items-center gap-3 lg:hidden">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索AI工具..."
className="input-field !pl-10 !h-10 !rounded-xl"
/>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label="分类导航"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
{mobileNavOpen && (
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
{renderSidebar()}
</div>
)}
<HeroBanner
title="追光"
highlight="AI"
highlightColor="blue"
starStyle="cosmos"
stats={[
{ label: "AI工具", value: totalTools, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
]}
slogan="汇聚全球优质AI工具,赋能每一个创新者"
subSlogan="发现、探索、使用 — 让人工智能触手可及"
/>
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatCategory[];
if (allCatsInGroup.length === 0) return null;
const hasChildren = children.length > 0;
const activeTab = getActiveTab(parent.id, children);
const totalCount = allCatsInGroup.reduce((s, c) => s + c.toolCount, 0);
const allTools = allCatsInGroup.flatMap((c) => c.tools);
const displayTools = activeTab === "all"
? allTools
: children.find((c) => c.slug === activeTab)?.tools || ownCat?.tools || [];
const maxDisplay = 8;
const visibleTools = displayTools.slice(0, maxDisplay);
const hasMore = displayTools.length > maxDisplay;
return (
<section
key={`cat-${parent.slug}`}
id={`cat-${parent.slug}`}
ref={(el) => { sectionRefs.current[`cat-${parent.slug}`] = el; }}
className="mb-8"
>
<div className="mb-3 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-[10px] font-semibold text-primary tabular-nums">
{totalCount}
</span>
</div>
{hasChildren && (
<div className="mb-4 flex flex-wrap gap-1.5">
<button
onClick={() => setActiveTab(parent.id, "all")}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === "all"
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
全部
<span className={`text-[9px] tabular-nums ${
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
{children.map((child) => (
<button
key={child.id}
onClick={() => setActiveTab(parent.id, child.slug)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === child.slug
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
{child.name}
<span className={`text-[9px] tabular-nums ${
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
}`}>{child.toolCount}</span>
</button>
))}
</div>
)}
{visibleTools.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleTools.map((tool) => (
<Card
key={tool.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]"
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={false} />
</div>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/30 group-hover:shadow-sm"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
/>
) : null}
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-xs font-semibold text-primary transition-all duration-300 group-hover:scale-105 ${tool.logoUrl ? 'hidden' : ''}`}>
{tool.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
{tool.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
</div>
</div>
</div>
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
敬请期待
</div>
)}
{hasMore && (
<div className="mt-3 text-center">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const cat = activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
router.push(`/categories/${cat}`);
}}
>
查看全部 {displayTools.length} 个 →
</Button>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
);
}
+47 -28
View File
@@ -3,32 +3,9 @@ import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { fetchLogoWithMethod } from "@/lib/logo-fetcher";
import { downloadLogo } from "@/lib/logo-storage";
import { checkTypeSchema } from "@/lib/validation";
export async function POST(request: Request) {
const authError = await requireAdmin();
if (authError) return authError;
try {
const body = await request.json();
const { type } = body as { type: "published" | "pending" };
if (!type || !["published", "pending"].includes(type)) {
return NextResponse.json(
{ error: "type 参数无效,必须为 published 或 pending" },
{ status: 400 }
);
}
const tools =
type === "published"
? await prisma.tool.findMany({
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
})
: await prisma.pendingTool.findMany({
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
});
const results: {
interface CheckResult {
id: number;
name: string;
url: string;
@@ -36,9 +13,14 @@ export async function POST(request: Request) {
logoFetched: boolean;
logoMethod: string;
logoUrl: string;
}[] = [];
}
for (const tool of tools) {
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;
@@ -114,7 +96,7 @@ export async function POST(request: Request) {
}
}
results.push({
return {
id: tool.id,
name: tool.name,
url: url || "",
@@ -122,7 +104,44 @@ export async function POST(request: Request) {
logoFetched,
logoMethod,
logoUrl,
};
}
export async function POST(request: Request) {
const authError = await requireAdmin();
if (authError) return authError;
try {
const body = await request.json();
const parsed = checkTypeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { type } = parsed.data;
const tools =
type === "published"
? await prisma.tool.findMany({
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
})
: await prisma.pendingTool.findMany({
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
});
const CONCURRENCY = 10;
const results: CheckResult[] = [];
for (let i = 0; i < tools.length; i += CONCURRENCY) {
const batch = tools.slice(i, i + CONCURRENCY);
const batchResults = await Promise.allSettled(
batch.map((tool) => checkSingleTool(tool, type))
);
for (const r of batchResults) {
if (r.status === "fulfilled") {
results.push(r.value);
}
}
}
return NextResponse.json({ results });
+6 -6
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { newsImportSchema } from "@/lib/validation";
function getTodayDate() {
const now = new Date();
@@ -13,14 +14,13 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { items } = body as {
items: { title: string; summary?: string; detail?: string; category?: string; sourceUrl?: string; hotScore?: number }[];
};
if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json({ error: "items 数组不能为空" }, { status: 400 });
const parsed = newsImportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { items } = parsed.data;
const today = getTodayDate();
const existing = await prisma.dailyReport.findUnique({
+5 -10
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { skillImportSchema } from "@/lib/validation";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -8,18 +9,12 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { skills, categoryId } = body as {
skills: { name: string; description?: string; sourceUrl?: string; sourceType?: string; rating?: number; features?: string[]; tags?: string[] }[];
categoryId: number;
};
if (!skills || !Array.isArray(skills) || skills.length === 0) {
return NextResponse.json({ error: "skills 数组不能为空" }, { status: 400 });
const parsed = skillImportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
if (!categoryId) {
return NextResponse.json({ error: "categoryId 不能为空" }, { status: 400 });
}
const { skills, categoryId } = parsed.data;
const [existingPending, existingPublished] = await Promise.all([
prisma.pendingSkill.findMany({ select: { name: true, sourceUrl: true } }),
+5 -10
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/admin-auth";
import { toolImportSchema } from "@/lib/validation";
export async function POST(request: Request) {
const authError = await requireAdmin();
@@ -8,18 +9,12 @@ export async function POST(request: Request) {
try {
const body = await request.json();
const { tools, categoryId } = body as {
tools: { name: string; description?: string; websiteUrl?: string; logoUrl?: string; pricingModel?: string; pricingDetail?: string; features?: string[]; tags?: string[] }[];
categoryId: number;
};
if (!tools || !Array.isArray(tools) || tools.length === 0) {
return NextResponse.json({ error: "tools 数组不能为空" }, { status: 400 });
const parsed = toolImportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
if (!categoryId) {
return NextResponse.json({ error: "categoryId 不能为空" }, { status: 400 });
}
const { tools, categoryId } = parsed.data;
const [existingPending, existingPublished] = await Promise.all([
prisma.pendingTool.findMany({ select: { name: true, websiteUrl: true } }),
@@ -11,12 +11,18 @@ export async function POST(request: Request, { params }: { params: { id: string
const pending = await prisma.pendingSkill.findUnique({ where: { id } });
if (!pending) return NextResponse.json({ error: "未找到" }, { status: 404 });
if (pending.status !== "pending") {
return NextResponse.json({ error: "该记录已被处理" }, { status: 409 });
}
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 tags = Array.isArray(pending.tags) ? pending.tags as any[] : [];
const skill = await prisma.skill.create({
const skillRef: { current: { id: number } | null } = { current: null };
await prisma.$transaction(async (tx) => {
skillRef.current = await tx.skill.create({
data: {
name: pending.name,
slug,
@@ -30,9 +36,10 @@ export async function POST(request: Request, { params }: { params: { id: string
status: "published",
},
});
await tx.pendingSkill.delete({ where: { id } });
});
await prisma.pendingSkill.delete({ where: { id } });
return NextResponse.json({ skill });
return NextResponse.json({ skill: skillRef.current });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
+16
View File
@@ -17,6 +17,22 @@ export async function GET(request: Request) {
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
name: true,
slug: true,
description: true,
sourceUrl: true,
sourceType: true,
rating: true,
features: true,
tags: true,
categoryId: true,
status: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.pendingSkill.count({ where: { status: "pending" } }),
]);
@@ -39,6 +39,10 @@ export async function POST(
return NextResponse.json({ error: "待审核工具不存在" }, { status: 404 });
}
if (pending.status !== "pending") {
return NextResponse.json({ error: "该记录已被处理" }, { status: 409 });
}
let slug = pinyinSlug(pending.name);
const existing = await prisma.tool.findUnique({ where: { slug } });
if (existing) {
@@ -49,7 +53,9 @@ export async function POST(
await prisma.category.findFirst({ orderBy: { sortOrder: "asc" } })
)?.id ?? 1);
const tool = await prisma.tool.create({
let toolId = 0;
await prisma.$transaction(async (tx) => {
const tool = await tx.tool.create({
data: {
name: pending.name,
slug,
@@ -64,12 +70,13 @@ export async function POST(
status: "published",
},
});
await prisma.pendingTool.delete({ where: { id } });
toolId = tool.id;
await tx.pendingTool.delete({ where: { id } });
});
return NextResponse.json({
success: true,
toolId: tool.id,
toolId,
message: "发布成功",
});
} catch (error: unknown) {
+85
View File
@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const tasks = [
{ taskKey: "task1-discover-tools", taskName: "发现AI工具", cronExpr: "0 6 * * *", enabled: true, desc: "每天从多源搜索新AI工具,过滤去重后入库等待审核" },
{ taskKey: "task3-check-tools", taskName: "工具巡检", cronExpr: "0 3 * * *", enabled: true, desc: "检查所有已入库工具的网站可用性、Logo完整性、自动发布合格工具" },
{ taskKey: "task4-discover-skills", taskName: "发现AI技能", cronExpr: "0 2 * * *", enabled: true, desc: "从GitHub搜索热门AI开源项目(≥1000 stars),获取README并入库" },
{ taskKey: "task5-update-stars", taskName: "更新星值", cronExpr: "0 1 * * *", enabled: true, desc: "更新所有GitHub技能的最新Stars数,计算日/周/月变化趋势" },
{ taskKey: "task6-review-hot", taskName: "评测热门", cronExpr: "0 4 * * *", enabled: true, desc: "对未评测的最热门2个AI技能进行五维深度评测并自动发布" },
{ taskKey: "daily-news", taskName: "AI日报生成", cronExpr: "30 7 * * *", enabled: true, desc: "搜索真实新闻素材,AI整理格式生成当日AI日报" },
];
const configs = await prisma.taskConfig.findMany();
const configMap = new Map(configs.map((c) => [c.taskKey, c]));
const merged = tasks.map((t) => {
const cfg = configMap.get(t.taskKey);
return {
taskKey: t.taskKey,
taskName: t.taskName,
cronExpr: cfg?.cronExpr || t.cronExpr,
enabled: cfg?.enabled ?? t.enabled,
description: t.desc,
config: cfg?.config || null,
updatedAt: cfg?.updatedAt?.toISOString() || null,
};
});
return NextResponse.json({ tasks: merged });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function PUT(request: Request) {
try {
const body = await request.json();
const taskKey = body.taskKey as string;
const { enabled, cronExpr, config } = body;
if (!taskKey) {
return NextResponse.json({ error: "缺少taskKey" }, { status: 400 });
}
const existing = await prisma.taskConfig.findUnique({ where: { taskKey } });
let result;
if (existing) {
result = await prisma.taskConfig.update({
where: { taskKey },
data: {
enabled: enabled ?? existing.enabled,
cronExpr: cronExpr ?? existing.cronExpr,
config: config ?? existing.config,
},
});
} else {
const taskMeta: Record<string, string> = {
"task1-discover-tools": "发现AI工具",
"task3-check-tools": "工具巡检",
"task4-discover-skills": "发现AI技能",
"task5-update-stars": "更新星值",
"task6-review-hot": "评测热门",
"daily-news": "AI日报生成",
};
result = await prisma.taskConfig.create({
data: {
taskKey,
taskName: taskMeta[taskKey] || taskKey,
enabled: enabled ?? true,
cronExpr: cronExpr || null,
config: config || null,
},
});
}
return NextResponse.json({ task: result });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const taskKey = searchParams.get("taskKey");
const page = parseInt(searchParams.get("page") || "1");
const pageSize = parseInt(searchParams.get("pageSize") || "20");
const where: Record<string, unknown> = {};
if (taskKey) where.taskKey = taskKey;
const [logs, total] = await Promise.all([
prisma.taskLog.findMany({
where,
orderBy: { startedAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.taskLog.count({ where }),
]);
return NextResponse.json({
logs: logs.map((l) => ({
...l,
startedAt: l.startedAt.toISOString(),
finishedAt: l.finishedAt?.toISOString() || null,
createdAt: l.createdAt.toISOString(),
})),
total,
page,
pageSize,
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import { requireAdmin } from "@/lib/admin-auth";
import { taskRunSchema } from "@/lib/validation";
import path from "path";
const execAsync = promisify(exec);
const SCRIPT_MAP: Record<string, string> = {
"task1-discover-tools": "scripts/task1-discover-tools.mjs",
"task3-check-tools": "scripts/task3-check-tools.mjs",
"task4-discover-skills": "scripts/daily-discover.mjs",
"task5-update-stars": "scripts/task5-update-stars.mjs",
"task6-review-hot": "scripts/task6-review-hot.mjs",
"daily-news": "scripts/daily-news.mjs",
};
const PROJECT_ROOT = process.env.SCRIPTS_ROOT || process.cwd();
export async function POST(request: Request) {
const authError = await requireAdmin();
if (authError) return authError;
try {
const body = await request.json();
const parsed = taskRunSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { taskKey } = parsed.data;
const scriptPath = SCRIPT_MAP[taskKey];
if (!scriptPath) {
return NextResponse.json({ error: `未知任务: ${taskKey}` }, { status: 400 });
}
const fullPath = path.join(PROJECT_ROOT, scriptPath);
const result: { taskKey: string; status: string; pid: number; output: string } = {
taskKey,
status: "started",
pid: 0,
output: "",
};
try {
const { stdout, stderr } = await execAsync(
`node "${fullPath}" 2>&1`,
{
timeout: 120000,
maxBuffer: 1024 * 1024 * 2,
env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: "0" },
}
);
result.status = stderr ? "completed_with_errors" : "completed";
result.output = (stdout + (stderr ? "\n" + stderr : "")).slice(-5000);
} catch (execError: unknown) {
const e = execError as { message?: string; stdout?: string; stderr?: string };
result.status = "failed";
result.output = (e.stdout || "") + "\n" + (e.stderr || "") + "\n" + (e.message || "");
}
return NextResponse.json(result);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+18 -9
View File
@@ -1,24 +1,29 @@
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { registerSchema } from "@/lib/validation";
import { rateLimit } from "@/lib/rate-limit";
export async function POST(request: Request) {
const body = await request.json();
const { email, password, name } = body;
if (!email || !password) {
return NextResponse.json({ error: "邮箱和密码不能为空" }, { status: 400 });
try {
const ip = request.headers.get("x-forwarded-for") || "unknown";
if (!rateLimit(ip, 5, 60000)) {
return NextResponse.json({ error: "请求过于频繁,请稍后再试" }, { status: 429 });
}
const body = await request.json();
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { email, password, name } = parsed.data;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "该邮箱已注册" }, { status: 409 });
}
if (password.length < 6) {
return NextResponse.json({ error: "密码至少6位" }, { status: 400 });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
@@ -33,4 +38,8 @@ export async function POST(request: Request) {
{ id: user.id, email: user.email, name: user.name },
{ status: 201 }
);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "注册失败" }, { status: 500 });
}
}
+67
View File
@@ -0,0 +1,67 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
export async function POST(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const commentId = parseInt(params.id);
const [existing] = await prisma.commentLike.findMany({
where: { commentId, userId },
});
if (existing) {
await prisma.commentLike.delete({
where: { commentId_userId: { commentId, userId } },
});
await prisma.comment.update({
where: { id: commentId },
data: { likeCount: { decrement: 1 } },
});
return NextResponse.json({ liked: false });
}
const result = await prisma.$transaction(async (tx) => {
await tx.commentLike.create({
data: { commentId, userId },
});
await tx.comment.update({
where: { id: commentId },
data: { likeCount: { increment: 1 } },
});
const comment = await tx.comment.findUnique({
where: { id: commentId },
select: { userId: true },
});
if (comment && comment.userId !== userId) {
await tx.notification.create({
data: {
userId: comment.userId,
type: "COMMENT_LIKE",
title: `${session.user.name || "用户"} 点赞了你的评论`,
link: "/notifications",
},
});
}
return comment;
});
if (result) {
await rewardPoints(result.userId, "comment_like", "评论被点赞", "comment", commentId);
}
return NextResponse.json({ liked: true });
}
+206
View File
@@ -0,0 +1,206 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const toolId = searchParams.get("toolId");
const skillId = searchParams.get("skillId");
const parentId = searchParams.get("parentId");
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
if (!toolId && !skillId) {
return NextResponse.json({ error: "必须提供 toolId 或 skillId" }, { status: 400 });
}
const where: any = { status: "ACTIVE" };
if (toolId) where.toolId = parseInt(toolId);
if (skillId) where.skillId = parseInt(skillId);
if (parentId) where.parentId = parseInt(parentId);
else where.parentId = null;
const [comments, totalCount] = await Promise.all([
prisma.comment.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
likes: { select: { userId: true } },
replies: {
where: { status: "ACTIVE" },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
likes: { select: { userId: true } },
},
orderBy: { createdAt: "asc" },
take: 5,
},
_count: { select: { replies: true } },
},
orderBy: [
{ isPinned: "desc" },
{ createdAt: "desc" },
],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.comment.count({ where }),
]);
const session = await getServerSession(authOptions);
const currentUserId = session?.user?.id ? parseInt(session.user.id) : null;
const formatted = comments.map((c: any) => ({
id: c.id,
content: c.content,
rating: c.rating,
likeCount: c.likeCount,
isPinned: c.isPinned,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
replyCount: c._count.replies,
liked: currentUserId ? c.likes.some((l: any) => l.userId === currentUserId) : false,
user: {
id: c.user.id,
name: c.user.name || c.user.email.split("@")[0],
avatarUrl: c.user.avatarUrl,
level: c.user.level,
points: c.user.points,
},
replies: c.replies.map((r: any) => ({
id: r.id,
content: r.content,
likeCount: r.likeCount,
createdAt: r.createdAt,
liked: currentUserId ? r.likes.some((l: any) => l.userId === currentUserId) : false,
user: {
id: r.user.id,
name: r.user.name || r.user.email.split("@")[0],
avatarUrl: r.user.avatarUrl,
level: r.user.level,
points: r.user.points,
},
})),
}));
return NextResponse.json({ comments: formatted, totalCount, page, pageSize });
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const body = await req.json();
const { toolSlug, skillSlug, content, rating, parentId } = body;
const userId = parseInt(session.user.id);
if (!content || !content.trim()) {
return NextResponse.json({ error: "评论内容不能为空" }, { status: 400 });
}
if (content.trim().length > 2000) {
return NextResponse.json({ error: "评论内容不能超过2000个字符" }, { status: 400 });
}
let toolId: number | undefined;
let skillId: number | undefined;
if (toolSlug) {
const tool = await prisma.tool.findUnique({ where: { slug: toolSlug } });
if (!tool) return NextResponse.json({ error: "工具不存在" }, { status: 404 });
toolId = tool.id;
} else if (skillSlug) {
const skill = await prisma.skill.findUnique({ where: { slug: skillSlug } });
if (!skill) return NextResponse.json({ error: "技能不存在" }, { status: 404 });
skillId = skill.id;
} else {
return NextResponse.json({ error: "必须提供 toolSlug 或 skillSlug" }, { status: 400 });
}
if (!parentId && rating && (rating < 1 || rating > 5)) {
return NextResponse.json({ error: "评分必须为1-5" }, { status: 400 });
}
const comment = await prisma.$transaction(async (tx) => {
const newComment = await tx.comment.create({
data: {
userId,
toolId,
skillId,
parentId: parentId || null,
content: content.trim(),
rating: parentId ? null : rating || null,
},
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
},
});
if (!parentId) {
if (toolId) {
await tx.tool.update({
where: { id: toolId },
data: { commentCount: { increment: 1 } },
});
}
if (skillId) {
await tx.skill.update({
where: { id: skillId },
data: { commentCount: { increment: 1 } },
});
}
}
const parentComment = parentId
? await tx.comment.findUnique({ where: { id: parentId }, select: { userId: true } })
: null;
if (parentComment && parentComment.userId !== userId) {
const replyTo = await tx.user.findUnique({
where: { id: parentId ? (await tx.comment.findUnique({ where: { id: parentId } }))?.userId : undefined },
select: { id: true, name: true },
});
if (replyTo) {
await tx.notification.create({
data: {
userId: replyTo.id,
type: "COMMENT_REPLY",
title: `${session.user.name || "用户"} 回复了你的评论`,
content: content.trim().slice(0, 100),
link: toolSlug ? `/tools/${toolSlug}` : `/skills/${skillSlug}`,
},
});
}
}
return newComment;
});
if (!parentId) {
await rewardPoints(userId, "comment", `评论${toolSlug || skillSlug}`, toolSlug ? "tool" : "skill", toolId || skillId);
}
const formatted = {
id: comment.id,
content: comment.content,
rating: comment.rating,
likeCount: 0,
isPinned: comment.isPinned,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
replyCount: 0,
liked: false,
user: {
id: comment.user.id,
name: comment.user.name || comment.user.email.split("@")[0],
avatarUrl: comment.user.avatarUrl,
level: comment.user.level,
points: comment.user.points,
},
};
return NextResponse.json(formatted, { status: 201 });
}
+16
View File
@@ -0,0 +1,16 @@
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET() {
const categories = await prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return NextResponse.json({ categories });
}
@@ -0,0 +1,84 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function PUT(req: Request, { params }: { params: { id: string; postId: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const postId = parseInt(params.postId);
const userId = parseInt(session.user.id);
const post = await prisma.forumPost.findUnique({ where: { id: postId } });
if (!post) {
return NextResponse.json({ error: "回复不存在" }, { status: 404 });
}
if (post.userId !== userId) {
return NextResponse.json({ error: "无权操作" }, { status: 403 });
}
const body = await req.json();
const { content } = body;
if (!content || !content.trim()) {
return NextResponse.json({ error: "回复内容不能为空" }, { status: 400 });
}
if (content.trim().length > 5000) {
return NextResponse.json({ error: "回复不能超过5000个字符" }, { status: 400 });
}
const updated = await prisma.forumPost.update({
where: { id: postId },
data: { content: content.trim() },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
},
});
return NextResponse.json({
id: updated.id,
content: updated.content,
isAnswer: updated.isAnswer,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
user: {
id: updated.user.id,
name: updated.user.name || updated.user.email.split("@")[0],
avatarUrl: updated.user.avatarUrl,
level: updated.user.level,
points: updated.user.points,
},
});
}
export async function DELETE(req: Request, { params }: { params: { id: string; postId: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const postId = parseInt(params.postId);
const userId = parseInt(session.user.id);
const post = await prisma.forumPost.findUnique({ where: { id: postId }, include: { topic: true } });
if (!post) {
return NextResponse.json({ error: "回复不存在" }, { status: 404 });
}
if (post.userId !== userId) {
return NextResponse.json({ error: "无权操作" }, { status: 403 });
}
await prisma.forumPost.delete({ where: { id: postId } });
await prisma.forumTopic.update({
where: { id: post.topicId },
data: {
replyCount: { decrement: 1 },
},
});
return NextResponse.json({ success: true });
}
@@ -0,0 +1,126 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
export async function GET(req: Request, { params }: { params: { id: string } }) {
const topicId = parseInt(params.id);
const { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const [posts, totalCount] = await Promise.all([
prisma.forumPost.findMany({
where: { topicId },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
},
orderBy: { createdAt: "asc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumPost.count({ where: { topicId } }),
]);
const formatted = posts.map((p) => ({
id: p.id,
content: p.content,
isAnswer: p.isAnswer,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
user: {
id: p.user.id,
name: p.user.name || p.user.email.split("@")[0],
avatarUrl: p.user.avatarUrl,
level: p.user.level,
points: p.user.points,
},
}));
return NextResponse.json({ posts: formatted, totalCount, page, pageSize });
}
export async function POST(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
const body = await req.json();
const { content } = body;
if (!content || !content.trim()) {
return NextResponse.json({ error: "回复内容不能为空" }, { status: 400 });
}
if (content.trim().length > 5000) {
return NextResponse.json({ error: "回复不能超过5000个字符" }, { status: 400 });
}
const topic = await prisma.forumTopic.findUnique({ where: { id: topicId } });
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.isLocked) {
return NextResponse.json({ error: "话题已锁定" }, { status: 400 });
}
const post = await prisma.$transaction(async (tx) => {
const newPost = await tx.forumPost.create({
data: {
topicId,
userId,
content: content.trim(),
},
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
},
});
const now = new Date();
await tx.forumTopic.update({
where: { id: topicId },
data: {
replyCount: { increment: 1 },
lastReplyAt: now,
updatedAt: now,
},
});
if (topic.userId !== userId) {
await tx.notification.create({
data: {
userId: topic.userId,
type: "TOPIC_REPLY",
title: `${session.user.name || "用户"} 回复了你的话题`,
content: content.trim().slice(0, 100),
link: `/community/topic/${topicId}`,
},
});
}
return newPost;
});
await rewardPoints(userId, "forum_post", `回复话题`, "topic", topicId);
return NextResponse.json(
{
id: post.id,
content: post.content,
isAnswer: post.isAnswer,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
user: {
id: post.user.id,
name: post.user.name || post.user.email.split("@")[0],
avatarUrl: post.user.avatarUrl,
level: post.user.level,
points: post.user.points,
},
},
{ status: 201 }
);
}
+113
View File
@@ -0,0 +1,113 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET(req: Request, { params }: { params: { id: string } }) {
const topicId = parseInt(params.id);
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
},
});
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
await prisma.forumTopic.update({
where: { id: topicId },
data: { viewCount: { increment: 1 } },
});
return NextResponse.json({
topic: {
id: topic.id,
title: topic.title,
slug: topic.slug,
content: topic.content,
isPinned: topic.isPinned,
isLocked: topic.isLocked,
viewCount: topic.viewCount + 1,
replyCount: topic._count.posts,
lastReplyAt: topic.lastReplyAt,
createdAt: topic.createdAt,
updatedAt: topic.updatedAt,
category: topic.category,
user: {
id: topic.user.id,
name: topic.user.name || topic.user.email.split("@")[0],
avatarUrl: topic.user.avatarUrl,
level: topic.user.level,
points: topic.user.points,
},
},
});
}
export async function PUT(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
const topic = await prisma.forumTopic.findUnique({ where: { id: topicId } });
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.userId !== userId) {
return NextResponse.json({ error: "无权操作" }, { status: 403 });
}
if (topic.isLocked) {
return NextResponse.json({ error: "话题已锁定" }, { status: 400 });
}
const body = await req.json();
const { title, content } = body;
if (title && title.length > 100) {
return NextResponse.json({ error: "标题不能超过100个字符" }, { status: 400 });
}
if (content && content.length > 10000) {
return NextResponse.json({ error: "内容不能超过10000个字符" }, { status: 400 });
}
const updated = await prisma.forumTopic.update({
where: { id: topicId },
data: {
title: title?.trim(),
content: content?.trim(),
},
});
return NextResponse.json(updated);
}
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const topicId = parseInt(params.id);
const userId = parseInt(session.user.id);
const topic = await prisma.forumTopic.findUnique({ where: { id: topicId } });
if (!topic) {
return NextResponse.json({ error: "话题不存在" }, { status: 404 });
}
if (topic.userId !== userId) {
return NextResponse.json({ error: "无权操作" }, { status: 403 });
}
await prisma.forumTopic.delete({ where: { id: topicId } });
return NextResponse.json({ success: true });
}
+136
View File
@@ -0,0 +1,136 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { rewardPoints } from "@/lib/points-reward";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const category = searchParams.get("category");
const search = searchParams.get("search");
const sort = searchParams.get("sort") || "latest";
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const where: any = {};
if (category) where.category = { slug: category };
if (search) {
where.title = { contains: search };
}
const orderBy: any = {};
if (sort === "latest") orderBy.createdAt = "desc";
else if (sort === "hot") orderBy.viewCount = "desc";
else if (sort === "active") orderBy.lastReplyAt = "desc";
else orderBy.createdAt = "desc";
const [topics, totalCount] = await Promise.all([
prisma.forumTopic.findMany({
where,
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
},
orderBy: [{ isPinned: "desc" }, orderBy],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumTopic.count({ where }),
]);
const formatted = topics.map((t) => ({
id: t.id,
title: t.title,
slug: t.slug,
isPinned: t.isPinned,
isLocked: t.isLocked,
viewCount: t.viewCount,
replyCount: t._count.posts,
lastReplyAt: t.lastReplyAt,
createdAt: t.createdAt,
category: t.category,
user: {
id: t.user.id,
name: t.user.name || t.user.email.split("@")[0],
avatarUrl: t.user.avatarUrl,
level: t.user.level,
points: t.user.points,
},
}));
return NextResponse.json({ topics: formatted, totalCount, page, pageSize });
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const body = await req.json();
const { categorySlug, title, content } = body;
const userId = parseInt(session.user.id);
if (!categorySlug || !title || !content) {
return NextResponse.json({ error: "请填写完整信息" }, { status: 400 });
}
if (title.length > 100) {
return NextResponse.json({ error: "标题不能超过100个字符" }, { status: 400 });
}
if (content.length > 10000) {
return NextResponse.json({ error: "内容不能超过10000个字符" }, { status: 400 });
}
const category = await prisma.forumCategory.findUnique({
where: { slug: categorySlug },
});
if (!category) {
return NextResponse.json({ error: "板块不存在" }, { status: 404 });
}
const slug = `${title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]/g, "-")}-${Date.now()}`.slice(0, 120);
const topic = await prisma.$transaction(async (tx) => {
const newTopic = await tx.forumTopic.create({
data: {
categoryId: category.id,
userId,
title: title.trim(),
slug,
content: content.trim(),
},
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
category: { select: { name: true, slug: true, icon: true } },
},
});
await tx.forumCategory.update({
where: { id: category.id },
data: { topicCount: { increment: 1 } },
});
return newTopic;
});
await rewardPoints(userId, "forum_topic", `发布话题: ${title}`, "topic", topic.id);
return NextResponse.json(
{
id: topic.id,
title: topic.title,
slug: topic.slug,
category: topic.category,
user: {
id: topic.user.id,
name: topic.user.name || topic.user.email.split("@")[0],
avatarUrl: topic.user.avatarUrl,
level: topic.user.level,
points: topic.user.points,
},
},
{ status: 201 }
);
}
+54
View File
@@ -0,0 +1,54 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const [notifications, totalCount, unreadCount] = await Promise.all([
prisma.notification.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.notification.count({ where: { userId } }),
prisma.notification.count({ where: { userId, isRead: false } }),
]);
return NextResponse.json({ notifications, totalCount, unreadCount, page, pageSize });
}
export async function PUT(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const body = await req.json();
const { ids } = body;
if (ids && Array.isArray(ids)) {
await prisma.notification.updateMany({
where: { id: { in: ids }, userId },
data: { isRead: true },
});
} else {
await prisma.notification.updateMany({
where: { userId, isRead: false },
data: { isRead: true },
});
}
return NextResponse.json({ success: true });
}
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const [topReviews, topStarsChange, allReviews] = await Promise.all([
prisma.skillReview.findMany({
where: { status: "PUBLISHED" },
include: {
skill: { select: { id: true, name: true, slug: true, description: true, githubStars: true } },
},
orderBy: { overall: "desc" },
take: 10,
}),
prisma.skill.findMany({
where: { githubStars: { gt: 0 }, starsChangeWeek: { not: 0 } },
select: { id: true, name: true, slug: true, githubStars: true, starsChangeDay: true, starsChangeWeek: true, starsChangeMonth: true },
orderBy: { starsChangeWeek: "desc" },
take: 10,
}),
prisma.skillReview.count({ where: { status: "PUBLISHED" } }),
]);
return NextResponse.json(
{
topByRating: topReviews.map((r) => ({
id: r.id,
overall: r.overall,
capability: r.capability,
devExp: r.devExp,
costLicense: r.costLicense,
community: r.community,
performance: r.performance,
skill: { id: r.skill.id, name: r.skill.name, slug: r.skill.slug, stars: r.skill.githubStars },
})),
topByStarsChange: topStarsChange.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
githubStars: s.githubStars,
starsChangeDay: s.starsChangeDay,
starsChangeWeek: s.starsChangeWeek,
starsChangeMonth: s.starsChangeMonth,
})),
totalReviews: allReviews,
},
{
headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" },
}
);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
@@ -3,6 +3,30 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET(
_request: Request,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const collectionId = parseInt(params.id);
const userId = parseInt(session.user.id);
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
include: { _count: { select: { items: true } } },
});
if (!collection || collection.userId !== userId) {
return NextResponse.json({ error: "收藏夹不存在或无权限" }, { status: 403 });
}
return NextResponse.json(collection);
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
+18 -3
View File
@@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
@@ -14,7 +15,7 @@ export async function GET(req: Request) {
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const where: any = { userId: parseInt(session.user.id) };
const where: Record<string, unknown> = { userId: parseInt(session.user.id) };
if (type === "tool") {
where.toolId = { not: null };
} else if (type === "skill") {
@@ -36,9 +37,14 @@ export async function GET(req: Request) {
]);
return NextResponse.json({ favorites, total, page, pageSize });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取收藏失败" }, { status: 500 });
}
}
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
@@ -64,7 +70,7 @@ export async function POST(req: Request) {
if (!skill) return NextResponse.json({ error: "技能不存在" }, { status: 404 });
}
const where: any = { userId };
const where: Record<string, unknown> = { userId };
if (toolId) where.toolId = toolId;
if (skillId) where.skillId = skillId;
@@ -82,9 +88,14 @@ export async function POST(req: Request) {
});
return NextResponse.json(fav, { status: 201 });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "添加收藏失败" }, { status: 500 });
}
}
export async function DELETE(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
@@ -101,11 +112,15 @@ export async function DELETE(req: Request) {
);
}
const where: any = { userId };
const where: Record<string, unknown> = { userId };
if (toolId) where.toolId = toolId;
if (skillId) where.skillId = skillId;
const deleted = await prisma.favorite.deleteMany({ where });
return NextResponse.json({ success: true, deleted: deleted.count });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "取消收藏失败" }, { status: 500 });
}
}
+10
View File
@@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
@@ -42,9 +43,14 @@ export async function GET() {
});
return NextResponse.json({ history });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取历史记录失败" }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
@@ -75,4 +81,8 @@ export async function POST(request: Request) {
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "记录浏览历史失败" }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
import { getUserPointsHistory, getUserStats, getLevelInfo, getReasonLabel } from "@/lib/points-reward";
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = parseInt(session.user.id);
const { searchParams } = new URL(req.url);
const type = searchParams.get("type");
if (type === "history") {
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
const { history, total } = await getUserPointsHistory(userId, page, pageSize);
const formatted = history.map((h) => ({
id: h.id,
points: h.points,
reason: getReasonLabel(h.reason),
detail: h.detail,
createdAt: h.createdAt,
}));
return NextResponse.json({ history: formatted, total, page, pageSize });
}
if (type === "stats") {
const stats = await getUserStats(userId);
return NextResponse.json(stats);
}
const stats = await getUserStats(userId);
return NextResponse.json(stats);
}
+20 -3
View File
@@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
@@ -15,8 +16,10 @@ export async function GET() {
id: true,
email: true,
name: true,
bio: true,
avatarUrl: true,
role: true,
createdAt: true,
},
});
@@ -24,33 +27,47 @@ export async function GET() {
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
}
return NextResponse.json(user);
return NextResponse.json({ id: user.id, email: user.email, name: user.name, bio: user.bio, avatarUrl: user.avatarUrl, createdAt: user.createdAt });
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "获取用户信息失败" }, { status: 500 });
}
}
export async function PUT(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const body = await request.json();
const { name } = body;
const { name, bio } = body;
if (name !== undefined && (typeof name !== "string" || name.length > 100)) {
return NextResponse.json({ error: "昵称格式不正确" }, { status: 400 });
}
if (bio !== undefined && (typeof bio !== "string" || bio.length > 500)) {
return NextResponse.json({ error: "简介不能超过500个字符" }, { status: 400 });
}
const user = await prisma.user.update({
where: { id: parseInt(session.user.id) },
data: { name: name || null },
data: { name: name || null, bio: bio ?? undefined },
select: {
id: true,
email: true,
name: true,
bio: true,
avatarUrl: true,
role: true,
createdAt: true,
},
});
return NextResponse.json(user);
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "更新用户信息失败" }, { status: 500 });
}
}
@@ -0,0 +1,216 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
category: { id: number; name: string; slug: string } | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
};
interface Props {
initialTools: Tool[];
initialTotal: number;
initialPage: number;
categoryId: number;
categoryName: string;
}
export function CategoryContent({ initialTools, initialTotal, initialPage, categoryId, categoryName }: Props) {
const router = useRouter();
const [tools, setTools] = useState<Tool[]>(initialTools);
const [total, setTotal] = useState(initialTotal);
const [page, setPage] = useState(initialPage);
const [loading, setLoading] = useState(false);
const [isInitial, setIsInitial] = useState(true);
const pageSize = 12;
const fetchTools = useCallback(async (p: number) => {
setLoading(true);
try {
const res = await fetch(
`/api/tools?categoryId=${categoryId}&page=${p}&pageSize=${pageSize}`
);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
setTools([]);
} finally {
setLoading(false);
}
}, [categoryId]);
const handlePageChange = (newPage: number) => {
if (isInitial) setIsInitial(false);
setPage(newPage);
fetchTools(newPage);
};
const totalPages = Math.ceil(total / pageSize);
if (!isInitial && loading) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/30" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/20" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
if (tools.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p className="mt-4 text-sm text-muted-foreground">该分类暂无工具</p>
<Button variant="ghost" size="sm" className="mt-2 text-muted-foreground" onClick={() => router.push("/tools")}>
浏览全部工具
</Button>
</div>
);
}
return (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tools.map((tool, i) => (
<Card
key={tool.id}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 60}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{tool.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
<div className="mt-1 flex items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/50">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{tool.viewCount}
</span>
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
{totalPages > 1 && (
<div className="mt-10 flex items-center justify-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
className="gap-1.5 rounded-lg"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
上一页
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
page === pageNum
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{pageNum}
</button>
);
})}
</div>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
className="gap-1.5 rounded-lg"
>
下一页
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</Button>
</div>
)}
</>
);
}
+50 -228
View File
@@ -1,42 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface Category {
id: number;
name: string;
slug: string;
children: Category[];
}
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
category: { id: number; name: string; slug: string } | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
};
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { Metadata } from "next";
import { CategoryContent } from "./CategoryContent";
const categoryAccent: Record<string, { bg: string; text: string; border: string }> = {
"文本生成": { bg: "bg-blue-50", text: "text-blue-700", border: "border-blue-200/60" },
@@ -51,64 +16,47 @@ const categoryAccent: Record<string, { bg: string; text: string; border: string
"内容检测": { bg: "bg-indigo-50", text: "text-indigo-700", border: "border-indigo-200/60" },
};
export default function CategoryPage() {
const router = useRouter();
const params = useParams();
const slug = params.slug as string;
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string }>;
}
const [categoryName, setCategoryName] = useState("");
const [categoryId, setCategoryId] = useState<number | null>(null);
const [tools, setTools] = useState<Tool[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const category = await prisma.category.findUnique({
where: { slug },
select: { name: true },
});
if (!category) {
return { title: "分类不存在" };
}
return {
title: `${category.name} - AI 工具分类`,
description: `浏览${category.name}分类下的所有 AI 工具`,
};
}
export default async function CategoryPage({ params, searchParams }: Props) {
const { slug } = await params;
const { page: pageStr } = await searchParams;
const page = Math.max(1, parseInt(pageStr || "1"));
const pageSize = 12;
useEffect(() => {
fetch("/api/categories")
.then((r) => r.json())
.then((cats: Category[]) => {
const find = (list: Category[]): Category | undefined => {
for (const c of list) {
if (c.slug === slug) return c;
const found = find(c.children);
if (found) return found;
}
return undefined;
};
const cat = find(cats);
if (cat) {
setCategoryName(cat.name);
setCategoryId(cat.id);
}
})
.catch(() => {});
}, [slug]);
const category = await prisma.category.findUnique({ where: { slug } });
if (!category) notFound();
const fetchTools = useCallback(async () => {
if (!categoryId) return;
setLoading(true);
try {
const res = await fetch(
`/api/tools?categoryId=${categoryId}&page=${page}&pageSize=${pageSize}`
);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
setTools([]);
} finally {
setLoading(false);
}
}, [categoryId, page]);
const [tools, total] = await Promise.all([
prisma.tool.findMany({
where: { categoryId: category.id, status: "published" },
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { viewCount: "desc" },
include: { category: { select: { id: true, name: true, slug: true } } },
}),
prisma.tool.count({ where: { categoryId: category.id, status: "published" } }),
]);
useEffect(() => {
fetchTools();
}, [fetchTools]);
const totalPages = Math.ceil(total / pageSize);
const accent = categoryAccent[categoryName] || { bg: "bg-primary/10", text: "text-primary", border: "border-primary/20" };
const accent = categoryAccent[category.name] || { bg: "bg-primary/10", text: "text-primary", border: "border-primary/20" };
return (
<div className="relative">
@@ -119,11 +67,11 @@ export default function CategoryPage() {
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 animate-fade-in">
<nav className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<a href="/" className="transition-colors hover:text-foreground">首页</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<button onClick={() => router.push("/tools")} className="transition-colors hover:text-foreground">全部工具</button>
<a href="/tools" className="transition-colors hover:text-foreground">全部工具</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground">{categoryName || "分类"}</span>
<span className="text-foreground">{category.name}</span>
</nav>
<div className="mb-10">
@@ -134,7 +82,7 @@ export default function CategoryPage() {
<span className={`text-sm font-medium ${accent.text}`}>分类</span>
</div>
<h1 className="text-3xl font-bold tracking-tight">
{categoryName || "分类"}
{category.name}
</h1>
<div className="mt-2 flex items-center gap-2">
<span className="text-sm text-muted-foreground">共</span>
@@ -143,139 +91,13 @@ export default function CategoryPage() {
</div>
</div>
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/30" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/20" />
</div>
</CardContent>
</Card>
))}
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p className="mt-4 text-sm text-muted-foreground">该分类暂无工具</p>
<Button variant="ghost" size="sm" className="mt-2 text-muted-foreground" onClick={() => router.push("/tools")}>
浏览全部工具
</Button>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tools.map((tool, i) => (
<Card
key={tool.id}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 60}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
<CategoryContent
initialTools={tools}
initialTotal={total}
initialPage={page}
categoryId={category.id}
categoryName={category.name}
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{tool.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
<div className="mt-1 flex items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/50">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{tool.viewCount}
</span>
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
{totalPages > 1 && (
<div className="mt-10 flex items-center justify-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
className="gap-1.5 rounded-lg"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
上一页
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => setPage(pageNum)}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
page === pageNum
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{pageNum}
</button>
);
})}
</div>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
className="gap-1.5 rounded-lg"
>
下一页
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</Button>
</div>
)}
</>
)}
</div>
</div>
);
+133
View File
@@ -0,0 +1,133 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
interface Props {
params: { category: string };
searchParams: { sort?: string; page?: string };
}
export default async function CategoryPage({ params, searchParams }: Props) {
const category = await prisma.forumCategory.findUnique({
where: { slug: params.category },
});
if (!category) notFound();
const sort = searchParams.sort || "latest";
const page = parseInt(searchParams.page || "1", 10);
const pageSize = 20;
const orderBy: any = {};
if (sort === "latest") orderBy.createdAt = "desc";
else if (sort === "hot") orderBy.viewCount = "desc";
else if (sort === "active") orderBy.lastReplyAt = "desc";
else orderBy.createdAt = "desc";
const [topics, totalCount] = await Promise.all([
prisma.forumTopic.findMany({
where: { categoryId: category.id },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
},
orderBy: [{ isPinned: "desc" }, orderBy],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.forumTopic.count({ where: { categoryId: category.id } }),
]);
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<a href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</a>
</div>
<div className="mb-6">
<h1 className="text-2xl font-bold">{category.name}</h1>
{category.description && (
<p className="mt-1 text-sm text-muted-foreground">{category.description}</p>
)}
</div>
<div className="flex items-center gap-2 mb-6">
{["latest", "hot", "active"].map((s) => (
<a
key={s}
href={`/community/${category.slug}?sort=${s}`}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
sort === s ? "bg-primary text-white" : "bg-white border border-border/40 text-muted-foreground hover:border-border/60"
}`}
>
{s === "latest" ? "最新" : s === "hot" ? "热门" : "活跃"}
</a>
))}
</div>
<div className="space-y-3">
{topics.map((topic) => {
const initial = topic.user.name?.charAt(0).toUpperCase() || "?";
return (
<a
key={topic.id}
href={`/community/topic/${topic.id}`}
className="flex items-start gap-3 p-4 rounded-xl border border-border/40 bg-white transition-all hover:border-border/60 hover:shadow-sm block"
>
{topic.user.avatarUrl ? (
<img src={topic.user.avatarUrl} alt="" className="h-9 w-9 rounded-full border border-border/40 object-cover flex-shrink-0" />
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary flex-shrink-0">
{initial}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{topic.isPinned && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full">置顶</span>
)}
{topic.isLocked && (
<span className="text-[10px] font-medium text-muted-foreground bg-muted/30 px-1.5 py-0.5 rounded-full">已锁定</span>
)}
<h3 className="text-sm font-medium hover:text-primary transition-colors truncate">
{topic.title}
</h3>
</div>
<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.createdAt.toLocaleDateString("zh-CN")}</span>
<span>{topic._count.posts}回复</span>
<span>{topic.viewCount}浏览</span>
</div>
</div>
</a>
);
})}
</div>
{totalCount > pageSize && (
<div className="flex justify-center gap-2 mt-6">
{Array.from({ length: Math.ceil(totalCount / pageSize) }, (_, i) => i + 1).map((p) => (
<a
key={p}
href={`/community/${category.slug}?sort=${sort}&page=${p}`}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
page === p
? "bg-primary text-white"
: "bg-white border border-border/40 text-muted-foreground hover:border-border/60"
}`}
>
{p}
</a>
))}
</div>
)}
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const dynamic = "force-dynamic";
async function getHotTopics() {
return prisma.forumTopic.findMany({
where: {},
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
},
orderBy: [{ isPinned: "desc" }, { createdAt: "desc" }],
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 (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-[1440px] mx-auto px-4 py-8">
<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">
<div className="lg:col-span-2 space-y-6">
<div>
<h2 className="text-lg font-semibold mb-4">板块</h2>
<div className="space-y-4">
{categories.map((cat) => (
<div key={cat.id}>
<div className="flex items-center gap-3 mb-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/5 text-lg">
{cat.icon || "📌"}
</div>
<h3 className="text-sm font-semibold">{cat.name}</h3>
{cat.description && (
<span className="text-xs text-muted-foreground/40">{cat.description}</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 ml-11">
{cat.children.map((child) => (
<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"
onClick={() => (window.location.href = `/community/${child.slug}`)}
>
<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>
</div>
))}
</div>
</div>
))}
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4">最新话题</h2>
<div className="space-y-3">
{hotTopics.map((topic) => {
const initial = topic.user.name?.charAt(0).toUpperCase() || "?";
return (
<div
key={topic.id}
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 ? (
<img src={topic.user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{initial}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{topic.isPinned && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full">置顶</span>
)}
<h3 className="text-sm font-medium hover:text-primary transition-colors truncate">
{topic.title}
</h3>
</div>
<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.category.name}</span>
<span>{topic._count.posts}回复</span>
<span>{topic.viewCount}浏览</span>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="space-y-6">
<div className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-semibold mb-4">活跃用户</h3>
<div className="space-y-3">
{activeUsers.map((user) => {
const initial = user.name?.charAt(0).toUpperCase() || "?";
return (
<div key={user.id} className="flex items-center gap-3">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{initial}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{user.name || user.email.split("@")[0]}</p>
<p className="text-xs text-muted-foreground/50">{user.points}分</p>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const dynamic = "force-dynamic";
interface Props {
params: { id: string };
}
export default async function TopicPage({ params }: Props) {
const session = await getServerSession(authOptions);
const topicId = parseInt(params.id);
const topic = await prisma.forumTopic.findUnique({
where: { id: topicId },
include: {
category: { select: { name: true, slug: true, icon: true } },
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
_count: { select: { posts: true } },
},
});
if (!topic) notFound();
await prisma.forumTopic.update({
where: { id: topicId },
data: { viewCount: { increment: 1 } },
});
const posts = await prisma.forumPost.findMany({
where: { topicId },
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } },
},
orderBy: { createdAt: "asc" },
take: 50,
});
const currentUserId = session?.user?.id ? parseInt(session.user.id) : null;
const isAuthor = currentUserId === topic.userId;
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<a href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</a>
</div>
<div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="p-6 border-b border-border/40">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-0.5 text-[10px] font-medium bg-primary/10 text-primary rounded-full">
{topic.category.name}
</span>
{topic.isPinned && (
<span className="px-2 py-0.5 text-[10px] font-medium bg-amber-50 text-amber-700 rounded-full">
置顶
</span>
)}
{topic.isLocked && (
<span className="px-2 py-0.5 text-[10px] font-medium bg-muted/30 text-muted-foreground rounded-full">
已锁定
</span>
)}
</div>
<h1 className="text-xl font-bold mb-4">{topic.title}</h1>
<div className="flex items-center gap-3 mb-6">
{topic.user.avatarUrl ? (
<img src={topic.user.avatarUrl} alt="" className="h-10 w-10 rounded-full border border-border/40 object-cover" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{topic.user.name?.charAt(0).toUpperCase() || "?"}
</div>
)}
<div>
<p className="text-sm font-medium">{topic.user.name || topic.user.email.split("@")[0]}</p>
<p className="text-xs text-muted-foreground/50">
{topic.createdAt.toLocaleDateString("zh-CN")} {topic.createdAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground/50">
<span>{topic.viewCount + 1}浏览</span>
<span>{topic._count.posts}回复</span>
</div>
</div>
<div className="prose prose-sm max-w-none text-muted-foreground/80 leading-relaxed whitespace-pre-wrap">
{topic.content}
</div>
</div>
</div>
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4">回复 ({posts.length})</h2>
<div className="space-y-4">
{posts.map((post) => {
const initial = post.user.name?.charAt(0).toUpperCase() || "?";
const isTopicAuthor = post.userId === topic.userId;
return (
<div key={post.id} className="rounded-xl border border-border/40 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex items-center gap-3 mb-3">
{post.user.avatarUrl ? (
<img src={post.user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{initial}
</div>
)}
<div>
<p className="text-sm font-medium">{post.user.name || post.user.email.split("@")[0]}</p>
<p className="text-xs text-muted-foreground/50">
{post.createdAt.toLocaleDateString("zh-CN")} {post.createdAt.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
{isTopicAuthor && (
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-medium bg-primary/10 text-primary rounded-full">
楼主
</span>
)}
</div>
<div className="text-sm text-muted-foreground/80 leading-relaxed whitespace-pre-wrap">
{post.content}
</div>
</div>
);
})}
</div>
</div>
{!topic.isLocked && (
<div className="mt-8">
<form
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const content = formData.get("content") as string;
if (!content.trim()) return;
const res = await fetch(`/api/forum/topics/${topicId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (res.ok) {
window.location.reload();
}
}}
className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
>
<h3 className="text-sm font-semibold mb-3">发表回复</h3>
<textarea
name="content"
placeholder="写下你的回复..."
rows={4}
maxLength={5000}
className="w-full resize-none rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors placeholder:text-muted-foreground/40 mb-3"
/>
<div className="flex justify-end">
<button
type="submit"
className="rounded-lg bg-primary text-white text-sm px-5 py-2 hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20"
>
发表回复
</button>
</div>
</form>
</div>
)}
{topic.isLocked && (
<div className="mt-8 text-center py-6 rounded-xl border border-border/40 bg-muted/20">
<p className="text-sm text-muted-foreground">此话题已锁定,无法回复</p>
</div>
)}
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4 text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-red-50 flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h2 className="text-lg font-semibold text-foreground">页面加载出错</h2>
<p className="text-sm text-muted-foreground">{error.message || "请稍后重试"}</p>
<button
onClick={reset}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
重新加载
</button>
</div>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground text-sm">加载中...</p>
</div>
</div>
)
}
+9 -129
View File
@@ -1,129 +1,21 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LoginForm } from "@/components/common/LoginForm";
export default function LoginPage() {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
if (mode === "register") {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name: name || undefined }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "注册失败");
}
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) throw new Error("自动登录失败,请手动登录");
router.push("/");
router.refresh();
} else {
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) throw new Error("邮箱或密码错误");
router.push("/");
router.refresh();
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md space-y-6 rounded-2xl border border-border/60 bg-white p-8 shadow-[0_4px_16px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.04)]">
<div className="text-center">
<h1 className="text-2xl font-bold">
{mode === "login" ? "欢迎回来" : "创建账号"}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{mode === "login" ? "登录以使用收藏等功能" : "注册后即可收藏喜欢的工具和技能"}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{mode === "register" && (
<input
type="text"
placeholder="昵称(选填)"
value={name}
onChange={(e) => setName(e.target.value)}
className="input-field !pl-3 !bg-white !border-border/70"
/>
)}
<input
type="email"
placeholder="邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="input-field !pl-3 !bg-white !border-border/70"
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="input-field !pl-3 !bg-white !border-border/70"
/>
{error && (
<p className="text-sm text-red-500 text-center">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 shadow-sm shadow-primary/20"
>
{loading
? "处理中..."
: mode === "login"
? "登录"
: "注册"}
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/40" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-muted-foreground">或</span>
</div>
</div>
<LoginForm
onSuccess={() => {
router.push("/");
router.refresh();
}}
socialLogin={
<button
type="button"
onClick={() => signIn("github", { callbackUrl: "/" })}
@@ -134,20 +26,8 @@ export default function LoginPage() {
</svg>
使用 GitHub 登录
</button>
<p className="text-sm text-center text-muted-foreground">
{mode === "login" ? "还没有账号?" : "已有账号?"}
<button
type="button"
onClick={() => {
setMode(mode === "login" ? "register" : "login");
setError("");
}}
className="ml-1 text-primary hover:underline font-medium"
>
{mode === "login" ? "去注册" : "去登录"}
</button>
</p>
}
/>
</div>
</div>
);
+19
View File
@@ -0,0 +1,19 @@
import Link from "next/link"
export default function NotFound() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4 text-center max-w-md">
<div className="text-7xl font-bold gradient-text">404</div>
<h2 className="text-lg font-semibold text-foreground">页面未找到</h2>
<p className="text-sm text-muted-foreground">您访问的页面不存在或已被移除</p>
<Link
href="/"
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
返回首页
</Link>
</div>
</div>
)
}
+46 -388
View File
@@ -1,400 +1,58 @@
"use client";
import { prisma } from "@/lib/prisma";
import type { Metadata } from "next";
import HomePageClient from "./HomePageClient";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
}
interface FlatCategory {
id: number;
name: string;
slug: string;
icon: string | null;
parentId: number | null;
toolCount: number;
tools: Tool[];
}
interface ParentCategory {
id: number;
name: string;
slug: string;
icon: string | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border border-emerald-200",
freemium: "bg-blue-50 text-blue-700 border border-blue-200",
paid: "bg-amber-50 text-amber-700 border border-amber-200",
subscription: "bg-purple-50 text-purple-700 border border-purple-200",
};
export default function HomePage() {
const router = useRouter();
const [categories, setCategories] = useState<FlatCategory[]>([]);
const [parentCategories, setParentCategories] = useState<ParentCategory[]>([]);
const [totalTools, setTotalTools] = useState(0);
const [totalSkills, setTotalSkills] = useState(0);
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<string>("");
const [search, setSearch] = useState("");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
useEffect(() => {
fetch("/api/homepage?type=tools")
.then((r) => r.json())
.then((data) => {
setCategories(data.categories || []);
setParentCategories(data.parentCategories || []);
setTotalTools(data.totalTools || 0);
setTotalSkills(data.totalSkills || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (categories.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) setActiveSection(visible[0].target.id);
export async function generateMetadata(): Promise<Metadata> {
return {
title: "追光AI - 汇聚全球优质AI工具",
description: "发现、探索、使用 — 让人工智能触手可及。浏览全球优质AI工具,按分类查找最适合你的AI应用。",
openGraph: {
title: "追光AI - 汇聚全球优质AI工具",
description: "发现、探索、使用 — 让人工智能触手可及。浏览全球优质AI工具,按分类查找最适合你的AI应用。",
type: "website",
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
);
Object.values(sectionRefs.current).forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [categories]);
const scrollToSection = useCallback((id: string) => {
const el = sectionRefs.current[id];
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: "smooth" });
}
setMobileNavOpen(false);
}, []);
const handleSearch = () => {
if (search.trim()) {
router.push(`/tools?search=${encodeURIComponent(search.trim())}`);
}
};
const getActiveTab = (parentId: number, children: FlatCategory[]) => {
if (activeTabs[parentId]) return activeTabs[parentId];
return "all";
};
const setActiveTab = (parentId: number, slug: string) => {
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
};
const groupedCategories = parentCategories.map((parent) => {
const ownCat = categories.find((c) => c.id === parent.id);
const children = categories.filter((c) => c.parentId === parent.id);
return { parent, ownCat, children };
});
if (loading) {
return (
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
))}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-10">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
const renderSidebar = () => (
<nav className="space-y-0.5">
<div className="relative mb-3">
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索工具..."
className="input-field-compact"
/>
</div>
export default async function HomePage() {
const [allCategories, totalTools, totalSkills] = await Promise.all([
prisma.category.findMany({
include: {
parent: { select: { id: true, name: true, slug: true, icon: true } },
_count: { select: { tools: { where: { status: "published" } } } },
tools: { where: { status: "published" }, take: 8, orderBy: { viewCount: "desc" } },
},
orderBy: { sortOrder: "asc" },
}),
prisma.tool.count({ where: { status: "published" } }),
prisma.skill.count({ where: { status: "published" } }),
]);
<div className="h-px bg-border/40 my-2" />
const flatCategories = allCategories.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
icon: cat.icon,
parentId: cat.parentId,
toolCount: cat._count.tools,
tools: cat.tools,
}));
{parentCategories.map((p) => {
const isActive = activeSection === `cat-${p.slug}`;
const ownCount = categories.find((c) => c.id === p.id)?.toolCount || 0;
const childCount = categories
.filter((c) => c.parentId === p.id)
.reduce((s, c) => s + c.toolCount, 0);
const totalCount = ownCount + childCount;
const parentCategories = allCategories
.filter((cat) => cat.parentId === null)
.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
icon: cat.icon,
}));
return (
<button
key={p.id}
onClick={() => scrollToSection(`cat-${p.slug}`)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
isActive
? "bg-primary/10 font-semibold text-primary shadow-sm"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`}
>
<span className="truncate">{p.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive ? "text-primary/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
);
})}
<div className="h-px bg-border/40 my-2" />
<div className="px-3 py-2">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个分类 · {totalTools} 个工具
</p>
</div>
</nav>
);
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
{renderSidebar()}
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-5 flex items-center gap-3 lg:hidden">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索AI工具..."
className="input-field !pl-10 !h-10 !rounded-xl"
<HomePageClient
flatCategories={flatCategories}
parentCategories={parentCategories}
totalTools={totalTools}
totalSkills={totalSkills}
/>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label="分类导航"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
{mobileNavOpen && (
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
{renderSidebar()}
</div>
)}
<HeroBanner
title="追光"
highlight="AI"
highlightColor="blue"
starStyle="cosmos"
stats={[
{ label: "AI工具", value: totalTools, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
]}
slogan="汇聚全球优质AI工具,赋能每一个创新者"
subSlogan="发现、探索、使用 — 让人工智能触手可及"
/>
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatCategory[];
if (allCatsInGroup.length === 0) return null;
const hasChildren = children.length > 0;
const activeTab = getActiveTab(parent.id, children);
const totalCount = allCatsInGroup.reduce((s, c) => s + c.toolCount, 0);
const allTools = allCatsInGroup.flatMap((c) => c.tools);
const displayTools = activeTab === "all"
? allTools
: children.find((c) => c.slug === activeTab)?.tools || ownCat?.tools || [];
const maxDisplay = 8;
const visibleTools = displayTools.slice(0, maxDisplay);
const hasMore = displayTools.length > maxDisplay;
return (
<section
key={`cat-${parent.slug}`}
id={`cat-${parent.slug}`}
ref={(el) => { sectionRefs.current[`cat-${parent.slug}`] = el; }}
className="mb-8"
>
<div className="mb-3 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-[10px] font-semibold text-primary tabular-nums">
{totalCount}
</span>
</div>
{hasChildren && (
<div className="mb-4 flex flex-wrap gap-1.5">
<button
onClick={() => setActiveTab(parent.id, "all")}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === "all"
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
全部
<span className={`text-[9px] tabular-nums ${
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
{children.map((child) => (
<button
key={child.id}
onClick={() => setActiveTab(parent.id, child.slug)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === child.slug
? "bg-primary text-white shadow-sm shadow-primary/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
{child.name}
<span className={`text-[9px] tabular-nums ${
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
}`}>{child.toolCount}</span>
</button>
))}
</div>
)}
{visibleTools.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleTools.map((tool) => (
<Card
key={tool.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]"
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={false} />
</div>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/30 group-hover:shadow-sm"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
/>
) : null}
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-xs font-semibold text-primary transition-all duration-300 group-hover:scale-105 ${tool.logoUrl ? 'hidden' : ''}`}>
{tool.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
{tool.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
</div>
</div>
</div>
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
敬请期待
</div>
)}
{hasMore && (
<div className="mt-3 text-center">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const cat = activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
router.push(`/categories/${cat}`);
}}
>
查看全部 {displayTools.length} 个 →
</Button>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
import { useState, useCallback } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
interface ReviewItem {
id: number;
overall: number;
capability: number;
devExp: number;
costLicense: number;
community: number;
performance: number;
reviews?: Record<string, { score: number; summary: string; detail: string }>;
skill: {
id: number;
name: string;
slug: string;
description: string;
sourceUrl: string;
category: { name: string; slug: string } | null;
};
reviewedAt: string;
}
const dimLabels: Record<string, string> = {
capability: "能力",
devExp: "体验",
costLicense: "成本",
community: "社区",
performance: "性能",
};
function getScoreColor(score: number): string {
if (score >= 4) return "text-emerald-700";
if (score >= 3) return "text-amber-700";
return "text-red-600";
}
function getScoreBg(score: number): string {
if (score >= 4) return "bg-emerald-50";
if (score >= 3) return "bg-amber-50";
return "bg-red-50";
}
function getOverallLabel(score: number): string {
if (score >= 4.5) return "卓越";
if (score >= 4) return "优秀";
if (score >= 3.5) return "良好";
if (score >= 3) return "中等";
return "一般";
}
function MiniRadar({ scores }: { scores: number[] }) {
const size = 80;
const cx = size / 2;
const cy = size / 2;
const r = 30;
const angles = scores.map((_, i) => (Math.PI * 2 * i) / scores.length - Math.PI / 2);
const points = scores.map((s, i) => {
const val = (s / 5) * r;
return `${cx + val * Math.cos(angles[i])},${cy + val * Math.sin(angles[i])}`;
}).join(" ");
const gridRings = [1, 2, 3, 4, 5].map((level) => {
const gridPoints = angles.map((a) => {
const val = (level / 5) * r;
return `${cx + val * Math.cos(a)},${cy + val * Math.sin(a)}`;
}).join(" ");
return <polygon key={level} points={gridPoints} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />;
});
const axisLines = angles.map((a, i) => (
<line key={i} x1={cx} y1={cy} x2={cx + r * Math.cos(a)} y2={cy + r * Math.sin(a)} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
));
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{gridRings}
{axisLines}
<polygon points={points} fill="rgba(139,92,246,0.15)" stroke="rgba(139,92,246,0.7)" strokeWidth="1.5" />
{scores.map((s, i) => (
<circle key={i} cx={cx + (s / 5) * r * Math.cos(angles[i])} cy={cy + (s / 5) * r * Math.sin(angles[i])} r="2.5" fill={s >= 4 ? "#10b981" : s >= 3 ? "#f59e0b" : "#ef4444"} />
))}
</svg>
);
}
function ReviewCard({ review }: { review: ReviewItem }) {
const scores = [review.capability, review.devExp, review.costLicense, review.community, review.performance];
const reviewEntries = review.reviews
? (Object.entries(review.reviews).filter(
([, v]) => v && typeof v === "object" && "score" in v
) as [string, { score: number; summary: string; detail: string }][])
: [];
return (
<Link href={`/skills/${review.skill.slug}`} className="block group">
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] transition-all duration-200 hover:shadow-md hover:border-primary/30 h-full">
<CardContent className="p-5">
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors truncate">
{review.skill.name}
</h3>
{review.skill.category && (
<span className="inline-block mt-1 text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded-md">
{review.skill.category.name}
</span>
)}
</div>
<div className={`flex-shrink-0 flex flex-col items-center justify-center rounded-xl px-3 py-2 ${getScoreBg(review.overall)}`}>
<span className={`text-xl font-bold ${getScoreColor(review.overall)}`}>
{review.overall}
</span>
<span className="text-[10px] text-muted-foreground">{getOverallLabel(review.overall)}</span>
</div>
</div>
<div className="flex items-center gap-4 mb-4">
<MiniRadar scores={scores} />
<div className="flex-1 grid grid-cols-5 gap-1">
{scores.map((s, i) => (
<div key={i} className="flex flex-col items-center">
<span className="text-[10px] text-muted-foreground mb-0.5">{dimLabels[Object.keys(dimLabels)[i]]}</span>
<div className="w-full bg-muted/50 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full ${s >= 4 ? "bg-emerald-500" : s >= 3 ? "bg-amber-500" : "bg-red-500"}`}
style={{ width: `${(s / 5) * 100}%` }}
/>
</div>
<span className="text-[10px] font-medium text-foreground/70 mt-0.5">{s}</span>
</div>
))}
</div>
</div>
{reviewEntries.length > 0 && (
<div className="space-y-1.5">
{reviewEntries.slice(0, 3).map(([key, dim]) => (
<div key={key} className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground w-10 flex-shrink-0">{dimLabels[key]}</span>
<span className="text-foreground/80 truncate">{dim.summary}</span>
</div>
))}
</div>
)}
{review.skill.description && (
<p className="mt-3 text-xs text-muted-foreground/70 line-clamp-2">
{review.skill.description}
</p>
)}
</CardContent>
</Card>
</Link>
);
}
interface Props {
initialReviews: ReviewItem[];
initialTotal: number;
}
export function ReviewsList({ initialReviews, initialTotal }: Props) {
const [reviews, setReviews] = useState<ReviewItem[]>(initialReviews);
const [total, setTotal] = useState(initialTotal);
const [loading, setLoading] = useState(false);
const [isInitial, setIsInitial] = useState(true);
const [page, setPage] = useState(1);
const pageSize = 12;
const fetchReviews = useCallback(async (p: number) => {
setLoading(true);
try {
const res = await fetch(`/api/skill-reviews?page=${p}&pageSize=${pageSize}`);
if (res.ok) {
const data = await res.json();
setReviews(data.reviews || []);
setTotal(data.total || 0);
}
} catch {}
setLoading(false);
}, []);
const handlePageChange = (newPage: number) => {
if (isInitial) setIsInitial(false);
setPage(newPage);
fetchReviews(newPage);
};
const totalPages = Math.ceil(total / pageSize);
if (!isInitial && loading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/60 bg-white animate-pulse">
<CardContent className="p-5">
<div className="h-5 bg-muted/50 rounded w-2/3 mb-3" />
<div className="h-3 bg-muted/50 rounded w-1/3 mb-4" />
<div className="h-20 bg-muted/30 rounded" />
</CardContent>
</Card>
))}
</div>
);
}
if (reviews.length === 0) {
return (
<div className="text-center py-20">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto text-muted-foreground/40 mb-4">
<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" />
</svg>
<p className="text-muted-foreground">暂无评测数据</p>
<p className="text-xs text-muted-foreground/60 mt-1">评测正在生成中,请稍后再来</p>
</div>
);
}
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<button onClick={() => handlePageChange(Math.max(1, page - 1))} disabled={page === 1} className="px-3 py-1.5 text-sm rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors">上一页</button>
<span className="text-sm text-muted-foreground px-2">{page} / {totalPages}</span>
<button onClick={() => handlePageChange(Math.min(totalPages, page + 1))} disabled={page === totalPages} className="px-3 py-1.5 text-sm rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors">下一页</button>
</div>
)}
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
function SidebarRankings() {
interface RankingItem {
id: number;
overall?: number;
skill?: { name: string; slug: string; stars?: number };
name?: string;
slug?: string;
githubStars?: number;
starsChangeWeek?: number;
starsChangeDay?: number;
}
const [rankings, setRankings] = useState<{
topByRating: RankingItem[];
topByStarsChange: RankingItem[];
totalReviews: number;
} | null>(null);
useEffect(() => {
fetch("/api/skill-reviews/rankings")
.then((r) => r.json())
.then(setRankings)
.catch(() => {});
}, []);
if (!rankings) {
return (
<div className="space-y-4">
<Card className="border-border/60 bg-white animate-pulse"><CardContent className="p-4"><div className="h-3 bg-muted/50 rounded w-2/3 mb-2" /><div className="h-16 bg-muted/30 rounded" /></CardContent></Card>
<Card className="border-border/60 bg-white animate-pulse"><CardContent className="p-4"><div className="h-3 bg-muted/50 rounded w-2/3 mb-2" /><div className="h-16 bg-muted/30 rounded" /></CardContent></Card>
</div>
);
}
return (
<div className="space-y-5">
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2 mb-3">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-amber-500">
<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>
评测分榜 TOP10
</h3>
<div className="space-y-2">
{rankings.topByRating.slice(0, 10).map((item, i) => (
<Link key={item.id} href={`/skills/${item.skill?.slug || ""}`} className="flex items-center gap-2 hover:bg-accent/50 rounded-md px-1 py-1 transition-colors group">
<span className={`flex-shrink-0 w-5 h-5 flex items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-amber-100 text-amber-700" : "bg-muted/50 text-muted-foreground"}`}>{i + 1}</span>
<span className="text-xs text-foreground/80 truncate flex-1 group-hover:text-primary transition-colors">{item.skill?.name}</span>
<span className="text-xs font-semibold text-violet-600">{item.overall}</span>
</Link>
))}
</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2 mb-3">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-500">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
<polyline points="17 6 23 6 23 12" />
</svg>
Stars涨幅榜 TOP10
</h3>
<div className="space-y-2">
{rankings.topByStarsChange.slice(0, 10).map((item, i) => (
<Link key={item.id} href={`/skills/${item.slug || ""}`} className="flex items-center gap-2 hover:bg-accent/50 rounded-md px-1 py-1 transition-colors group">
<span className={`flex-shrink-0 w-5 h-5 flex items-center justify-center rounded text-[10px] font-bold ${i < 3 ? "bg-emerald-100 text-emerald-700" : "bg-muted/50 text-muted-foreground"}`}>{i + 1}</span>
<span className="text-xs text-foreground/80 truncate flex-1 group-hover:text-primary transition-colors">{item.name}</span>
<span className="text-[10px] text-emerald-600 font-medium">{(item.starsChangeWeek && item.starsChangeWeek > 0 ? "+" : "")}{item.starsChangeWeek || 0}</span>
</Link>
))}
</div>
</CardContent>
</Card>
</div>
);
}
export { SidebarRankings };
+47 -242
View File
@@ -1,191 +1,50 @@
"use client";
import { prisma } from "@/lib/prisma";
import { Metadata } from "next";
import { ReviewsList } from "./ReviewsList";
import { SidebarRankings } from "./SidebarRankings";
import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
interface ReviewItem {
id: number;
overall: number;
capability: number;
devExp: number;
costLicense: number;
community: number;
performance: number;
reviews?: Record<string, { score: number; summary: string; detail: string }>;
skill: {
id: number;
name: string;
slug: string;
description: string;
sourceUrl: string;
category: { name: string; slug: string } | null;
};
reviewedAt: string;
}
const dimLabels: Record<string, string> = {
capability: "能力",
devExp: "体验",
costLicense: "成本",
community: "社区",
performance: "性能",
export const metadata: Metadata = {
title: "AI评测",
description: "基于五维模型的AI技能深度评测,涵盖场景能力、开发体验、成本许可、社区健康、性能稳定",
};
function getScoreColor(score: number): string {
if (score >= 4) return "text-emerald-700";
if (score >= 3) return "text-amber-700";
return "text-red-600";
}
function getScoreBg(score: number): string {
if (score >= 4) return "bg-emerald-50";
if (score >= 3) return "bg-amber-50";
return "bg-red-50";
}
function getOverallLabel(score: number): string {
if (score >= 4.5) return "卓越";
if (score >= 4) return "优秀";
if (score >= 3.5) return "良好";
if (score >= 3) return "中等";
return "一般";
}
function MiniRadar({ scores }: { scores: number[] }) {
const size = 80;
const cx = size / 2;
const cy = size / 2;
const r = 30;
const angles = scores.map((_, i) => (Math.PI * 2 * i) / scores.length - Math.PI / 2);
const points = scores.map((s, i) => {
const val = (s / 5) * r;
return `${cx + val * Math.cos(angles[i])},${cy + val * Math.sin(angles[i])}`;
}).join(" ");
const gridRings = [1, 2, 3, 4, 5].map((level) => {
const gridPoints = angles.map((a) => {
const val = (level / 5) * r;
return `${cx + val * Math.cos(a)},${cy + val * Math.sin(a)}`;
}).join(" ");
return <polygon key={level} points={gridPoints} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />;
});
const axisLines = angles.map((a, i) => (
<line key={i} x1={cx} y1={cy} x2={cx + r * Math.cos(a)} y2={cy + r * Math.sin(a)} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
));
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{gridRings}
{axisLines}
<polygon points={points} fill="rgba(139,92,246,0.15)" stroke="rgba(139,92,246,0.7)" strokeWidth="1.5" />
{scores.map((s, i) => (
<circle key={i} cx={cx + (s / 5) * r * Math.cos(angles[i])} cy={cy + (s / 5) * r * Math.sin(angles[i])} r="2.5" fill={s >= 4 ? "#10b981" : s >= 3 ? "#f59e0b" : "#ef4444"} />
))}
</svg>
);
}
function ReviewCard({ review }: { review: ReviewItem }) {
const scores = [review.capability, review.devExp, review.costLicense, review.community, review.performance];
const reviewEntries = review.reviews
? (Object.entries(review.reviews).filter(
([, v]) => v && typeof v === "object" && "score" in v
) as [string, { score: number; summary: string; detail: string }][])
: [];
return (
<Link href={`/skills/${review.skill.slug}`} className="block group">
<Card className="border-border/60 bg-white overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] transition-all duration-200 hover:shadow-md hover:border-primary/30 h-full">
<CardContent className="p-5">
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors truncate">
{review.skill.name}
</h3>
{review.skill.category && (
<span className="inline-block mt-1 text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded-md">
{review.skill.category.name}
</span>
)}
</div>
<div className={`flex-shrink-0 flex flex-col items-center justify-center rounded-xl px-3 py-2 ${getScoreBg(review.overall)}`}>
<span className={`text-xl font-bold ${getScoreColor(review.overall)}`}>
{review.overall}
</span>
<span className="text-[10px] text-muted-foreground">{getOverallLabel(review.overall)}</span>
</div>
</div>
<div className="flex items-center gap-4 mb-4">
<MiniRadar scores={scores} />
<div className="flex-1 grid grid-cols-5 gap-1">
{scores.map((s, i) => (
<div key={i} className="flex flex-col items-center">
<span className="text-[10px] text-muted-foreground mb-0.5">{dimLabels[Object.keys(dimLabels)[i]]}</span>
<div className="w-full bg-muted/50 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full rounded-full ${s >= 4 ? "bg-emerald-500" : s >= 3 ? "bg-amber-500" : "bg-red-500"}`}
style={{ width: `${(s / 5) * 100}%` }}
/>
</div>
<span className="text-[10px] font-medium text-foreground/70 mt-0.5">{s}</span>
</div>
))}
</div>
</div>
{reviewEntries.length > 0 && (
<div className="space-y-1.5">
{reviewEntries.slice(0, 3).map(([key, dim]) => (
<div key={key} className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground w-10 flex-shrink-0">{dimLabels[key]}</span>
<span className="text-foreground/80 truncate">{dim.summary}</span>
</div>
))}
</div>
)}
{review.skill.description && (
<p className="mt-3 text-xs text-muted-foreground/70 line-clamp-2">
{review.skill.description}
</p>
)}
</CardContent>
</Card>
</Link>
);
}
function ReviewsContent() {
const searchParams = useSearchParams();
const [reviews, setReviews] = useState<ReviewItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
export default async function ReviewsPage() {
const pageSize = 12;
const fetchReviews = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/skill-reviews?page=${page}&pageSize=${pageSize}`);
if (res.ok) {
const data = await res.json();
setReviews(data.reviews || []);
setTotal(data.total || 0);
}
} catch {}
setLoading(false);
}, [page]);
const [reviews, total] = await Promise.all([
prisma.skillReview.findMany({
where: { status: "PUBLISHED" },
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" } }),
]);
useEffect(() => {
fetchReviews();
}, [fetchReviews]);
const totalPages = Math.ceil(total / pageSize);
const serializedReviews = reviews.map((r) => ({
id: r.id,
overall: r.overall,
capability: r.capability,
devExp: r.devExp,
costLicense: r.costLicense,
community: r.community,
performance: r.performance,
reviews: r.reviews as Record<string, { score: number; summary: string; detail: string }> | undefined,
skill: r.skill,
reviewedAt: r.reviewedAt.toISOString(),
}));
return (
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 py-8">
@@ -208,69 +67,15 @@ function ReviewsContent() {
</div>
</div>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/60 bg-white animate-pulse">
<CardContent className="p-5">
<div className="h-5 bg-muted/50 rounded w-2/3 mb-3" />
<div className="h-3 bg-muted/50 rounded w-1/3 mb-4" />
<div className="h-20 bg-muted/30 rounded" />
</CardContent>
</Card>
))}
</div>
) : reviews.length === 0 ? (
<div className="text-center py-20">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto text-muted-foreground/40 mb-4">
<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" />
</svg>
<p className="text-muted-foreground">暂无评测数据</p>
<p className="text-xs text-muted-foreground/60 mt-1">评测正在生成中,请稍后再来</p>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex-1 min-w-0">
<ReviewsList initialReviews={serializedReviews} initialTotal={total} />
</div>
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1.5 text-sm rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
上一页
</button>
<span className="text-sm text-muted-foreground px-2">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1.5 text-sm rounded-lg border border-border/60 bg-white text-muted-foreground hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
下一页
</button>
<div className="lg:w-64 flex-shrink-0">
<SidebarRankings />
</div>
</div>
)}
</>
)}
</div>
);
}
export default function ReviewsPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">加载中...</p>
</div>
}>
<ReviewsContent />
</Suspense>
);
}
+413
View File
@@ -0,0 +1,413 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
interface Skill {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
sourceUrl: string;
sourceType: string;
rating: number;
viewCount: number;
}
interface FlatSkillCategory {
id: number;
name: string;
slug: string;
icon: string | null;
parentId: number | null;
skillCount: number;
skills: Skill[];
}
interface ParentSkillCategory {
id: number;
name: string;
slug: string;
icon: string | null;
}
interface SkillsPageClientProps {
flatCategories: FlatSkillCategory[];
parentCategories: ParentSkillCategory[];
totalTools: number;
totalSkills: number;
}
const sourceTypeLabel: Record<string, string> = {
gitee: "Gitee",
gitcode: "GitCode",
github: "GitHub",
gitlab: "GitLab",
huggingface: "HuggingFace",
marketplace: "Marketplace",
other: "其他",
};
const sourceTypeColor: Record<string, string> = {
gitee: "bg-red-100 text-red-700 border border-red-200",
gitcode: "bg-orange-100 text-orange-700 border border-orange-200",
github: "bg-slate-100 text-slate-700 border border-slate-200",
gitlab: "bg-purple-100 text-purple-700 border border-purple-200",
huggingface: "bg-yellow-100 text-yellow-700 border border-yellow-200",
marketplace: "bg-blue-100 text-blue-700 border border-blue-200",
other: "bg-secondary text-muted-foreground",
};
export default function SkillsPageClient({
flatCategories: categories,
parentCategories,
totalTools,
totalSkills,
}: SkillsPageClientProps) {
const router = useRouter();
const [activeSection, setActiveSection] = useState<string>("");
const [search, setSearch] = useState("");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
useEffect(() => {
if (categories.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) setActiveSection(visible[0].target.id);
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
);
Object.values(sectionRefs.current).forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [categories]);
const scrollToSection = useCallback((id: string) => {
const el = sectionRefs.current[id];
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: "smooth" });
}
setMobileNavOpen(false);
}, []);
const handleSearch = () => {
if (search.trim()) {
router.push(`/skills?search=${encodeURIComponent(search.trim())}`);
}
};
const getActiveTab = (parentId: number, children: FlatSkillCategory[]) => {
if (activeTabs[parentId]) return activeTabs[parentId];
return "all";
};
const setActiveTab = (parentId: number, slug: string) => {
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
};
const groupedCategories = parentCategories.map((parent) => {
const ownCat = categories.find((c) => c.id === parent.id);
const children = categories.filter((c) => c.parentId === parent.id);
return { parent, ownCat, children };
});
const isEmpty = categories.length === 0 && parentCategories.length === 0;
if (isEmpty) {
return (
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
))}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-10">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
const renderSidebar = () => (
<nav className="space-y-0.5">
<div className="relative mb-3">
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索技能..."
className="input-field-compact !focus:border-violet-500/50 !focus:ring-violet-500/15"
/>
</div>
<div className="h-px bg-border/40 my-2" />
{parentCategories.map((p) => {
const isActive = activeSection === `skill-${p.slug}`;
const ownCount = categories.find((c) => c.id === p.id)?.skillCount || 0;
const childCount = categories
.filter((c) => c.parentId === p.id)
.reduce((s, c) => s + c.skillCount, 0);
const totalCount = ownCount + childCount;
return (
<button
key={p.id}
onClick={() => scrollToSection(`skill-${p.slug}`)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
isActive
? "bg-violet-100 font-semibold text-violet-700 shadow-sm"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`}
>
<span className="truncate">{p.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive ? "text-violet-600/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
);
})}
<div className="h-px bg-border/40 my-2" />
<div className="px-3 py-2">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个分类 · {totalSkills} 个技能
</p>
</div>
</nav>
);
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
{renderSidebar()}
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-5 flex items-center gap-3 lg:hidden">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索AI技能..."
className="input-field !pl-10 !h-10 !rounded-xl !focus:border-violet-500/50 !focus:ring-violet-500/15"
/>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label="分类导航"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
{mobileNavOpen && (
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
{renderSidebar()}
</div>
)}
<HeroBanner
title="追光"
highlight="Skills"
highlightColor="violet"
starStyle="aurora"
stats={[
{ label: "AI技能", value: totalSkills, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
]}
slogan="开源AI技能生态,构建智能未来"
subSlogan="国内优质AI开源项目,一站式发现与学习"
/>
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatSkillCategory[];
if (allCatsInGroup.length === 0) return null;
const hasChildren = children.length > 0;
const activeTab = getActiveTab(parent.id, children);
const totalCount = allCatsInGroup.reduce((s, c) => s + c.skillCount, 0);
const allSkills = allCatsInGroup.flatMap((c) => c.skills);
const displaySkills = activeTab === "all"
? allSkills
: children.find((c) => c.slug === activeTab)?.skills || ownCat?.skills || [];
const maxDisplay = 8;
const visibleSkills = displaySkills.slice(0, maxDisplay);
const hasMore = displaySkills.length > maxDisplay;
return (
<section
key={`skill-${parent.slug}`}
id={`skill-${parent.slug}`}
ref={(el) => { sectionRefs.current[`skill-${parent.slug}`] = el; }}
className="mb-8"
>
<div className="mb-3 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
<span className="rounded-full bg-violet-100 px-2.5 py-0.5 text-[10px] font-semibold text-violet-700 tabular-nums">
{totalCount}
</span>
</div>
{hasChildren && (
<div className="mb-4 flex flex-wrap gap-1.5">
<button
onClick={() => setActiveTab(parent.id, "all")}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === "all"
? "bg-violet-600 text-white shadow-sm shadow-violet-500/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
全部
<span className={`text-[9px] tabular-nums ${
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
{children.map((child) => (
<button
key={child.id}
onClick={() => setActiveTab(parent.id, child.slug)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === child.slug
? "bg-violet-600 text-white shadow-sm shadow-violet-500/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
{child.name}
<span className={`text-[9px] tabular-nums ${
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
}`}>{child.skillCount}</span>
</button>
))}
</div>
)}
{visibleSkills.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleSkills.map((skill) => (
<Card
key={skill.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-violet-300/50 hover:shadow-[0_8px_24px_rgba(124,58,237,0.1),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]"
onClick={() => router.push(`/skills/${skill.slug}`)}
>
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="skill" id={skill.id} initialFavorited={false} />
</div>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{skill.logoUrl ? (
<img
src={skill.logoUrl}
referrerPolicy="no-referrer"
alt=""
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-violet-300/50 group-hover:shadow-sm"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
/>
) : null}
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-violet-100 to-violet-50 text-xs font-semibold text-violet-700 transition-all duration-300 group-hover:scale-105 ${skill.logoUrl ? 'hidden' : ''}`}>
{skill.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground group-hover:text-violet-700 transition-colors duration-200">
{skill.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${sourceTypeColor[skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
{sourceTypeLabel[skill.sourceType] || skill.sourceType}
</span>
{skill.rating > 0 && (
<span className="flex items-center gap-0.5 text-[9px] text-amber-600/80">
{Array.from({ length: 5 }).map((_, si) => (
<svg key={si} width="8" height="8" viewBox="0 0 24 24" fill={si < Math.round(skill.rating) ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5">
<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="ml-0.5">{skill.rating.toFixed(1)}</span>
</span>
)}
</div>
</div>
</div>
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{skill.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
敬请期待
</div>
)}
{hasMore && (
<div className="mt-3 text-center">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const cat = activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
router.push(`/skills?category=${cat}`);
}}
>
查看全部 {displaySkills.length} 个 →
</Button>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
);
}
+44 -93
View File
@@ -1,8 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -14,23 +12,8 @@ const SkillReviewCard = dynamic(
{ ssr: false }
);
interface SkillDetail {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
sourceUrl: string;
sourceType: string;
rating: number;
features: unknown;
tags: unknown;
viewCount: number;
favorited?: boolean;
favoriteCount?: number;
category: { id: number; name: string; slug: string } | null;
createdAt: string;
updatedAt: string;
interface Props {
params: { slug: string };
}
const sourceTypeLabel: Record<string, string> = {
@@ -60,73 +43,41 @@ const tagColors = [
"border-cyan-200 bg-cyan-50 text-cyan-700",
];
export default function SkillDetailPage() {
const router = useRouter();
const params = useParams();
const slug = params.slug as string;
const { status } = useSession();
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const skill = await prisma.skill.findFirst({
where: { slug: params.slug, status: "published" },
select: { name: true, description: true },
});
if (!skill) {
return { title: "技能不存在" };
}
return {
title: skill.name,
description: skill.description,
};
}
const [skill, setSkill] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
export default async function SkillDetailPage({ params }: Props) {
const { slug } = params;
useEffect(() => {
fetch(`/api/skills/${slug}`)
.then((r) => {
if (!r.ok) throw new Error("技能不存在");
return r.json();
})
.then((data) => {
setSkill(data);
if (status === "authenticated" && data?.id) {
fetch("/api/user/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skillId: data.id }),
const skill = await prisma.skill.findFirst({
where: { slug, status: "published" },
include: {
category: { select: { id: true, name: true, slug: true } },
_count: { select: { favorites: true } },
},
});
if (!skill) notFound();
prisma.skill.update({
where: { id: skill.id },
data: { viewCount: { increment: 1 } },
}).catch(() => {});
}
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [slug, status]);
if (loading) {
return (
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<div className="animate-pulse space-y-8">
<div className="h-4 w-20 rounded bg-muted/40" />
<div className="rounded-2xl border border-border/50 bg-white p-8 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex gap-6">
<div className="h-20 w-20 rounded-2xl bg-muted/40" />
<div className="flex-1 space-y-3">
<div className="h-8 w-56 rounded bg-muted/40" />
<div className="h-5 w-40 rounded bg-muted/30" />
</div>
</div>
</div>
</div>
</div>
);
}
if (error || !skill) {
return (
<div className="mx-auto flex max-w-4xl flex-col items-center px-4 py-24 sm:px-6 lg:px-8">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="mt-5 text-lg font-medium text-muted-foreground">{error || "技能不存在"}</p>
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
返回
</Button>
</div>
);
}
const features = Array.isArray(skill.features) ? skill.features : [];
const tags = Array.isArray(skill.tags) ? skill.tags : [];
const features: string[] = Array.isArray(skill.features) ? skill.features as string[] : [];
const tags: string[] = Array.isArray(skill.tags) ? skill.tags as string[] : [];
const favoriteCount = skill._count.favorites;
return (
<div className="relative">
@@ -137,9 +88,9 @@ export default function SkillDetailPage() {
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 animate-fade-in">
<nav className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<a href="/" className="transition-colors hover:text-foreground">首页</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<button onClick={() => router.push("/skills")} className="transition-colors hover:text-foreground">技能库</button>
<a href="/skills" className="transition-colors hover:text-foreground">技能库</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground truncate max-w-[200px]">{skill.name}</span>
</nav>
@@ -164,17 +115,17 @@ export default function SkillDetailPage() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{skill.name}</h1>
<FavoriteButton type="skill" id={skill.id} initialFavorited={skill.favorited ?? false} />
<FavoriteButton type="skill" id={skill.id} initialFavorited={false} />
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{skill.category && (
<button
onClick={() => router.push(`/skills?category=${skill.category!.slug}`)}
<a
href={`/skills?category=${skill.category.slug}`}
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground transition-all duration-200 hover:border-violet-300/50 hover:text-foreground hover:bg-white hover:shadow-sm"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
{skill.category.name}
</button>
</a>
)}
<span className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium ${sourceTypeColor[skill.sourceType] || "bg-secondary text-muted-foreground border-border"}`}>
{sourceTypeLabel[skill.sourceType] || skill.sourceType}
@@ -193,10 +144,10 @@ export default function SkillDetailPage() {
<span className="ml-0.5 font-medium">{skill.rating.toFixed(1)}</span>
</span>
)}
{skill.favoriteCount !== undefined && (
{favoriteCount > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /></svg>
{skill.favoriteCount} 人收藏
{favoriteCount} 人收藏
</span>
)}
</div>
+46 -405
View File
@@ -1,417 +1,58 @@
"use client";
import { prisma } from "@/lib/prisma";
import type { Metadata } from "next";
import SkillsPageClient from "./SkillsPageClient";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { HeroBanner } from "@/components/common/HeroBanner";
interface Skill {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
sourceUrl: string;
sourceType: string;
rating: number;
viewCount: number;
}
interface FlatSkillCategory {
id: number;
name: string;
slug: string;
icon: string | null;
parentId: number | null;
skillCount: number;
skills: Skill[];
}
interface ParentSkillCategory {
id: number;
name: string;
slug: string;
icon: string | null;
}
const sourceTypeLabel: Record<string, string> = {
gitee: "Gitee",
gitcode: "GitCode",
github: "GitHub",
gitlab: "GitLab",
huggingface: "HuggingFace",
marketplace: "Marketplace",
other: "其他",
};
const sourceTypeColor: Record<string, string> = {
gitee: "bg-red-100 text-red-700 border border-red-200",
gitcode: "bg-orange-100 text-orange-700 border border-orange-200",
github: "bg-slate-100 text-slate-700 border border-slate-200",
gitlab: "bg-purple-100 text-purple-700 border border-purple-200",
huggingface: "bg-yellow-100 text-yellow-700 border border-yellow-200",
marketplace: "bg-blue-100 text-blue-700 border border-blue-200",
other: "bg-secondary text-muted-foreground",
};
export default function SkillsHomePage() {
const router = useRouter();
const [categories, setCategories] = useState<FlatSkillCategory[]>([]);
const [parentCategories, setParentCategories] = useState<ParentSkillCategory[]>([]);
const [totalTools, setTotalTools] = useState(0);
const [totalSkills, setTotalSkills] = useState(0);
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<string>("");
const [search, setSearch] = useState("");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTabs, setActiveTabs] = useState<Record<number, string>>({});
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
useEffect(() => {
fetch("/api/homepage?type=skills")
.then((r) => r.json())
.then((data) => {
setCategories(data.categories || []);
setParentCategories(data.parentCategories || []);
setTotalTools(data.totalTools || 0);
setTotalSkills(data.totalSkills || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (categories.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) setActiveSection(visible[0].target.id);
export async function generateMetadata(): Promise<Metadata> {
return {
title: "追光Skills - 开源AI技能生态",
description: "国内优质AI开源项目,一站式发现与学习。浏览精选开源AI技能,构建智能未来。",
openGraph: {
title: "追光Skills - 开源AI技能生态",
description: "国内优质AI开源项目,一站式发现与学习。浏览精选开源AI技能,构建智能未来。",
type: "website",
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
);
Object.values(sectionRefs.current).forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [categories]);
const scrollToSection = useCallback((id: string) => {
const el = sectionRefs.current[id];
if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 80;
window.scrollTo({ top, behavior: "smooth" });
}
setMobileNavOpen(false);
}, []);
const handleSearch = () => {
if (search.trim()) {
router.push(`/skills?search=${encodeURIComponent(search.trim())}`);
}
};
const getActiveTab = (parentId: number, children: FlatSkillCategory[]) => {
if (activeTabs[parentId]) return activeTabs[parentId];
return "all";
};
const setActiveTab = (parentId: number, slug: string) => {
setActiveTabs((prev) => ({ ...prev, [parentId]: slug }));
};
const groupedCategories = parentCategories.map((parent) => {
const ownCat = categories.find((c) => c.id === parent.id);
const children = categories.filter((c) => c.parentId === parent.id);
return { parent, ownCat, children };
});
if (loading) {
return (
<div className="mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded-lg bg-muted/30" />
))}
</div>
</aside>
<div className="min-w-0 flex-1 space-y-10">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-muted/30" />
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-28 animate-pulse rounded-xl bg-muted/20" />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
const renderSidebar = () => (
<nav className="space-y-0.5">
<div className="relative mb-3">
<svg className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索技能..."
className="input-field-compact !focus:border-violet-500/50 !focus:ring-violet-500/15"
/>
</div>
export default async function SkillsHomePage() {
const [allCategories, totalTools, totalSkills] = await Promise.all([
prisma.skillCategory.findMany({
include: {
parent: { select: { id: true, name: true, slug: true, icon: true } },
_count: { select: { skills: { where: { status: "published" } } } },
skills: { where: { status: "published" }, take: 8, orderBy: { viewCount: "desc" } },
},
orderBy: { sortOrder: "asc" },
}),
prisma.tool.count({ where: { status: "published" } }),
prisma.skill.count({ where: { status: "published" } }),
]);
<div className="h-px bg-border/40 my-2" />
const flatCategories = allCategories.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
icon: cat.icon,
parentId: cat.parentId,
skillCount: cat._count.skills,
skills: cat.skills,
}));
{parentCategories.map((p) => {
const isActive = activeSection === `skill-${p.slug}`;
const ownCount = categories.find((c) => c.id === p.id)?.skillCount || 0;
const childCount = categories
.filter((c) => c.parentId === p.id)
.reduce((s, c) => s + c.skillCount, 0);
const totalCount = ownCount + childCount;
const parentCategories = allCategories
.filter((cat) => cat.parentId === null)
.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
icon: cat.icon,
}));
return (
<button
key={p.id}
onClick={() => scrollToSection(`skill-${p.slug}`)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[13px] transition-all duration-200 ${
isActive
? "bg-violet-100 font-semibold text-violet-700 shadow-sm"
: "text-muted-foreground hover:bg-white hover:text-foreground hover:shadow-sm"
}`}
>
<span className="truncate">{p.name}</span>
<span className={`ml-auto shrink-0 text-[10px] tabular-nums ${
isActive ? "text-violet-600/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
);
})}
<div className="h-px bg-border/40 my-2" />
<div className="px-3 py-2">
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{categories.length} 个分类 · {totalSkills} 个技能
</p>
</div>
</nav>
);
return (
<div className="mx-auto max-w-[1440px] px-4 py-4 sm:px-6 lg:px-8">
<div className="flex gap-6">
<aside className="hidden w-52 shrink-0 lg:block sticky top-[68px] self-start">
<div className="sidebar-bg rounded-xl p-3 max-h-[calc(100vh-76px)] overflow-y-auto scrollbar-thin">
{renderSidebar()}
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-5 flex items-center gap-3 lg:hidden">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="搜索AI技能..."
className="input-field !pl-10 !h-10 !rounded-xl !focus:border-violet-500/50 !focus:ring-violet-500/15"
<SkillsPageClient
flatCategories={flatCategories}
parentCategories={parentCategories}
totalTools={totalTools}
totalSkills={totalSkills}
/>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-white text-muted-foreground transition-all hover:bg-accent hover:text-foreground hover:shadow-sm"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label="分类导航"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
{mobileNavOpen && (
<div className="mb-5 rounded-xl border border-border/60 bg-white p-4 lg:hidden animate-slide-down shadow-sm">
{renderSidebar()}
</div>
)}
<HeroBanner
title="追光"
highlight="Skills"
highlightColor="violet"
starStyle="aurora"
stats={[
{ label: "AI技能", value: totalSkills, unit: "个" },
{ label: "分类", value: categories.length, unit: "个" },
]}
slogan="开源AI技能生态,构建智能未来"
subSlogan="国内优质AI开源项目,一站式发现与学习"
/>
{groupedCategories.map(({ parent, ownCat, children }) => {
const allCatsInGroup = [ownCat, ...children].filter(Boolean) as FlatSkillCategory[];
if (allCatsInGroup.length === 0) return null;
const hasChildren = children.length > 0;
const activeTab = getActiveTab(parent.id, children);
const totalCount = allCatsInGroup.reduce((s, c) => s + c.skillCount, 0);
const allSkills = allCatsInGroup.flatMap((c) => c.skills);
const displaySkills = activeTab === "all"
? allSkills
: children.find((c) => c.slug === activeTab)?.skills || ownCat?.skills || [];
const maxDisplay = 8;
const visibleSkills = displaySkills.slice(0, maxDisplay);
const hasMore = displaySkills.length > maxDisplay;
return (
<section
key={`skill-${parent.slug}`}
id={`skill-${parent.slug}`}
ref={(el) => { sectionRefs.current[`skill-${parent.slug}`] = el; }}
className="mb-8"
>
<div className="mb-3 flex items-center gap-2.5">
<h2 className="text-lg font-bold tracking-tight">{parent.name}</h2>
<span className="rounded-full bg-violet-100 px-2.5 py-0.5 text-[10px] font-semibold text-violet-700 tabular-nums">
{totalCount}
</span>
</div>
{hasChildren && (
<div className="mb-4 flex flex-wrap gap-1.5">
<button
onClick={() => setActiveTab(parent.id, "all")}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === "all"
? "bg-violet-600 text-white shadow-sm shadow-violet-500/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
全部
<span className={`text-[9px] tabular-nums ${
activeTab === "all" ? "text-white/70" : "text-muted-foreground/50"
}`}>{totalCount}</span>
</button>
{children.map((child) => (
<button
key={child.id}
onClick={() => setActiveTab(parent.id, child.slug)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-all duration-200 ${
activeTab === child.slug
? "bg-violet-600 text-white shadow-sm shadow-violet-500/25"
: "bg-white text-muted-foreground hover:text-foreground border border-border/60 hover:border-border"
}`}
>
{child.name}
<span className={`text-[9px] tabular-nums ${
activeTab === child.slug ? "text-white/70" : "text-muted-foreground/50"
}`}>{child.skillCount}</span>
</button>
))}
</div>
)}
{visibleSkills.length > 0 ? (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{visibleSkills.map((skill) => (
<Card
key={skill.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-violet-300/50 hover:shadow-[0_8px_24px_rgba(124,58,237,0.1),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)]"
onClick={() => router.push(`/skills/${skill.slug}`)}
>
<div className="absolute top-1.5 right-1.5 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="skill" id={skill.id} initialFavorited={false} />
</div>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{skill.logoUrl ? (
<img
src={skill.logoUrl}
referrerPolicy="no-referrer"
alt=""
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-violet-300/50 group-hover:shadow-sm"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); }}
/>
) : null}
<div className={`flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-violet-100 to-violet-50 text-xs font-semibold text-violet-700 transition-all duration-300 group-hover:scale-105 ${skill.logoUrl ? 'hidden' : ''}`}>
{skill.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground group-hover:text-violet-700 transition-colors duration-200">
{skill.name}
</p>
<div className="mt-1 flex items-center gap-1.5">
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-medium ${sourceTypeColor[skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
{sourceTypeLabel[skill.sourceType] || skill.sourceType}
</span>
{skill.rating > 0 && (
<span className="flex items-center gap-0.5 text-[9px] text-amber-600/80">
{Array.from({ length: 5 }).map((_, si) => (
<svg key={si} width="8" height="8" viewBox="0 0 24 24" fill={si < Math.round(skill.rating) ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5">
<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="ml-0.5">{skill.rating.toFixed(1)}</span>
</span>
)}
</div>
</div>
</div>
<p className="mt-2.5 line-clamp-2 text-xs leading-relaxed text-muted-foreground/80">
{skill.description || "暂无简介"}
</p>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/60 bg-white py-8 text-center text-sm text-muted-foreground/60">
敬请期待
</div>
)}
{hasMore && (
<div className="mt-3 text-center">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
const cat = activeTab === "all"
? parent.slug
: children.find((c) => c.slug === activeTab)?.slug || parent.slug;
router.push(`/skills?category=${cat}`);
}}
>
查看全部 {displaySkills.length} 个 →
</Button>
</div>
)}
</section>
);
})}
</div>
</div>
</div>
);
}
+524
View File
@@ -0,0 +1,524 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
interface Category {
id: number;
name: string;
slug: string;
children: Category[];
}
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
favorited?: boolean;
category: { id: number; name: string; slug: string } | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
freemium: "bg-blue-500/15 text-blue-400 border-blue-500/20",
paid: "bg-amber-500/15 text-amber-400 border-amber-500/20",
subscription: "bg-purple-500/15 text-purple-400 border-purple-500/20",
};
const pricingDot: Record<string, string> = {
free: "bg-emerald-400",
freemium: "bg-blue-400",
paid: "bg-amber-400",
subscription: "bg-purple-400",
};
const pricingOptions = [
{ value: "", label: "全部" },
{ value: "free", label: "免费" },
{ value: "freemium", label: "免费增值" },
{ value: "paid", label: "付费" },
{ value: "subscription", label: "订阅" },
];
const categoryIcons: Record<string, string> = {
"文本生成": "M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7",
"图像生成": "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z",
"视频生成": "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z",
"音频处理": "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z",
"代码助手": "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
"办公效率": "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
"AI 角色": "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
"设计工具": "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01",
"数据分析": "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",
"内容检测": "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
};
interface ToolsContentProps {
initialCategories: Category[];
initialTools: Tool[];
initialTotal: number;
}
export default function ToolsContent({ initialCategories, initialTools, initialTotal }: ToolsContentProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [categories] = useState<Category[]>(initialCategories);
const [tools, setTools] = useState<Tool[]>(initialTools);
const [total, setTotal] = useState(initialTotal);
const [loading, setLoading] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const page = parseInt(searchParams.get("page") || "1");
const search = searchParams.get("search") || "";
const categoryId = searchParams.get("categoryId") || "";
const pricingModel = searchParams.get("pricingModel") || "";
const pageSize = 12;
const [searchInput, setSearchInput] = useState(search);
const isFirstRender = useRef(true);
const fetchTools = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
});
if (search) params.set("search", search);
if (categoryId) params.set("categoryId", categoryId);
if (pricingModel) params.set("pricingModel", pricingModel);
const res = await fetch(`/api/tools?${params}`);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
setTools([]);
} finally {
setLoading(false);
}
}, [page, search, categoryId, pricingModel]);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
fetchTools();
}, [fetchTools]);
const updateParams = (updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
};
const totalPages = Math.ceil(total / pageSize);
const activeCategoryName = categoryId
? categories.find((c) => c.id.toString() === categoryId)?.name || ""
: "全部分类";
const activePricingName = pricingModel
? pricingOptions.find((p) => p.value === pricingModel)?.label || ""
: "";
const Sidebar = () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
分类筛选
</h3>
<nav className="space-y-0.5">
<button
onClick={() => updateParams({ categoryId: "" })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
!categoryId
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
!categoryId ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
}`}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
全部分类
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => updateParams({ categoryId: cat.id.toString() })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
categoryId === cat.id.toString()
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
categoryId === cat.id.toString() ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
}`}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={categoryIcons[cat.name] || "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"} />
</svg>
</div>
{cat.name}
{categoryId === cat.id.toString() && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</nav>
</div>
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
<div>
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
定价模型
</h3>
<nav className="space-y-0.5">
{pricingOptions.map((opt) => (
<button
key={opt.value}
onClick={() => updateParams({ pricingModel: opt.value })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
pricingModel === opt.value
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
{opt.value ? (
<span className={`h-2 w-2 rounded-full transition-all duration-200 ${
pricingModel === opt.value ? "scale-125" : pricingDot[opt.value] || "bg-muted-foreground/40"
}`} style={{ backgroundColor: pricingModel === opt.value ? undefined : undefined }} />
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-muted/50 group-hover:bg-accent">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
)}
{opt.label}
{pricingModel === opt.value && opt.value && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</nav>
</div>
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
<div className="rounded-xl border border-border/50 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p className="text-xs text-muted-foreground leading-relaxed">
💡 点击分类或定价模型进行筛选,支持组合筛选
</p>
</div>
</div>
);
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
</div>
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground">全部工具</span>
</div>
<h1 className="mt-3 text-3xl font-bold tracking-tight">
探索<span className="gradient-text">AI</span>工具
</h1>
<p className="mt-2 text-muted-foreground">
发现最适合你的AI工具,提升工作效率与创造力
</p>
</div>
<div className="flex gap-8">
<aside className="hidden w-60 shrink-0 md:block">
<div className="sticky top-24 rounded-xl border border-border/50 bg-white p-4 scrollbar-thin shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<Sidebar />
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-6 flex items-center gap-3">
<div className="relative flex-1">
<svg className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && updateParams({ search: searchInput })
}
placeholder="搜索AI工具..."
className="h-11 w-full rounded-xl border border-border/50 bg-muted/50 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 focus:bg-white focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
/>
{searchInput && (
<button
onClick={() => { setSearchInput(""); updateParams({ search: "" }); }}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
)}
</div>
<Button
size="default"
onClick={() => updateParams({ search: searchInput })}
className="h-11 gap-2 glow-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
搜索
</Button>
<button
className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/50 text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground md:hidden"
onClick={() => setMobileFilterOpen(!mobileFilterOpen)}
aria-label="筛选"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
</button>
</div>
{mobileFilterOpen && (
<div className="mb-6 rounded-xl border border-border/50 bg-white p-4 md:hidden animate-slide-down shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<Sidebar />
</div>
)}
<div className="mb-5 flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
{search ? `搜索 "${search}"` : activeCategoryName}
</span>
{activePricingName && (
<>
<span className="text-muted-foreground/30">·</span>
<span className="text-sm text-muted-foreground">{activePricingName}</span>
</>
)}
<span className="text-muted-foreground/30">·</span>
<span className="text-sm font-semibold text-foreground">{total}</span>
<span className="text-sm text-muted-foreground">个工具</span>
</div>
{(categoryId || pricingModel || search) && (
<button
onClick={() => { setSearchInput(""); router.push("/tools"); }}
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
清除筛选
</button>
)}
</div>
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
</div>
</CardContent>
</Card>
))}
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">暂无匹配的工具</p>
<p className="mt-1 text-sm text-muted-foreground/50">试试其他关键词或筛选条件</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => { setSearchInput(""); router.push("/tools"); }}>
清除筛选
</Button>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((tool, i) => (
<Card
key={tool.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<div className="absolute top-2 right-2 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={tool.favorited ?? false} />
</div>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{tool.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
<div className="mt-1 flex items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
{tool.category && (
<span className="text-[10px] text-muted-foreground/60">
{tool.category.name}
</span>
)}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted-foreground/40">
<span className="flex items-center gap-1">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{tool.viewCount}
</span>
</div>
</CardContent>
</Card>
))}
</div>
{totalPages > 1 && (
<div className="mt-10 flex items-center justify-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", (page - 1).toString());
router.push(`/tools?${params.toString()}`);
}}
className="gap-1.5 rounded-lg"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
上一页
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", pageNum.toString());
router.push(`/tools?${params.toString()}`);
}}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
page === pageNum
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{pageNum}
</button>
);
})}
</div>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", (page + 1).toString());
router.push(`/tools?${params.toString()}`);
}}
className="gap-1.5 rounded-lg"
>
下一页
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</Button>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
);
}
+70 -99
View File
@@ -1,32 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { ReviewSection } from "@/components/common/ReviewSection";
interface ToolDetail {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
pricingDetail: string | null;
features: unknown;
tags: unknown;
viewCount: number;
favorited?: boolean;
favoriteCount?: number;
category: { id: number; name: string; slug: string } | null;
createdAt: string;
updatedAt: string;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
@@ -59,78 +39,71 @@ const tagColors = [
"border-rose-200 bg-rose-50 text-rose-700",
];
export default function ToolDetailPage() {
const router = useRouter();
const params = useParams();
const slug = params.slug as string;
const { status } = useSession();
interface PageProps {
params: { slug: string };
}
const [tool, setTool] = useState<ToolDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
export async function generateMetadata({ params }: PageProps) {
const tool = await prisma.tool.findUnique({
where: { slug: params.slug, status: "published" },
select: { name: true, description: true },
});
if (!tool) {
return { title: "工具不存在" };
}
return {
title: `${tool.name} - 追光AI工具详情`,
description: tool.description,
};
}
useEffect(() => {
fetch(`/api/tools/${slug}`)
.then((r) => {
if (!r.ok) throw new Error("工具不存在");
return r.json();
})
.then((data) => {
setTool(data);
if (status === "authenticated" && data?.id) {
fetch("/api/user/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ toolId: data.id }),
export default async function ToolDetailPage({ params }: PageProps) {
const tool = await prisma.tool.findUnique({
where: { slug: params.slug, status: "published" },
select: {
id: true,
name: true,
slug: true,
description: true,
logoUrl: true,
websiteUrl: true,
pricingModel: true,
pricingDetail: true,
features: true,
tags: true,
viewCount: true,
createdAt: true,
updatedAt: true,
category: { select: { id: true, name: true, slug: true } },
},
});
if (!tool) {
notFound();
}
prisma.tool.update({
where: { id: tool.id },
data: { viewCount: { increment: 1 } },
}).catch(() => {});
}
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [slug, status]);
if (loading) {
return (
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<div className="animate-pulse space-y-8">
<div className="h-4 w-20 rounded bg-muted/40" />
<div className="rounded-2xl border border-border/50 bg-white p-8 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex gap-6">
<div className="h-20 w-20 rounded-2xl bg-muted/40" />
<div className="flex-1 space-y-3">
<div className="h-8 w-56 rounded bg-muted/40" />
<div className="h-5 w-40 rounded bg-muted/30" />
</div>
</div>
<div className="mt-8 space-y-2">
<div className="h-4 w-full rounded bg-muted/30" />
<div className="h-4 w-3/4 rounded bg-muted/30" />
<div className="h-4 w-1/2 rounded bg-muted/20" />
</div>
</div>
</div>
</div>
);
const session = await getServerSession(authOptions);
let favorited = false;
if (session?.user?.id) {
const fav = await prisma.favorite.findFirst({
where: { userId: parseInt(session.user.id), toolId: tool.id },
select: { id: true },
});
favorited = !!fav;
}
if (error || !tool) {
return (
<div className="mx-auto flex max-w-4xl flex-col items-center px-4 py-24 sm:px-6 lg:px-8">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="mt-5 text-lg font-medium text-muted-foreground">{error || "工具不存在"}</p>
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
返回
</Button>
</div>
);
}
const favoriteCount = await prisma.favorite.count({
where: { toolId: tool.id },
});
const features = Array.isArray(tool.features) ? tool.features : [];
const tags = Array.isArray(tool.tags) ? tool.tags : [];
const displayViewCount = tool.viewCount + 1;
const features = (Array.isArray(tool.features) ? tool.features as string[] : []);
const tags = (Array.isArray(tool.tags) ? tool.tags as string[] : []);
return (
<div className="relative">
@@ -142,9 +115,9 @@ export default function ToolDetailPage() {
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 animate-fade-in">
<nav className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<a href="/" className="transition-colors hover:text-foreground">首页</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<button onClick={() => router.push("/tools")} className="transition-colors hover:text-foreground">全部工具</button>
<a href="/tools" className="transition-colors hover:text-foreground">全部工具</a>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground truncate max-w-[200px]">{tool.name}</span>
</nav>
@@ -169,17 +142,17 @@ export default function ToolDetailPage() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{tool.name}</h1>
<FavoriteButton type="tool" id={tool.id} initialFavorited={tool.favorited ?? false} />
<FavoriteButton type="tool" id={tool.id} initialFavorited={favorited} />
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{tool.category && (
<button
onClick={() => router.push(`/categories/${tool.category!.slug}`)}
<a
href={`/categories/${tool.category.slug}`}
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground transition-all duration-200 hover:border-primary/30 hover:text-foreground hover:bg-white hover:shadow-sm"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
{tool.category.name}
</button>
</a>
)}
<span className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -192,14 +165,12 @@ export default function ToolDetailPage() {
)}
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{tool.viewCount} 次浏览
{displayViewCount} 次浏览
</span>
{tool.favoriteCount !== undefined && (
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-white px-2.5 py-1 text-xs text-muted-foreground">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" /></svg>
{tool.favoriteCount} 人收藏
{favoriteCount} 人收藏
</span>
)}
</div>
</div>
+137 -512
View File
@@ -1,528 +1,153 @@
"use client";
import { prisma } from "@/lib/prisma";
import type { Metadata } from "next";
import { Suspense } from "react";
import type { Prisma } from "@prisma/client";
import ToolsContent from "./ToolsContent";
import { useState, useEffect, useCallback, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
export async function generateMetadata({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}): Promise<Metadata> {
const search =
typeof searchParams.search === "string" ? searchParams.search : "";
const categoryId =
typeof searchParams.categoryId === "string"
? searchParams.categoryId
: "";
interface Category {
id: number;
name: string;
slug: string;
children: Category[];
let title = "AI 工具库 - 发现优质AI工具";
let description = "发现最适合你的AI工具,提升工作效率与创造力。浏览免费、付费、订阅等多种定价模式的AI工具。";
if (search) {
title = `${search} - AI 工具搜索`;
description = `搜索 "${search}" 相关的AI工具,找到你需要的AI应用和服务。`;
}
interface Tool {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
websiteUrl: string;
pricingModel: string;
viewCount: number;
favorited?: boolean;
category: { id: number; name: string; slug: string } | null;
if (categoryId) {
const category = await prisma.category.findUnique({
where: { id: parseInt(categoryId) || 0 },
select: { name: true },
});
if (category) {
title = `${category.name} - AI 工具库`;
description = `浏览${category.name}分类下的AI工具,找到最适合该领域的AI应用。`;
}
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
return {
title,
description,
openGraph: {
title,
description,
type: "website",
},
};
}
export default async function ToolsPage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const page = Math.max(
1,
parseInt(
typeof searchParams.page === "string" ? searchParams.page : "1"
) || 1
);
const search =
typeof searchParams.search === "string" ? searchParams.search : "";
const categoryId =
typeof searchParams.categoryId === "string"
? searchParams.categoryId
: "";
const pricingModel =
typeof searchParams.pricingModel === "string"
? searchParams.pricingModel
: "";
const pageSize = 12;
const [prismaCategories, toolData] = await Promise.all([
prisma.category.findMany({
where: { parentId: null },
include: { children: true },
orderBy: { sortOrder: "asc" },
}),
(async () => {
const where: Prisma.ToolWhereInput = {
status: "published",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
freemium: "bg-blue-500/15 text-blue-400 border-blue-500/20",
paid: "bg-amber-500/15 text-amber-400 border-amber-500/20",
subscription: "bg-purple-500/15 text-purple-400 border-purple-500/20",
};
if (search) {
where.name = { contains: search };
}
const pricingDot: Record<string, string> = {
free: "bg-emerald-400",
freemium: "bg-blue-400",
paid: "bg-amber-400",
subscription: "bg-purple-400",
};
if (categoryId) {
const parsedId = parseInt(categoryId);
if (!isNaN(parsedId)) {
where.categoryId = parsedId;
}
}
const pricingOptions = [
{ value: "", label: "全部" },
{ value: "free", label: "免费" },
{ value: "freemium", label: "免费增值" },
{ value: "paid", label: "付费" },
{ value: "subscription", label: "订阅" },
];
if (pricingModel) {
where.pricingModel = pricingModel as Prisma.EnumPricingModelFilter["equals"];
}
const categoryIcons: Record<string, string> = {
"文本生成": "M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7",
"图像生成": "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z",
"视频生成": "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z",
"音频处理": "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z",
"代码助手": "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
"办公效率": "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
"AI 角色": "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
"设计工具": "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01",
"数据分析": "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",
"内容检测": "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
};
const [tools, total] = await Promise.all([
prisma.tool.findMany({
where,
include: { category: { select: { id: true, name: true, slug: true } } },
orderBy: { viewCount: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.tool.count({ where }),
]);
return { tools, total };
})(),
]);
const initialTools = toolData.tools.map((tool) => ({
id: tool.id,
name: tool.name,
slug: tool.slug,
description: tool.description,
logoUrl: tool.logoUrl,
websiteUrl: tool.websiteUrl,
pricingModel: tool.pricingModel,
viewCount: tool.viewCount,
favorited: false,
category: tool.category,
}));
const categories = prismaCategories.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
children: cat.children.map((child) => ({
id: child.id,
name: child.name,
slug: child.slug,
children: [] as { id: number; name: string; slug: string; children: never[] }[],
})),
}));
export default function ToolsPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center"><p className="text-muted-foreground">加载中...</p></div>}>
<ToolsContent />
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">加载中...</p>
</div>
}
>
<ToolsContent
initialCategories={categories}
initialTools={initialTools}
initialTotal={toolData.total}
/>
</Suspense>
);
}
function ToolsContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [categories, setCategories] = useState<Category[]>([]);
const [tools, setTools] = useState<Tool[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const page = parseInt(searchParams.get("page") || "1");
const search = searchParams.get("search") || "";
const categoryId = searchParams.get("categoryId") || "";
const pricingModel = searchParams.get("pricingModel") || "";
const pageSize = 12;
const [searchInput, setSearchInput] = useState(search);
useEffect(() => {
fetch("/api/categories")
.then((r) => r.json())
.then(setCategories)
.catch(() => {});
}, []);
const fetchTools = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
});
if (search) params.set("search", search);
if (categoryId) params.set("categoryId", categoryId);
if (pricingModel) params.set("pricingModel", pricingModel);
const res = await fetch(`/api/tools?${params}`);
const data = await res.json();
setTools(data.tools || []);
setTotal(data.total || 0);
} catch {
setTools([]);
} finally {
setLoading(false);
}
}, [page, search, categoryId, pricingModel]);
useEffect(() => {
fetchTools();
}, [fetchTools]);
const updateParams = (updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
params.delete("page");
router.push(`/tools?${params.toString()}`);
};
const totalPages = Math.ceil(total / pageSize);
const activeCategoryName = categoryId
? categories.find((c) => c.id.toString() === categoryId)?.name || ""
: "全部分类";
const activePricingName = pricingModel
? pricingOptions.find((p) => p.value === pricingModel)?.label || ""
: "";
const Sidebar = () => (
<div className="space-y-6">
<div>
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
分类筛选
</h3>
<nav className="space-y-0.5">
<button
onClick={() => updateParams({ categoryId: "" })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
!categoryId
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
!categoryId ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
}`}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
全部分类
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => updateParams({ categoryId: cat.id.toString() })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
categoryId === cat.id.toString()
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
<div className={`flex h-5 w-5 items-center justify-center rounded-md transition-colors duration-200 ${
categoryId === cat.id.toString() ? "bg-primary/20" : "bg-muted/50 group-hover:bg-accent"
}`}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={categoryIcons[cat.name] || "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"} />
</svg>
</div>
{cat.name}
{categoryId === cat.id.toString() && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</nav>
</div>
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
<div>
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
定价模型
</h3>
<nav className="space-y-0.5">
{pricingOptions.map((opt) => (
<button
key={opt.value}
onClick={() => updateParams({ pricingModel: opt.value })}
className={`group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-all duration-200 ${
pricingModel === opt.value
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground"
}`}
>
{opt.value ? (
<span className={`h-2 w-2 rounded-full transition-all duration-200 ${
pricingModel === opt.value ? "scale-125" : pricingDot[opt.value] || "bg-muted-foreground/40"
}`} style={{ backgroundColor: pricingModel === opt.value ? undefined : undefined }} />
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-muted/50 group-hover:bg-accent">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
)}
{opt.label}
{pricingModel === opt.value && opt.value && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="ml-auto text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</nav>
</div>
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
<div className="rounded-xl border border-border/50 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p className="text-xs text-muted-foreground leading-relaxed">
💡 点击分类或定价模型进行筛选,支持组合筛选
</p>
</div>
</div>
);
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
</div>
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground">全部工具</span>
</div>
<h1 className="mt-3 text-3xl font-bold tracking-tight">
探索<span className="gradient-text">AI</span>工具
</h1>
<p className="mt-2 text-muted-foreground">
发现最适合你的AI工具,提升工作效率与创造力
</p>
</div>
<div className="flex gap-8">
<aside className="hidden w-60 shrink-0 md:block">
<div className="sticky top-24 rounded-xl border border-border/50 bg-white p-4 scrollbar-thin shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<Sidebar />
</div>
</aside>
<div className="min-w-0 flex-1">
<div className="mb-6 flex items-center gap-3">
<div className="relative flex-1">
<svg className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && updateParams({ search: searchInput })
}
placeholder="搜索AI工具..."
className="h-11 w-full rounded-xl border border-border/50 bg-muted/50 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 focus:bg-white focus:outline-none focus:ring-2 focus:ring-primary/15 transition-all duration-200"
/>
{searchInput && (
<button
onClick={() => { setSearchInput(""); updateParams({ search: "" }); }}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
)}
</div>
<Button
size="default"
onClick={() => updateParams({ search: searchInput })}
className="h-11 gap-2 glow-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
搜索
</Button>
<button
className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/50 text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground md:hidden"
onClick={() => setMobileFilterOpen(!mobileFilterOpen)}
aria-label="筛选"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
</button>
</div>
{mobileFilterOpen && (
<div className="mb-6 rounded-xl border border-border/50 bg-white p-4 md:hidden animate-slide-down shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<Sidebar />
</div>
)}
<div className="mb-5 flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
{search ? `搜索 "${search}"` : activeCategoryName}
</span>
{activePricingName && (
<>
<span className="text-muted-foreground/30">·</span>
<span className="text-sm text-muted-foreground">{activePricingName}</span>
</>
)}
<span className="text-muted-foreground/30">·</span>
<span className="text-sm font-semibold text-foreground">{total}</span>
<span className="text-sm text-muted-foreground">个工具</span>
</div>
{(categoryId || pricingModel || search) && (
<button
onClick={() => { setSearchInput(""); router.push("/tools"); }}
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
清除筛选
</button>
)}
</div>
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
</div>
</CardContent>
</Card>
))}
</div>
) : tools.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">暂无匹配的工具</p>
<p className="mt-1 text-sm text-muted-foreground/50">试试其他关键词或筛选条件</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => { setSearchInput(""); router.push("/tools"); }}>
清除筛选
</Button>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((tool, i) => (
<Card
key={tool.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/tools/${tool.slug}`)}
>
<div className="absolute top-2 right-2 z-10" onClick={(e) => e.stopPropagation()}>
<FavoriteButton type="tool" id={tool.id} initialFavorited={tool.favorited ?? false} />
</div>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{tool.logoUrl ? (
<img
src={tool.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/15 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{tool.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">{tool.name}</p>
<div className="mt-1 flex items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[tool.pricingModel] || tool.pricingModel}
</span>
{tool.category && (
<span className="text-[10px] text-muted-foreground/60">
{tool.category.name}
</span>
)}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-1 shrink-0 text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{tool.description || "暂无简介"}
</p>
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted-foreground/40">
<span className="flex items-center gap-1">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{tool.viewCount}
</span>
</div>
</CardContent>
</Card>
))}
</div>
{totalPages > 1 && (
<div className="mt-10 flex items-center justify-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", (page - 1).toString());
router.push(`/tools?${params.toString()}`);
}}
className="gap-1.5 rounded-lg"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
上一页
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", pageNum.toString());
router.push(`/tools?${params.toString()}`);
}}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
page === pageNum
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{pageNum}
</button>
);
})}
</div>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", (page + 1).toString());
router.push(`/tools?${params.toString()}`);
}}
className="gap-1.5 rounded-lg"
>
下一页
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</Button>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import Link from "next/link";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
import { getLevelInfo } from "@/lib/level-config";
import { MessageSquare, TrendingUp, Star, Award, Calendar, Share2, Edit3, Check } from "lucide-react";
export const dynamic = "force-dynamic";
interface Props {
params: { id: string };
}
export default async function UserProfilePage({ params }: Props) {
const userId = parseInt(params.id);
if (isNaN(userId)) notFound();
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
forumTopics: {
take: 10,
orderBy: { createdAt: "desc" },
include: { category: { select: { name: true, slug: true, icon: true } } },
},
forumPosts: {
take: 10,
orderBy: { createdAt: "desc" },
include: { topic: { select: { id: true, title: true } } },
},
comments: {
take: 10,
orderBy: { createdAt: "desc" },
include: {
tool: { select: { name: true, slug: true } },
skill: { select: { name: true, slug: true } },
},
},
},
});
if (!user) notFound();
const session = await getServerSession(authOptions);
const isOwner = session?.user?.id === userId.toString();
const levelInfo = getLevelInfo(user.points);
const shareUrl = `${process.env.NEXTAUTH_URL || "https://www.zhuig.com"}/user/${userId}`;
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-4">
<Link href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</Link>
</div>
<div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="h-32 bg-gradient-to-r from-violet-500 via-purple-500 to-indigo-500 relative">
<div className="absolute -bottom-12 left-6">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-24 w-24 rounded-full border-4 border-white object-cover shadow-lg" />
) : (
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white border-4 border-white shadow-lg text-3xl font-bold text-primary">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="absolute top-4 right-4 flex items-center gap-2">
{isOwner ? (
<Link href="/user" className="px-3 py-1.5 bg-white/20 text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1">
<Edit3 className="w-3 h-3" /> 编辑资料
</Link>
) : (
<a href={shareUrl} className="px-3 py-1.5 bg-white/20 text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1"
onClick={(e) => {
e.preventDefault();
navigator.clipboard?.writeText(shareUrl).then(() => alert("已复制分享链接!"));
}}>
<Share2 className="w-3 h-3" /> 分享
</a>
)}
</div>
</div>
<div className="pt-16 px-6 pb-6">
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<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>
{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">
<MemberBadge level={user.level} points={user.points} size="md" />
<span className="text-xs text-muted-foreground/50 flex items-center gap-1">
<Calendar className="w-3 h-3" />
注册于 {user.createdAt.toLocaleDateString("zh-CN")}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mt-6">
{[
{ label: "积分", value: user.points, icon: Award, color: "text-emerald-600 bg-emerald-50" },
{ label: "话题", value: user.forumTopics.length, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "回复", value: user.forumPosts.length, icon: MessageSquare, color: "text-blue-600 bg-blue-50" },
{ label: "评论", value: user.comments.length, icon: Star, color: "text-amber-600 bg-amber-50" },
].map((stat) => (
<div key={stat.label} className="text-center p-3 rounded-lg bg-muted/30">
<div className={`mx-auto mb-1 flex h-7 w-7 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<p className="text-lg font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
<div className="mt-6">
<LevelProgress level={user.level} points={user.points} />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 className="text-lg font-semibold mb-4">发布的话题</h2>
{user.forumTopics.length === 0 ? (
<p className="text-sm text-muted-foreground/50 text-center py-6 rounded-xl border border-border/40 bg-white">暂无话题</p>
) : (
<div className="space-y-3">
{user.forumTopics.map((topic) => (
<Link key={topic.id} href={`/community/topic/${topic.id}`} className="block">
<div className="p-4 rounded-xl border border-border/40 bg-white hover:border-border/60 transition-colors shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-medium">{topic.title}</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>{topic.category.name}</span>
<span>{topic.createdAt.toLocaleDateString("zh-CN")}</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
<div>
<h2 className="text-lg font-semibold mb-4">参与的评论</h2>
{user.comments.length === 0 ? (
<p className="text-sm text-muted-foreground/50 text-center py-6 rounded-xl border border-border/40 bg-white">暂无评论</p>
) : (
<div className="space-y-3">
{user.comments.map((comment) => {
const target = comment.tool || comment.skill;
return (
<Link key={comment.id} href={target ? `/${comment.tool ? "tools" : "skills"}/${target.slug}` : "#"} className="block">
<div className="p-4 rounded-xl border border-border/40 bg-white hover:border-border/60 transition-colors shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p className="text-sm text-muted-foreground/80 truncate">{comment.content}</p>
{target && (
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>于 {comment.tool ? "工具" : "技能"}: {target.name}</span>
<span>{comment.createdAt.toLocaleDateString("zh-CN")}</span>
</div>
)}
</div>
</Link>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+546
View File
@@ -0,0 +1,546 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import { useRouter, useParams } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
interface CollectionItem {
id: number;
toolId: number | null;
skillId: number | null;
addedAt: string;
tool?: {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
pricingModel: string;
category: { id: number; name: string; slug: string } | null;
} | null;
skill?: {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
sourceType: string;
category: { id: number; name: string; slug: string } | null;
} | null;
}
interface Collection {
id: number;
name: string;
description: string | null;
_count: { items: number };
}
interface SearchResult {
id: number;
name: string;
slug: string;
description: string;
logoUrl: string | null;
type: "tool" | "skill";
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
};
const sourceTypeLabel: Record<string, string> = {
github: "GitHub",
gitlab: "GitLab",
huggingface: "HuggingFace",
other: "其他",
};
const sourceTypeColor: Record<string, string> = {
github: "bg-slate-50 text-slate-700",
gitlab: "bg-orange-50 text-orange-700",
huggingface: "bg-yellow-50 text-yellow-700",
other: "bg-secondary text-muted-foreground",
};
export default function CollectionDetailPage() {
const { data: session, status } = useSession();
const router = useRouter();
const params = useParams();
const collectionId = params.id as string;
const [collection, setCollection] = useState<Collection | null>(null);
const [items, setItems] = useState<CollectionItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showAddModal, setShowAddModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [addingItems, setAddingItems] = useState<Set<number>>(new Set());
const fetchData = useCallback(async () => {
setLoading(true);
setError("");
try {
const [collRes, itemsRes] = await Promise.all([
fetch(`/api/user/collections/${collectionId}`),
fetch(`/api/user/collections/${collectionId}/items`),
]);
if (!collRes.ok) {
const e = await collRes.json();
throw new Error(e.error || "加载失败");
}
const collData = await collRes.json();
const itemsData = await itemsRes.json();
setCollection(collData);
setItems(itemsData.items || []);
} catch (err: any) {
setError(err.message || "加载收藏夹失败");
} finally {
setLoading(false);
}
}, [collectionId]);
useEffect(() => {
if (status === "loading") return;
if (status === "unauthenticated") {
setLoading(false);
return;
}
fetchData();
}, [status, fetchData]);
const handleRemoveItem = async (itemId: number) => {
try {
const res = await fetch(`/api/user/collections/${collectionId}/items/${itemId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("删除失败");
setItems((prev) => prev.filter((i) => i.id !== itemId));
if (collection) {
setCollection({ ...collection, _count: { items: collection._count.items - 1 } });
}
} catch {
alert("删除失败,请重试");
}
};
const handleSearch = async (query: string) => {
setSearchQuery(query);
if (query.trim().length < 1) {
setSearchResults([]);
return;
}
setSearching(true);
try {
const [toolsRes, skillsRes] = await Promise.all([
fetch(`/api/tools?search=${encodeURIComponent(query)}&pageSize=8`),
fetch(`/api/skills?search=${encodeURIComponent(query)}&pageSize=8`),
]);
const toolsData = await toolsRes.json();
const skillsData = await skillsRes.json();
const existingToolIds = new Set(items.filter((i) => i.toolId).map((i) => i.toolId!));
const existingSkillIds = new Set(items.filter((i) => i.skillId).map((i) => i.skillId!));
const results: SearchResult[] = [
...(toolsData.tools || [])
.filter((t: any) => !existingToolIds.has(t.id))
.map((t: any) => ({ id: t.id, name: t.name, slug: t.slug, description: t.description, logoUrl: t.logoUrl, type: "tool" as const })),
...(skillsData.skills || [])
.filter((s: any) => !existingSkillIds.has(s.id))
.map((s: any) => ({ id: s.id, name: s.name, slug: s.slug, description: s.description, logoUrl: s.logoUrl, type: "skill" as const })),
];
setSearchResults(results);
} catch {
setSearchResults([]);
} finally {
setSearching(false);
}
};
const handleAddItem = async (result: SearchResult) => {
setAddingItems((prev) => new Set(prev).add(result.id));
try {
const body: any = {};
if (result.type === "tool") body.toolId = result.id;
else body.skillId = result.id;
const res = await fetch(`/api/user/collections/${collectionId}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const e = await res.json();
throw new Error(e.error || "添加失败");
}
setSearchResults((prev) => prev.filter((r) => r.id !== result.id));
fetchData();
} catch (err: any) {
alert(err.message || "添加失败");
} finally {
setAddingItems((prev) => {
const next = new Set(prev);
next.delete(result.id);
return next;
});
}
};
if (status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (status === "unauthenticated") {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<Card className="w-full max-w-md p-8 text-center rounded-2xl border border-border/60 bg-white shadow-[0_4px_16px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.04)]">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 border border-primary/20">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<h2 className="text-xl font-bold">请先登录</h2>
<p className="mt-2 text-sm text-muted-foreground">
登录后即可查看收藏夹详情
</p>
<button
onClick={() => router.push("/login")}
className="mt-6 w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
去登录
</button>
</Card>
</div>
);
}
const itemCount = collection?._count.items ?? 0;
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
</div>
<div className="relative mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg className="w-7 h-7 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-4 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
<button onClick={fetchData} className="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 transition-colors">重新加载</button>
</div>
) : loading ? (
<>
<div className="mb-8 animate-fade-in">
<div className="h-4 w-48 animate-pulse rounded bg-muted/40" />
<div className="mt-3 h-8 w-64 animate-pulse rounded bg-muted/40" />
<div className="mt-2 h-4 w-32 animate-pulse rounded bg-muted/30" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
<div className="h-11 w-11 animate-pulse rounded-xl bg-muted/40" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted/40" />
<div className="h-3 w-16 animate-pulse rounded bg-muted/40" />
</div>
</div>
<div className="mt-3 space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted/30" />
<div className="h-3 w-3/4 animate-pulse rounded bg-muted/30" />
</div>
</CardContent>
</Card>
))}
</div>
</>
) : !collection ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">收藏夹不存在</p>
<button onClick={() => router.push("/user/collections")} className="mt-4 rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground">
返回收藏夹列表
</button>
</div>
) : (
<>
<div className="mb-8 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">
首页
</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<button onClick={() => router.push("/user/collections")} className="transition-colors hover:text-foreground">
我的收藏夹
</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span className="text-foreground truncate max-w-[200px]">{collection.name}</span>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
<span className="gradient-text">{collection.name}</span>
</h1>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{itemCount}
</span>
</div>
<button
onClick={() => { setShowAddModal(true); setSearchQuery(""); setSearchResults([]); }}
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
添加内容
</button>
</div>
{collection.description && (
<p className="mt-2 text-muted-foreground">{collection.description}</p>
)}
</div>
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4">
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm" onClick={() => setShowAddModal(false)} />
<div className="relative w-full max-w-lg bg-white/95 backdrop-blur-md rounded-2xl border border-border/60 shadow-[0_20px_60px_rgba(0,0,0,0.15)] overflow-hidden animate-slide-up">
<div className="p-5 border-b border-border/40">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-lg">添加到收藏夹</h3>
<button
onClick={() => setShowAddModal(false)}
className="p-1.5 rounded-lg text-muted-foreground hover:bg-muted/50 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="relative">
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/50"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
type="text"
placeholder="搜索 AI 工具或技能..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 bg-muted/30 rounded-xl border border-border/40 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors"
autoFocus
/>
</div>
</div>
<div className="max-h-[50vh] overflow-y-auto p-3">
{searching ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : searchResults.length === 0 && searchQuery.trim() ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mb-3 text-muted-foreground/30">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<p className="text-sm">未找到匹配的内容</p>
</div>
) : searchResults.length > 0 ? (
<div className="space-y-2">
{searchResults.map((result) => (
<div
key={`${result.type}-${result.id}`}
className="flex items-center gap-3 p-3 rounded-xl border border-border/40 bg-white hover:border-primary/20 hover:shadow-sm transition-all"
>
{result.logoUrl ? (
<img
src={result.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-10 w-10 rounded-lg border border-border/40 bg-white object-contain p-0.5 shrink-0"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-sm font-semibold text-primary shrink-0">
{result.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{result.name}</p>
<p className="text-xs text-muted-foreground/60 truncate">{result.description || "暂无简介"}</p>
</div>
<span className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium ${
result.type === "tool"
? "bg-primary/10 text-primary"
: "bg-violet-100 text-violet-700"
}`}>
{result.type === "tool" ? "工具" : "技能"}
</span>
<button
onClick={() => handleAddItem(result)}
disabled={addingItems.has(result.id)}
className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{addingItems.has(result.id) ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : "添加"}
</button>
</div>
))}
</div>
) : null}
</div>
</div>
</div>
)}
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground/40">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">收藏夹为空</p>
<p className="mt-1 text-sm text-muted-foreground/50">
添加 AI 工具或技能到这个收藏夹
</p>
<button
onClick={() => { setShowAddModal(true); setSearchQuery(""); setSearchResults([]); }}
className="mt-4 flex items-center gap-1.5 rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
添加内容
</button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, i) => {
const isTool = !!item.tool;
const data = item.tool || item.skill;
if (!data) return null;
const href = isTool
? `/tools/${data.slug}`
: `/skills/${data.slug}`;
return (
<Card
key={item.id}
className="group relative cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] hover:-translate-y-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.05),0_1px_2px_rgba(0,0,0,0.03)] animate-slide-up"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
onClick={() => router.push(href)}
>
<button
className="absolute top-2 right-2 z-10 p-1.5 rounded-lg text-muted-foreground/40 hover:text-red-500 hover:bg-red-50 transition-all"
onClick={(e) => {
e.stopPropagation();
handleRemoveItem(item.id);
}}
title="从收藏夹移除"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<CardContent className="p-5">
<div className="flex items-start gap-3.5">
{data.logoUrl ? (
<img
src={data.logoUrl}
referrerPolicy="no-referrer"
alt=""
className="h-11 w-11 rounded-xl border border-border/40 bg-white object-contain p-0.5 transition-all duration-300 group-hover:scale-105 group-hover:border-primary/20 group-hover:shadow-md"
/>
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/40 bg-gradient-to-br from-primary/10 to-primary/5 text-sm font-semibold text-primary transition-all duration-300 group-hover:scale-105">
{data.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground group-hover:text-primary transition-colors duration-200">
{data.name}
</p>
<div className="mt-1 flex items-center gap-2">
{isTool && item.tool ? (
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[item.tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[item.tool.pricingModel] || item.tool.pricingModel}
</span>
) : !isTool && item.skill ? (
<span className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-medium ${sourceTypeColor[item.skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
{sourceTypeLabel[item.skill.sourceType] || item.skill.sourceType}
</span>
) : null}
{data.category && (
<span className="text-[10px] text-muted-foreground/60">
{data.category.name}
</span>
)}
</div>
</div>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-muted-foreground/80">
{data.description || "暂无简介"}
</p>
<div className="mt-3 flex items-center justify-between">
<span className={`rounded-md px-1.5 py-0.5 text-[10px] font-medium ${
isTool
? "bg-primary/10 text-primary"
: "bg-violet-100 text-violet-700"
}`}>
{isTool ? "工具" : "技能"}
</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground/0 transition-all duration-300 group-hover:text-muted-foreground/50 group-hover:translate-x-0.5">
<path d="M9 5l7 7-7 7" />
</svg>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</>
)}
</div>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { FolderOpen, Plus, ChevronRight, Loader2, X } from "lucide-react";
interface Collection {
id: number;
name: string;
description: string | null;
createdAt: string;
_count: { items: number };
}
export default function CollectionsPage() {
const { data: session, status } = useSession();
const router = useRouter();
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
useEffect(() => {
if (status === "loading") return;
if (status === "unauthenticated") {
setLoading(false);
return;
}
fetchCollections();
}, [status]);
const fetchCollections = () => {
setLoading(true);
setError("");
fetch("/api/user/collections")
.then((r) => r.json())
.then((data) => setCollections(data.collections || []))
.catch(() => setError("加载收藏夹失败"))
.finally(() => setLoading(false));
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
setCreateError("");
try {
const res = await fetch("/api/user/collections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() || undefined }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "创建失败");
}
setShowCreate(false);
setNewName("");
setNewDesc("");
fetchCollections();
} catch (err: any) {
setCreateError(err.message);
} finally {
setCreating(false);
}
};
if (status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (status === "unauthenticated") {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<Card className="w-full max-w-md p-8 text-center rounded-2xl border border-border/60 bg-white shadow-[0_4px_16px_rgba(0,0,0,0.06)]">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 border border-primary/20">
<FolderOpen className="w-7 h-7 text-primary" />
</div>
<h2 className="text-xl font-bold">请先登录</h2>
<p className="mt-2 text-sm text-muted-foreground">登录后即可管理收藏夹</p>
<button onClick={() => router.push("/login")} className="mt-6 w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">
去登录
</button>
</Card>
</div>
);
}
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 left-1/4 h-[500px] w-[600px] rounded-full bg-primary/4 blur-[120px]" />
</div>
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<span className="text-foreground">我的收藏夹</span>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
我的<span className="gradient-text">收藏夹</span>
</h1>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{collections.length}
</span>
</div>
<button
onClick={() => { setShowCreate(true); setCreateError(""); }}
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors shadow-sm shadow-primary/20"
>
<Plus className="w-4 h-4" />
新建收藏夹
</button>
</div>
</div>
{showCreate && (
<Card className="mb-6 border-border/60 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
<CardContent className="p-5">
<form onSubmit={handleCreate} className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">新建收藏夹</h3>
<button type="button" onClick={() => setShowCreate(false)} className="p-1 rounded-lg text-muted-foreground hover:bg-accent transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<input
type="text"
placeholder="收藏夹名称"
value={newName}
onChange={(e) => setNewName(e.target.value)}
maxLength={50}
required
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors"
autoFocus
/>
<input
type="text"
placeholder="描述(选填)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="w-full px-3 py-2 bg-white rounded-lg border border-border/60 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors"
/>
{createError && <p className="text-sm text-red-500">{createError}</p>}
<button
type="submit"
disabled={creating || !newName.trim()}
className="w-full py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{creating ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : "创建"}
</button>
</form>
</CardContent>
</Card>
)}
{error ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-50 border border-red-200/60">
<svg className="w-7 h-7 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="mt-4 text-sm font-medium text-foreground">加载失败</p>
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
<button onClick={fetchCollections} className="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 transition-colors">重新加载</button>
</div>
) : loading ? (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="border-border/50 bg-white">
<CardContent className="p-5">
<div className="h-5 w-32 animate-pulse rounded bg-muted/40 mb-2" />
<div className="h-3 w-48 animate-pulse rounded bg-muted/30" />
</CardContent>
</Card>
))}
</div>
) : collections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-muted/30 border border-border/30">
<FolderOpen className="w-8 h-8 text-muted-foreground/40" />
</div>
<p className="mt-5 text-base font-medium text-muted-foreground">暂无收藏夹</p>
<p className="mt-1 text-sm text-muted-foreground/50">创建收藏夹来整理你的AI工具和技能</p>
<button onClick={() => setShowCreate(true)} className="mt-4 flex items-center gap-1.5 rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/30 hover:text-foreground">
<Plus className="w-4 h-4" />
新建收藏夹
</button>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{collections.map((c, i) => (
<Card
key={c.id}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_4px_12px_rgba(0,0,0,0.06)] hover:-translate-y-0.5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] animate-slide-up"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
onClick={() => router.push(`/user/collections/${c.id}`)}
>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="min-w-0">
<h3 className="font-semibold text-foreground truncate group-hover:text-primary transition-colors">{c.name}</h3>
{c.description && (
<p className="mt-1 text-sm text-muted-foreground truncate">{c.description}</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground/60">
<span>{c._count.items} 项</span>
<span>{new Date(c.createdAt).toLocaleDateString("zh-CN")} 创建</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground/0 group-hover:text-muted-foreground/50 transition-all mt-0.5" />
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}
+146 -8
View File
@@ -5,7 +5,10 @@ import { useSession, signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Wrench, Zap, LogOut, Edit3, Check, X, Clock } from "lucide-react";
import { Heart, Wrench, Zap, LogOut, Edit3, Check, X, Clock, MessageCircle, TrendingUp, Award, Star } from "lucide-react";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
import { UserLevel } from "@prisma/client";
function getTimeAgo(date: Date): string {
const now = new Date();
@@ -29,8 +32,17 @@ export default function UserProfilePage() {
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState("");
const [currentName, setCurrentName] = useState("");
const [editingBio, setEditingBio] = useState(false);
const [bioValue, setBioValue] = useState("");
const [currentBio, setCurrentBio] = useState("");
const [saving, setSaving] = useState(false);
const [browsingHistory, setBrowsingHistory] = useState<any[]>([]);
const [createdAt, setCreatedAt] = useState<string | null>(null);
const [historyTotal, setHistoryTotal] = useState(0);
const [userPoints, setUserPoints] = useState(0);
const [userLevel, setUserLevel] = useState<UserLevel>(UserLevel.LV01);
const [pointsHistory, setPointsHistory] = useState<any[]>([]);
const [userStats, setUserStats] = useState<any>(null);
useEffect(() => {
if (status === "loading") return;
@@ -41,6 +53,15 @@ export default function UserProfilePage() {
setNameValue(session.user.name);
}
fetch("/api/user/profile")
.then((r) => r.json())
.then((data) => {
data.createdAt && setCreatedAt(data.createdAt);
data.bio && setCurrentBio(data.bio);
data.bio && setBioValue(data.bio);
})
.catch(() => {});
fetch("/api/user/favorites?type=all")
.then((r) => r.json())
.then((data) => setFavoriteTotal(data.total || 0))
@@ -48,7 +69,24 @@ export default function UserProfilePage() {
fetch("/api/user/history")
.then((r) => r.json())
.then((data) => setBrowsingHistory(data.history || []))
.then((data) => {
setBrowsingHistory(data.history || []);
setHistoryTotal(data.history?.length || 0);
})
.catch(() => {});
fetch("/api/user/points?type=stats")
.then((r) => r.json())
.then((data) => {
setUserPoints(data.points || 0);
setUserLevel(data.level?.level || UserLevel.LV01);
setUserStats(data);
})
.catch(() => {});
fetch("/api/user/points?type=history&pageSize=5")
.then((r) => r.json())
.then((data) => setPointsHistory(data.history || []))
.catch(() => {});
}, [status, session]);
@@ -91,18 +129,28 @@ export default function UserProfilePage() {
const res = await fetch("/api/user/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: nameValue }),
body: JSON.stringify({ name: nameValue, bio: bioValue }),
});
if (!res.ok) throw new Error();
setCurrentName(nameValue || "");
setCurrentBio(bioValue || "");
setEditingName(false);
setEditingBio(false);
} catch {
setNameValue(currentName);
setBioValue(currentBio);
} finally {
setSaving(false);
}
};
const handleCancelEdit = () => {
setEditingName(false);
setEditingBio(false);
setNameValue(currentName);
setBioValue(currentBio);
};
const shortcuts = [
{ icon: Heart, label: "我的收藏", desc: `${favoriteTotal} 个收藏`, href: "/user/favorites", color: "text-red-600 bg-red-50 border-red-200/60" },
{ icon: Wrench, label: "浏览工具", desc: "发现AI工具", href: "/tools", color: "text-primary bg-primary/10 border-primary/20" },
@@ -136,7 +184,7 @@ export default function UserProfilePage() {
</div>
<div className="flex-1 text-center sm:text-left min-w-0">
{editingName ? (
<div className="flex items-center gap-2 justify-center sm:justify-start">
<div className="flex items-center gap-2 justify-center sm:justify-start mb-2">
<input
type="text"
value={nameValue}
@@ -149,7 +197,7 @@ export default function UserProfilePage() {
<button onClick={handleSaveName} disabled={saving} className="p-1.5 rounded-lg text-emerald-600 hover:bg-emerald-50 transition-colors">
<Check className="w-4 h-4" />
</button>
<button onClick={() => { setEditingName(false); setNameValue(currentName); }} className="p-1.5 rounded-lg text-muted-foreground hover:bg-accent transition-colors">
<button onClick={handleCancelEdit} className="p-1.5 rounded-lg text-muted-foreground hover:bg-accent transition-colors">
<X className="w-4 h-4" />
</button>
</div>
@@ -171,17 +219,107 @@ export default function UserProfilePage() {
{session?.user?.email}
</p>
<div className="mt-2 flex items-center gap-2 justify-center sm:justify-start">
<span className="rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{((session?.user as any)?.role === "admin") ? "管理员" : "用户"}
</span>
<MemberBadge level={userLevel} points={userPoints} size="md" />
<span className="text-xs text-muted-foreground/60">
{favoriteTotal} 个收藏
</span>
{createdAt && (
<>
<span className="text-xs text-muted-foreground/40">·</span>
<span className="text-xs text-muted-foreground/60">
注册于 {new Date(createdAt).toLocaleDateString("zh-CN")}
</span>
</>
)}
</div>
<div className="mt-4 pt-4 border-t border-border/40">
{editingBio ? (
<div className="flex flex-col gap-2">
<textarea
value={bioValue}
onChange={(e) => setBioValue(e.target.value)}
maxLength={500}
rows={3}
className="px-3 py-2 bg-white rounded-lg border border-border/70 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors w-full text-foreground resize-none"
placeholder="介绍一下自己吧..."
autoFocus
/>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{bioValue.length}/500</span>
<div className="flex gap-2">
<button onClick={handleSaveName} disabled={saving} className="px-3 py-1 rounded-lg text-sm bg-primary text-white hover:bg-primary/90 transition-colors">
保存
</button>
<button onClick={handleCancelEdit} className="px-3 py-1 rounded-lg text-sm text-muted-foreground hover:bg-accent transition-colors">
取消
</button>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-2 justify-center sm:justify-start">
<p className="text-sm text-muted-foreground flex-1">
{currentBio || "这个人很懒,还没有写简介~"}
</p>
<button
onClick={() => setEditingBio(true)}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0"
title="编辑简介"
>
<Edit3 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
</div>
</Card>
<div className="grid gap-4 sm:grid-cols-2 mb-6">
<LevelProgress level={userLevel} points={userPoints} />
<Card className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-semibold mb-3">积分明细</h3>
{pointsHistory.length === 0 ? (
<p className="text-xs text-muted-foreground/50 text-center py-4">暂无积分记录</p>
) : (
<div className="space-y-2">
{pointsHistory.map((ph: any) => (
<div key={ph.id} className="flex items-center justify-between text-xs">
<div>
<p className="font-medium text-foreground/80">{ph.reason}</p>
{ph.detail && <p className="text-muted-foreground/50 truncate max-w-[150px]">{ph.detail}</p>}
</div>
<div className="text-right">
<p className="font-semibold text-emerald-600">+{ph.points}</p>
<p className="text-muted-foreground/40">{new Date(ph.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</Card>
</div>
{userStats && (
<div className="grid gap-4 grid-cols-4 mb-6">
{[
{ label: "评论", value: userStats.commentCount || 0, icon: MessageCircle, color: "text-blue-600 bg-blue-50" },
{ label: "话题", value: userStats.topicCount || 0, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "评价", value: userStats.reviewCount || 0, icon: Star, color: "text-amber-600 bg-amber-50" },
{ label: "积分", value: userStats.points || 0, icon: Award, color: "text-emerald-600 bg-emerald-50" },
].map((stat) => (
<div key={stat.label} className="rounded-xl border border-border/40 bg-white p-4 text-center shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className={`mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-4 h-4" />
</div>
<p className="text-lg font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
)}
<div className="grid gap-4 sm:grid-cols-3 mb-6">
{shortcuts.map((s) => (
<Card
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { ThumbsUp, MessageSquare, ChevronDown, ChevronUp, Star } from "lucide-react";
import MemberBadge from "@/components/common/MemberBadge";
import { UserLevel } from "@prisma/client";
interface User {
id: number;
name: string;
avatarUrl?: string;
level: UserLevel;
points: number;
}
interface Reply {
id: number;
content: string;
likeCount: number;
createdAt: string;
liked: boolean;
user: User;
}
interface CommentData {
id: number;
content: string;
rating?: number;
likeCount: number;
isPinned: boolean;
createdAt: string;
updatedAt: string;
replyCount: number;
liked: boolean;
user: User;
replies?: Reply[];
}
interface CommentCardProps {
comment: CommentData;
targetType: "tool" | "skill";
targetSlug: string;
onReply: (parentId: number, replyToName: string) => void;
onLike: (commentId: number) => void;
}
function getTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
if (seconds < 60) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
if (months < 12) return `${months}个月前`;
return date.toLocaleDateString("zh-CN");
}
export default function CommentCard({ comment, targetType, targetSlug, onReply, onLike }: CommentCardProps) {
const { data: session } = useSession();
const [showReplies, setShowReplies] = useState(false);
const [likeAnimating, setLikeAnimating] = useState(false);
const handleLike = () => {
setLikeAnimating(true);
onLike(comment.id);
setTimeout(() => setLikeAnimating(false), 300);
};
const initial = comment.user.name.charAt(0).toUpperCase();
return (
<div className={`rounded-xl border transition-all ${comment.isPinned ? "border-primary/30 bg-primary/[0.03]" : "border-border/40 bg-white hover:border-border/60"} shadow-[0_1px_2px_rgba(0,0,0,0.04)]`}>
{comment.isPinned && (
<div className="px-4 pt-2">
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a1 1 0 011 1v1.323l3.954 1.582 1.699-3.189a1 1 0 011.827 1.035L17.1 5.5H19a1 1 0 010 2h-2.095l-1.6 3.2a1 1 0 01-1.21.447L10 9.5V17a1 1 0 11-2 0v-7.5L3.805 7.5a1 1 0 01-1.21-.447L.995 3.85a1 1 0 011.827-1.035L4.427 5.5 8.364 3.924A1 1 0 019 3.09V3a1 1 0 011-1z" />
</svg>
置顶
</span>
</div>
)}
<div className="p-4">
<div className="flex items-start gap-3">
{comment.user.avatarUrl ? (
<img src={comment.user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{initial}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{comment.user.name}</span>
<MemberBadge level={comment.user.level} points={comment.user.points} size="sm" showPoints={false} />
{comment.rating && (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star key={s} className={`w-3 h-3 ${s <= comment.rating! ? "fill-amber-400 text-amber-400" : "text-muted-foreground/20"}`} />
))}
</div>
)}
<span className="text-[10px] text-muted-foreground/40">{getTimeAgo(new Date(comment.createdAt))}</span>
</div>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground/80 whitespace-pre-wrap">{comment.content}</p>
<div className="mt-3 flex items-center gap-4">
<button
onClick={handleLike}
className={`flex items-center gap-1 text-xs transition-colors ${comment.liked ? "text-primary" : "text-muted-foreground/50 hover:text-foreground"}`}
>
<ThumbsUp className={`w-3.5 h-3.5 transition-transform ${likeAnimating ? "scale-125" : ""}`} />
<span>{comment.likeCount || "点赞"}</span>
</button>
<button
onClick={() => onReply(comment.id, comment.user.name)}
className="flex items-center gap-1 text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
>
<MessageSquare className="w-3.5 h-3.5" />
<span>回复</span>
</button>
</div>
{comment.replies && comment.replies.length > 0 && (
<div className="mt-3 space-y-2">
<button
onClick={() => setShowReplies(!showReplies)}
className="flex items-center gap-1 text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
>
{showReplies ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
<span>{comment.replyCount > (comment.replies?.length || 0) ? `共${comment.replyCount}条回复,已加载${comment.replies?.length}条` : `共${comment.replies.length}条回复`}</span>
</button>
{showReplies && comment.replies.map((reply) => (
<div key={reply.id} className="flex items-start gap-2 pl-3 border-l-2 border-border/30 py-2">
{reply.user.avatarUrl ? (
<img src={reply.user.avatarUrl} alt="" className="h-6 w-6 rounded-full border border-border/30 object-cover" />
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted/50 text-[10px] font-medium text-muted-foreground">
{reply.user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium">{reply.user.name}</span>
<span className="text-[10px] text-muted-foreground/40">{getTimeAgo(new Date(reply.createdAt))}</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground/70 whitespace-pre-wrap">{reply.content}</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { LoginModal } from "@/components/common/LoginModal";
import { UserLevel } from "@prisma/client";
interface CommentData {
id: number;
content: string;
rating?: number;
likeCount: number;
liked: boolean;
isPinned: boolean;
updatedAt: string;
replyCount: number;
createdAt: string;
user: {
id: number;
name: string;
avatarUrl?: string;
level: UserLevel;
points: number;
};
replies?: CommentData[];
parentId?: number;
}
interface CommentFormProps {
targetType: "tool" | "skill";
targetSlug: string;
parentId?: number;
replyToName?: string;
onSuccess: (comment: CommentData) => void;
onCancel?: () => void;
}
function StarSelector({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
const [hover, setHover] = useState(0);
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => onChange(star === value ? 0 : star)}
onMouseEnter={() => setHover(star)}
onMouseLeave={() => setHover(0)}
className="transition-transform hover:scale-110"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
className={
star <= (hover || value)
? "fill-amber-400 text-amber-400"
: "fill-none text-muted-foreground/30"
}
>
<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"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
</button>
))}
</div>
);
}
export default function CommentForm({
targetType,
targetSlug,
parentId,
replyToName,
onSuccess,
onCancel,
}: CommentFormProps) {
const { data: session, status } = useSession();
const [content, setContent] = useState("");
const [rating, setRating] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [showLoginModal, setShowLoginModal] = useState(false);
const maxLength = 2000;
const isReply = !!parentId;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (status !== "authenticated") {
setShowLoginModal(true);
return;
}
const trimmed = content.trim();
if (!trimmed || trimmed.length < 1) {
setError("评论内容不能为空");
return;
}
if (trimmed.length > maxLength) {
setError(`评论内容不能超过${maxLength}个字符`);
return;
}
setSubmitting(true);
setError("");
try {
const body: Record<string, unknown> = {
content: trimmed,
};
if (targetType === "tool") {
body.toolSlug = targetSlug;
} else {
body.skillSlug = targetSlug;
}
if (parentId) {
body.parentId = parentId;
}
if (!isReply && rating > 0) {
body.rating = rating;
}
const res = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "提交失败");
}
const newComment: CommentData = await res.json();
setContent("");
setRating(0);
onSuccess(newComment);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
};
return (
<>
<form
onSubmit={handleSubmit}
className="space-y-3 animate-slide-up"
>
{isReply && replyToName && (
<p className="text-xs text-muted-foreground">
回复 <span className="font-medium text-foreground">@{replyToName}</span>
</p>
)}
<textarea
value={content}
onChange={(e) => {
setContent(e.target.value);
if (error) setError("");
}}
placeholder={isReply ? "写下你的回复..." : "分享你的想法..."}
rows={isReply ? 2 : 3}
maxLength={maxLength}
className="w-full resize-none rounded-lg border border-border/60 bg-white px-3 py-2 text-sm focus:outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/15 transition-colors placeholder:text-muted-foreground/40"
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{!isReply && (
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">评分</span>
<StarSelector value={rating} onChange={setRating} />
</div>
)}
<span className="text-xs text-muted-foreground/50">
{content.length}/{maxLength}
</span>
</div>
<div className="flex items-center gap-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-border/60 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted/30"
>
取消
</button>
)}
<button
type="submit"
disabled={submitting || !content.trim()}
className="rounded-lg bg-primary text-white text-sm px-4 py-2 hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{submitting ? "提交中..." : isReply ? "回复" : "发表评论"}
</button>
</div>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
</form>
<LoginModal
open={showLoginModal}
onClose={() => setShowLoginModal(false)}
/>
</>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect } from "react";
import CommentCard from "@/components/common/CommentCard";
import CommentForm from "@/components/common/CommentForm";
import { UserLevel } from "@prisma/client";
interface CommentItem {
id: number;
content: string;
rating?: number;
likeCount: number;
isPinned: boolean;
createdAt: string;
updatedAt: string;
replyCount: number;
liked: boolean;
user: {
id: number;
name: string;
avatarUrl?: string;
level: UserLevel;
points: number;
};
replies?: {
id: number;
content: string;
likeCount: number;
createdAt: string;
liked: boolean;
user: {
id: number;
name: string;
avatarUrl?: string;
level: UserLevel;
points: number;
};
}[];
}
interface CommentThreadProps {
targetType: "tool" | "skill";
targetSlug: string;
targetId?: number;
}
export default function CommentThread({ targetType, targetSlug, targetId }: CommentThreadProps) {
const [comments, setComments] = useState<CommentItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [replyingTo, setReplyingTo] = useState<{ id: number; name: string } | null>(null);
const fetchComments = async () => {
setLoading(true);
try {
const idParam = targetType === "tool" ? "toolId" : "skillId";
const res = await fetch(`/api/comments?${idParam}=${targetId}&page=${page}&pageSize=10`);
const data = await res.json();
setComments(data.comments || []);
setTotalCount(data.totalCount || 0);
} catch (error) {
console.error("Failed to fetch comments:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (targetId) fetchComments();
}, [targetId, targetType, page]);
const handleNewComment = (comment: CommentItem) => {
setComments((prev) => [comment, ...prev]);
setTotalCount((prev) => prev + 1);
};
const handleLike = async (commentId: number) => {
try {
const res = await fetch(`/api/comments/${commentId}/like`, { method: "POST" });
if (res.ok) {
const data = await res.json();
setComments((prev) =>
prev.map((c) =>
c.id === commentId
? { ...c, liked: data.liked, likeCount: data.liked ? c.likeCount + 1 : c.likeCount - 1 }
: c
)
);
}
} catch (error) {
console.error("Failed to like comment:", error);
}
};
const handleReply = (parentId: number, replyToName: string) => {
setReplyingTo({ id: parentId, name: replyToName });
};
const handleReplySuccess = (reply: CommentItem) => {
setComments((prev) =>
prev.map((c) =>
c.id === replyingTo?.id
? { ...c, replies: [...(c.replies || []), reply], replyCount: c.replyCount + 1 }
: c
)
);
setReplyingTo(null);
};
if (!targetId) return null;
return (
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id}>
<CommentCard
comment={comment}
targetType={targetType}
targetSlug={targetSlug}
onReply={handleReply}
onLike={handleLike}
/>
{replyingTo?.id === comment.id && (
<div className="ml-8 mt-2 p-3 rounded-lg border border-border/40 bg-muted/20">
<CommentForm
targetType={targetType}
targetSlug={targetSlug}
parentId={replyingTo.id}
replyToName={replyingTo.name}
onSuccess={handleReplySuccess}
onCancel={() => setReplyingTo(null)}
/>
</div>
)}
</div>
))}
{totalCount > 10 && (
<div className="flex justify-center gap-2 pt-2">
{Array.from({ length: Math.ceil(totalCount / 10) }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
page === p
? "bg-primary text-white"
: "bg-white border border-border/40 text-muted-foreground hover:border-border/60"
}`}
>
{p}
</button>
))}
</div>
)}
</div>
);
}
+4
View File
@@ -2,6 +2,7 @@
import { useState, useOptimistic } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Heart } from "lucide-react";
import { cn } from "@/lib/utils";
import { LoginModal } from "@/components/common/LoginModal";
@@ -17,6 +18,7 @@ export function FavoriteButton({ type, id, initialFavorited = false, className }
const { data: session, status } = useSession();
const [optimisticFav, addOptimisticFav] = useOptimistic(initialFavorited);
const [showLoginModal, setShowLoginModal] = useState(false);
const router = useRouter();
const handleClick = async () => {
if (status === "unauthenticated" || !session) {
@@ -33,8 +35,10 @@ export function FavoriteButton({ type, id, initialFavorited = false, className }
body: JSON.stringify({ [type === "tool" ? "toolId" : "skillId"]: id }),
});
if (!res.ok) throw new Error("操作失败");
router.refresh();
} catch {
addOptimisticFav(!newState);
router.refresh();
}
};
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useState, useEffect } from "react";
import { getLevelInfo } from "@/lib/level-config";
import { UserLevel } from "@prisma/client";
import MemberBadge from "@/components/common/MemberBadge";
import { TrendingUp, BarChart3, Activity } from "lucide-react";
interface LevelProgressProps {
level: UserLevel;
points: number;
}
export default function LevelProgress({ level, points }: LevelProgressProps) {
const info = getLevelInfo(points);
return (
<div className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${info.bgColor}`}>
<TrendingUp className={`w-4 h-4 ${info.color}`} />
</div>
<MemberBadge level={info.level} points={info.points} size="md" />
</div>
<span className={`text-lg font-bold ${info.color}`}>{info.points}分</span>
</div>
{info.nextLevel && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">距离 {info.nextLevel.label} 还差</span>
<span className="font-medium text-foreground">{info.nextLevel.remaining}分</span>
</div>
<div className="h-2 rounded-full bg-muted/50 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${parseInt(info.nextLevel.level.replace("LV", "")) >= 15 ? "bg-amber-500" : parseInt(info.nextLevel.level.replace("LV", "")) >= 10 ? "bg-violet-500" : "bg-slate-400"}`}
style={{ width: `${info.progress}%` }}
/>
</div>
</div>
)}
{!info.nextLevel && (
<p className="text-sm text-center text-muted-foreground">🎉 已达到最高等级!</p>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More