2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间
This commit is contained in:
+72
-255
@@ -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 记录)
|
||||
@@ -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) — 国内科技资讯
|
||||
|
||||
+35
-25
@@ -1,7 +1,7 @@
|
||||
# 追光AI - 模块文档索引
|
||||
|
||||
## 项目概述
|
||||
追光AI是一个AI工具与开源技能目录网站,支持提示词驱动的工具/技能发现、审核管理和前台展示。
|
||||
追光AI是一个AI工具与开源技能目录网站,支持提示词驱动的工具/技能发现、审核管理和前台展示,集成五维评测系统、任务调度、权限体系和社区互动功能。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -12,48 +12,54 @@
|
||||
| 样式 | 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 |
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
zhuiguang-ai/
|
||||
├── prisma/ # 数据库Schema + 迁移 + 种子
|
||||
├── public/ # 静态资源 (logo.png, logos/)
|
||||
├── scripts/ # 独立脚本 (discover-skills, import-tools)
|
||||
├── prisma/ # 数据库Schema + 迁移 + 种子
|
||||
├── public/ # 静态资源 (logo.png, logos/)
|
||||
├── 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 # 首页
|
||||
│ ├── components/ # 组件 (Header, Footer, UI, Common)
|
||||
│ ├── lib/ # 工具库 (prisma, auth, admin-auth, github, deepseek, logo-fetcher, logo-storage, utils)
|
||||
│ └── middleware.ts # 路由保护
|
||||
├── .env # 环境变量
|
||||
├── restart.ps1 # 服务重启脚本(端口8301-8310)
|
||||
└── package.json # 依赖配置
|
||||
│ ├── components/ # 组件 (Header, Footer, UI, Common)
|
||||
│ ├── lib/ # 工具库 (prisma, auth, admin-auth, github, deepseek, logo-fetcher, logo-storage, utils)
|
||||
│ └── middleware.ts # 路由保护
|
||||
├── .env # 环境变量
|
||||
├── restart.ps1 # 服务重启脚本(端口8301-8310)
|
||||
└── package.json # 依赖配置
|
||||
```
|
||||
|
||||
## 模块文档
|
||||
|
||||
| 序号 | 模块 | 文档 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 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请求限制
|
||||
|
||||
@@ -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 | 添加任务调度系统+权限体系+社区功能 |
|
||||
|
||||
@@ -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 配置键
|
||||
|
||||
|
||||
@@ -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%透明度)
|
||||
|
||||
@@ -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直接生成新闻内容**(会编造假新闻),必须基于真实新闻素材整理
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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
|
||||
- 重试工具函数
|
||||
|
||||
@@ -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()` 发放积分,包含:
|
||||
- 每日上限检查(防刷分)
|
||||
- 事务写入(积分变更 + 历史记录同时写入)
|
||||
- 等级自动随积分变化更新
|
||||
|
||||
### 通知自动触发
|
||||
以下操作自动生成通知:
|
||||
- 回复评论 → 通知评论作者
|
||||
- 点赞评论 → 通知评论作者
|
||||
- 回复话题 → 通知话题作者
|
||||
|
||||
### 权限控制
|
||||
- 所有写操作(评论/发帖/回复/点赞)需已登录
|
||||
- 编辑/删除操作仅限作者本人
|
||||
- 锁定话题禁止回复
|
||||
@@ -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"
|
||||
Generated
+103
-4
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
+70
-31
@@ -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({
|
||||
model: "deepseek-v4-pro", messages: [{ role: "user", content: prompt }], temperature: 0.3,
|
||||
});
|
||||
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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,109 @@ 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";
|
||||
|
||||
interface CheckResult {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
accessible: boolean;
|
||||
logoFetched: boolean;
|
||||
logoMethod: string;
|
||||
logoUrl: string;
|
||||
}
|
||||
|
||||
async function checkSingleTool(tool: {
|
||||
id: number;
|
||||
name: string;
|
||||
websiteUrl: string | null;
|
||||
logoUrl: string | null;
|
||||
}, type: "published" | "pending"): Promise<CheckResult> {
|
||||
const url = tool.websiteUrl;
|
||||
let accessible = false;
|
||||
let html: string | null = null;
|
||||
|
||||
if (url) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
accessible = res.status >= 200 && res.status < 400;
|
||||
|
||||
if (accessible) {
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
if (ct.includes("text/html")) {
|
||||
html = await res.text();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
accessible = false;
|
||||
}
|
||||
}
|
||||
|
||||
let logoFetched = false;
|
||||
let logoMethod = "";
|
||||
let logoUrl = tool.logoUrl || "";
|
||||
|
||||
const needsLogo = !logoUrl || logoUrl === "" || (!logoUrl.startsWith("/logos/") && !logoUrl.startsWith("data:"));
|
||||
|
||||
if (accessible && url && needsLogo) {
|
||||
try {
|
||||
let logoResult = { url: "", method: "" };
|
||||
|
||||
if (html) {
|
||||
logoResult = await fetchLogoWithMethod(url, html);
|
||||
}
|
||||
|
||||
if (!logoResult.url) {
|
||||
logoResult = await fetchLogoWithMethod(url);
|
||||
}
|
||||
|
||||
if (logoResult.url) {
|
||||
const localPath = await downloadLogo(logoResult.url, tool.name.replace(/[^a-z0-9]/gi, "_").substring(0, 20));
|
||||
|
||||
if (type === "published") {
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: { logoUrl: localPath },
|
||||
});
|
||||
} else {
|
||||
await prisma.pendingTool.update({
|
||||
where: { id: tool.id },
|
||||
data: { logoUrl: localPath },
|
||||
});
|
||||
}
|
||||
|
||||
logoFetched = true;
|
||||
logoMethod = logoResult.method;
|
||||
logoUrl = localPath;
|
||||
}
|
||||
} catch {
|
||||
// Logo fetch failed silently
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
url: url || "",
|
||||
accessible,
|
||||
logoFetched,
|
||||
logoMethod,
|
||||
logoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdmin();
|
||||
@@ -10,15 +113,13 @@ export async function POST(request: Request) {
|
||||
|
||||
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 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({
|
||||
@@ -28,101 +129,19 @@ export async function POST(request: Request) {
|
||||
select: { id: true, name: true, websiteUrl: true, logoUrl: true },
|
||||
});
|
||||
|
||||
const results: {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
accessible: boolean;
|
||||
logoFetched: boolean;
|
||||
logoMethod: string;
|
||||
logoUrl: string;
|
||||
}[] = [];
|
||||
const CONCURRENCY = 10;
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
const url = tool.websiteUrl;
|
||||
let accessible = false;
|
||||
let html: string | null = null;
|
||||
|
||||
if (url) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
accessible = res.status >= 200 && res.status < 400;
|
||||
|
||||
if (accessible) {
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
if (ct.includes("text/html")) {
|
||||
html = await res.text();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
accessible = false;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let logoFetched = false;
|
||||
let logoMethod = "";
|
||||
let logoUrl = tool.logoUrl || "";
|
||||
|
||||
const needsLogo = !logoUrl || logoUrl === "" || (!logoUrl.startsWith("/logos/") && !logoUrl.startsWith("data:"));
|
||||
|
||||
if (accessible && url && needsLogo) {
|
||||
try {
|
||||
let logoResult = { url: "", method: "" };
|
||||
|
||||
if (html) {
|
||||
logoResult = await fetchLogoWithMethod(url, html);
|
||||
}
|
||||
|
||||
if (!logoResult.url) {
|
||||
logoResult = await fetchLogoWithMethod(url);
|
||||
}
|
||||
|
||||
if (logoResult.url) {
|
||||
const localPath = await downloadLogo(logoResult.url, tool.name.replace(/[^a-z0-9]/gi, "_").substring(0, 20));
|
||||
|
||||
if (type === "published") {
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: { logoUrl: localPath },
|
||||
});
|
||||
} else {
|
||||
await prisma.pendingTool.update({
|
||||
where: { id: tool.id },
|
||||
data: { logoUrl: localPath },
|
||||
});
|
||||
}
|
||||
|
||||
logoFetched = true;
|
||||
logoMethod = logoResult.method;
|
||||
logoUrl = localPath;
|
||||
}
|
||||
} catch {
|
||||
// Logo fetch failed silently
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
url: url || "",
|
||||
accessible,
|
||||
logoFetched,
|
||||
logoMethod,
|
||||
logoUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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,28 +11,35 @@ 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({
|
||||
data: {
|
||||
name: pending.name,
|
||||
slug,
|
||||
categoryId: pending.categoryId!,
|
||||
description: pending.description || "",
|
||||
logoUrl: pending.logoUrl,
|
||||
sourceUrl: pending.sourceUrl || "",
|
||||
sourceType: pending.sourceType || "github",
|
||||
features,
|
||||
tags,
|
||||
status: "published",
|
||||
},
|
||||
const skillRef: { current: { id: number } | null } = { current: null };
|
||||
await prisma.$transaction(async (tx) => {
|
||||
skillRef.current = await tx.skill.create({
|
||||
data: {
|
||||
name: pending.name,
|
||||
slug,
|
||||
categoryId: pending.categoryId!,
|
||||
description: pending.description || "",
|
||||
logoUrl: pending.logoUrl,
|
||||
sourceUrl: pending.sourceUrl || "",
|
||||
sourceType: pending.sourceType || "github",
|
||||
features,
|
||||
tags,
|
||||
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 });
|
||||
|
||||
@@ -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,27 +53,30 @@ export async function POST(
|
||||
await prisma.category.findFirst({ orderBy: { sortOrder: "asc" } })
|
||||
)?.id ?? 1);
|
||||
|
||||
const tool = await prisma.tool.create({
|
||||
data: {
|
||||
name: pending.name,
|
||||
slug,
|
||||
categoryId,
|
||||
description: pending.description ?? "",
|
||||
logoUrl: pending.logoUrl,
|
||||
websiteUrl: pending.websiteUrl ?? "",
|
||||
pricingModel: pending.pricingModel ?? "freemium",
|
||||
pricingDetail: pending.pricingDetail,
|
||||
features: pending.features as any,
|
||||
tags: pending.tags as any,
|
||||
status: "published",
|
||||
},
|
||||
let toolId = 0;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const tool = await tx.tool.create({
|
||||
data: {
|
||||
name: pending.name,
|
||||
slug,
|
||||
categoryId,
|
||||
description: pending.description ?? "",
|
||||
logoUrl: pending.logoUrl,
|
||||
websiteUrl: pending.websiteUrl ?? "",
|
||||
pricingModel: pending.pricingModel ?? "freemium",
|
||||
pricingDetail: pending.pricingDetail,
|
||||
features: pending.features as any,
|
||||
tags: pending.tags as any,
|
||||
status: "published",
|
||||
},
|
||||
});
|
||||
toolId = tool.id;
|
||||
await tx.pendingTool.delete({ where: { id } });
|
||||
});
|
||||
|
||||
await prisma.pendingTool.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
toolId: tool.id,
|
||||
toolId,
|
||||
message: "发布成功",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,45 @@
|
||||
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;
|
||||
try {
|
||||
const ip = request.headers.get("x-forwarded-for") || "unknown";
|
||||
if (!rateLimit(ip, 5, 60000)) {
|
||||
return NextResponse.json({ error: "请求过于频繁,请稍后再试" }, { status: 429 });
|
||||
}
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: "邮箱和密码不能为空" }, { status: 400 });
|
||||
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 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
name: name || null,
|
||||
role: "user",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ 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 });
|
||||
}
|
||||
|
||||
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: {
|
||||
email,
|
||||
passwordHash,
|
||||
name: name || null,
|
||||
role: "user",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ id: user.id, email: user.email, name: user.name },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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 } }
|
||||
|
||||
@@ -4,108 +4,123 @@ 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 });
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const type = searchParams.get("type") || "all";
|
||||
const page = parseInt(searchParams.get("page") || "1", 10);
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
|
||||
|
||||
const where: Record<string, unknown> = { userId: parseInt(session.user.id) };
|
||||
if (type === "tool") {
|
||||
where.toolId = { not: null };
|
||||
} else if (type === "skill") {
|
||||
where.skillId = { not: null };
|
||||
}
|
||||
|
||||
const [favorites, total] = await Promise.all([
|
||||
prisma.favorite.findMany({
|
||||
where,
|
||||
include: {
|
||||
tool: true,
|
||||
skill: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.favorite.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ favorites, total, page, pageSize });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message || "获取收藏失败" }, { status: 500 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const type = searchParams.get("type") || "all";
|
||||
const page = parseInt(searchParams.get("page") || "1", 10);
|
||||
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
|
||||
|
||||
const where: any = { userId: parseInt(session.user.id) };
|
||||
if (type === "tool") {
|
||||
where.toolId = { not: null };
|
||||
} else if (type === "skill") {
|
||||
where.skillId = { not: null };
|
||||
}
|
||||
|
||||
const [favorites, total] = await Promise.all([
|
||||
prisma.favorite.findMany({
|
||||
where,
|
||||
include: {
|
||||
tool: true,
|
||||
skill: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.favorite.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ favorites, total, page, pageSize });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { toolId, skillId } = body;
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json(
|
||||
{ error: "必须提供 toolId 或 skillId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (toolId) {
|
||||
const tool = await prisma.tool.findUnique({ where: { id: toolId } });
|
||||
if (!tool) return NextResponse.json({ error: "工具不存在" }, { status: 404 });
|
||||
}
|
||||
if (skillId) {
|
||||
const skill = await prisma.skill.findUnique({ where: { id: skillId } });
|
||||
if (!skill) return NextResponse.json({ error: "技能不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { userId };
|
||||
if (toolId) where.toolId = toolId;
|
||||
if (skillId) where.skillId = skillId;
|
||||
|
||||
const existing = await prisma.favorite.findFirst({ where });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "已收藏" }, { status: 409 });
|
||||
}
|
||||
|
||||
const fav = await prisma.favorite.create({
|
||||
data: {
|
||||
userId,
|
||||
toolId: toolId || null,
|
||||
skillId: skillId || null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(fav, { status: 201 });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message || "添加收藏失败" }, { status: 500 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { toolId, skillId } = body;
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json(
|
||||
{ error: "必须提供 toolId 或 skillId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (toolId) {
|
||||
const tool = await prisma.tool.findUnique({ where: { id: toolId } });
|
||||
if (!tool) return NextResponse.json({ error: "工具不存在" }, { status: 404 });
|
||||
}
|
||||
if (skillId) {
|
||||
const skill = await prisma.skill.findUnique({ where: { id: skillId } });
|
||||
if (!skill) return NextResponse.json({ error: "技能不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const where: any = { userId };
|
||||
if (toolId) where.toolId = toolId;
|
||||
if (skillId) where.skillId = skillId;
|
||||
|
||||
const existing = await prisma.favorite.findFirst({ where });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "已收藏" }, { status: 409 });
|
||||
}
|
||||
|
||||
const fav = await prisma.favorite.create({
|
||||
data: {
|
||||
userId,
|
||||
toolId: toolId || null,
|
||||
skillId: skillId || null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(fav, { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { toolId, skillId } = body;
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json(
|
||||
{ error: "必须提供 toolId 或 skillId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { toolId, skillId } = body;
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json(
|
||||
{ error: "必须提供 toolId 或 skillId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const where: any = { 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 });
|
||||
}
|
||||
|
||||
@@ -4,75 +4,85 @@ import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const history = await prisma.browsingHistory.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
tool: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
pricingModel: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
sourceType: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { viewedAt: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return NextResponse.json({ history });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message || "获取历史记录失败" }, { status: 500 });
|
||||
}
|
||||
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
const history = await prisma.browsingHistory.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
tool: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
pricingModel: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
logoUrl: true,
|
||||
sourceType: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { viewedAt: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return NextResponse.json({ history });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { toolId, skillId } = body;
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json({ error: "必须提供 toolId 或 skillId" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (toolId) {
|
||||
await prisma.browsingHistory.upsert({
|
||||
where: { userId_toolId: { userId, toolId } },
|
||||
update: { viewedAt: new Date() },
|
||||
create: { userId, toolId },
|
||||
});
|
||||
}
|
||||
|
||||
if (skillId) {
|
||||
await prisma.browsingHistory.upsert({
|
||||
where: { userId_skillId: { userId, skillId } },
|
||||
update: { viewedAt: new Date() },
|
||||
create: { userId, skillId },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
return NextResponse.json({ error: err.message || "记录浏览历史失败" }, { status: 500 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { toolId, skillId } = body;
|
||||
const userId = parseInt(session.user.id);
|
||||
|
||||
if (!toolId && !skillId) {
|
||||
return NextResponse.json({ error: "必须提供 toolId 或 skillId" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (toolId) {
|
||||
await prisma.browsingHistory.upsert({
|
||||
where: { userId_toolId: { userId, toolId } },
|
||||
update: { viewedAt: new Date() },
|
||||
create: { userId, toolId },
|
||||
});
|
||||
}
|
||||
|
||||
if (skillId) {
|
||||
await prisma.browsingHistory.upsert({
|
||||
where: { userId_skillId: { userId, skillId } },
|
||||
update: { viewedAt: new Date() },
|
||||
create: { userId, skillId },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,53 +4,70 @@ import { authOptions } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: parseInt(session.user.id) },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
bio: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: parseInt(session.user.id) },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(user);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
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, 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 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name } = body;
|
||||
|
||||
if (name !== undefined && (typeof name !== "string" || name.length > 100)) {
|
||||
return NextResponse.json({ error: "昵称格式不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: parseInt(session.user.id) },
|
||||
data: { name: name || null },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(user);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,140 +91,14 @@ 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"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<CategoryContent
|
||||
initialTools={tools}
|
||||
initialTotal={total}
|
||||
initialPage={page}
|
||||
categoryId={category.id}
|
||||
categoryName={category.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+19
-139
@@ -1,153 +1,33 @@
|
||||
"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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn("github", { callbackUrl: "/" })}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-border/60 bg-white text-sm font-medium text-foreground hover:bg-muted/30 hover:shadow-sm transition-all"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</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>
|
||||
<LoginForm
|
||||
onSuccess={() => {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
socialLogin={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn("github", { callbackUrl: "/" })}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-border/60 bg-white text-sm font-medium text-foreground hover:bg-muted/30 hover:shadow-sm transition-all"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
使用 GitHub 登录
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+49
-391
@@ -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;
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "追光AI - 汇聚全球优质AI工具",
|
||||
description: "发现、探索、使用 — 让人工智能触手可及。浏览全球优质AI工具,按分类查找最适合你的AI应用。",
|
||||
openGraph: {
|
||||
title: "追光AI - 汇聚全球优质AI工具",
|
||||
description: "发现、探索、使用 — 让人工智能触手可及。浏览全球优质AI工具,按分类查找最适合你的AI应用。",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 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" } },
|
||||
},
|
||||
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
|
||||
);
|
||||
Object.values(sectionRefs.current).forEach((el) => {
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [categories]);
|
||||
orderBy: { sortOrder: "asc" },
|
||||
}),
|
||||
prisma.tool.count({ where: { status: "published" } }),
|
||||
prisma.skill.count({ where: { status: "published" } }),
|
||||
]);
|
||||
|
||||
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 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,
|
||||
}));
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
const parentCategories = allCategories
|
||||
.filter((cat) => cat.parentId === null)
|
||||
.map((cat) => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
icon: cat.icon,
|
||||
}));
|
||||
|
||||
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>
|
||||
<HomePageClient
|
||||
flatCategories={flatCategories}
|
||||
parentCategories={parentCategories}
|
||||
totalTools={totalTools}
|
||||
totalSkills={totalSkills}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
+49
-244
@@ -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 className="flex flex-col lg:flex-row gap-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<ReviewsList initialReviews={serializedReviews} initialTotal={total} />
|
||||
</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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
const [skill, setSkill] = useState<SkillDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
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 }),
|
||||
}).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>
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
export default async function SkillDetailPage({ params }: Props) {
|
||||
const { slug } = params;
|
||||
|
||||
const features = Array.isArray(skill.features) ? skill.features : [];
|
||||
const tags = Array.isArray(skill.tags) ? skill.tags : [];
|
||||
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(() => {});
|
||||
|
||||
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>
|
||||
|
||||
+49
-408
@@ -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;
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "追光Skills - 开源AI技能生态",
|
||||
description: "国内优质AI开源项目,一站式发现与学习。浏览精选开源AI技能,构建智能未来。",
|
||||
openGraph: {
|
||||
title: "追光Skills - 开源AI技能生态",
|
||||
description: "国内优质AI开源项目,一站式发现与学习。浏览精选开源AI技能,构建智能未来。",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 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" } },
|
||||
},
|
||||
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 }
|
||||
);
|
||||
Object.values(sectionRefs.current).forEach((el) => {
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [categories]);
|
||||
orderBy: { sortOrder: "asc" },
|
||||
}),
|
||||
prisma.tool.count({ where: { status: "published" } }),
|
||||
prisma.skill.count({ where: { status: "published" } }),
|
||||
]);
|
||||
|
||||
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 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,
|
||||
}));
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
const parentCategories = allCategories
|
||||
.filter((cat) => cat.parentId === null)
|
||||
.map((cat) => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
icon: cat.icon,
|
||||
}));
|
||||
|
||||
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>
|
||||
<SkillsPageClient
|
||||
flatCategories={flatCategories}
|
||||
parentCategories={parentCategories}
|
||||
totalTools={totalTools}
|
||||
totalSkills={totalSkills}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+74
-103
@@ -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 }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [slug, status]);
|
||||
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 (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>
|
||||
);
|
||||
if (!tool) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: { viewCount: { increment: 1 } },
|
||||
}).catch(() => {});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const features = Array.isArray(tool.features) ? tool.features : [];
|
||||
const tags = Array.isArray(tool.tags) ? tool.tags : [];
|
||||
const favoriteCount = await prisma.favorite.count({
|
||||
where: { toolId: tool.id },
|
||||
});
|
||||
|
||||
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>
|
||||
<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>
|
||||
{favoriteCount} 人收藏
|
||||
</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} 人收藏
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -322,4 +293,4 @@ export default function ToolDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+134
-509
@@ -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应用和服务。`;
|
||||
}
|
||||
|
||||
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应用。`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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>
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
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 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 [searchInput, setSearchInput] = useState(search);
|
||||
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",
|
||||
};
|
||||
|
||||
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);
|
||||
if (search) {
|
||||
where.name = { contains: search };
|
||||
}
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
if (categoryId) {
|
||||
const parsedId = parseInt(categoryId);
|
||||
if (!isNaN(parsedId)) {
|
||||
where.categoryId = parsedId;
|
||||
}
|
||||
}
|
||||
|
||||
const activeCategoryName = categoryId
|
||||
? categories.find((c) => c.id.toString() === categoryId)?.name || ""
|
||||
: "全部分类";
|
||||
if (pricingModel) {
|
||||
where.pricingModel = pricingModel as Prisma.EnumPricingModelFilter["equals"];
|
||||
}
|
||||
|
||||
const activePricingName = pricingModel
|
||||
? pricingOptions.find((p) => p.value === pricingModel)?.label || ""
|
||||
: "";
|
||||
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 }),
|
||||
]);
|
||||
|
||||
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>
|
||||
return { tools, total };
|
||||
})(),
|
||||
]);
|
||||
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-border/50 to-transparent" />
|
||||
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,
|
||||
}));
|
||||
|
||||
<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>
|
||||
);
|
||||
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[] }[],
|
||||
})),
|
||||
}));
|
||||
|
||||
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>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">加载中...</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>
|
||||
}
|
||||
>
|
||||
<ToolsContent
|
||||
initialCategories={categories}
|
||||
initialTools={initialTools}
|
||||
initialTotal={toolData.total}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user