diff --git a/.trae/CHANGELOG b/.trae/CHANGELOG index 47e88fa..963e313 100644 --- a/.trae/CHANGELOG +++ b/.trae/CHANGELOG @@ -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 记录) \ No newline at end of file diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index b56884a..2f6fb30 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -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) — 国内科技资讯 diff --git a/.trae/specs/modules.md b/.trae/specs/modules.md index 9321cce..d3f6cbb 100644 --- a/.trae/specs/modules.md +++ b/.trae/specs/modules.md @@ -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= → PendingTool / PendingSkill (待审核) → 管理员审核(编辑/发布/拒绝/批量操作) → Tool / Skill (已发布) - → 前台展示(分类浏览/搜索/详情) + → 前台展示(分类浏览/搜索/详情/评测/评论) ``` ## 关键特性 @@ -79,12 +85,16 @@ DEEPSEEK_API_KEY= - **检测+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请求限制 diff --git a/.trae/specs/modules/01-database.md b/.trae/specs/modules/01-database.md index 96bb97e..27fd150 100644 --- a/.trae/specs/modules/01-database.md +++ b/.trae/specs/modules/01-database.md @@ -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 | 添加任务调度系统+权限体系+社区功能 | diff --git a/.trae/specs/modules/02-api.md b/.trae/specs/modules/02-api.md index f0db86b..ae47ca3 100644 --- a/.trae/specs/modules/02-api.md +++ b/.trae/specs/modules/02-api.md @@ -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 配置键 diff --git a/.trae/specs/modules/03-pages.md b/.trae/specs/modules/03-pages.md index a710d5a..30b6176 100644 --- a/.trae/specs/modules/03-pages.md +++ b/.trae/specs/modules/03-pages.md @@ -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新闻发现 - 系统配置: 系统配置 + - 任务调度: 任务中心、任务日志 + - 角色权限: 角色权限管理 - 登录页不显示侧边栏 diff --git a/.trae/specs/modules/04-components-and-styles.md b/.trae/specs/modules/04-components-and-styles.md index 64efbde..cb0ed6c 100644 --- a/.trae/specs/modules/04-components-and-styles.md +++ b/.trae/specs/modules/04-components-and-styles.md @@ -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%透明度) diff --git a/.trae/specs/modules/05-ai-discovery.md b/.trae/specs/modules/05-ai-discovery.md index d8d65e7..763c6a5 100644 --- a/.trae/specs/modules/05-ai-discovery.md +++ b/.trae/specs/modules/05-ai-discovery.md @@ -79,19 +79,6 @@ AI发现模块是追光AI的核心功能,采用"提示词+JSON粘贴导入"模 - `downloadLogo(url, filename): Promise` — 下载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直接生成新闻内容**(会编造假新闻),必须基于真实新闻素材整理 diff --git a/.trae/specs/modules/06-auth.md b/.trae/specs/modules/06-auth.md index 3d25ce0..0702609 100644 --- a/.trae/specs/modules/06-auth.md +++ b/.trae/specs/modules/06-auth.md @@ -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= -GITHUB_ID= -GITHUB_SECRET= +NEXTAUTH_URL=http://localhost:8301 +NEXTAUTH_SECRET=zhuiguang-ai-dev-secret +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= ``` diff --git a/.trae/specs/modules/07-lib-and-scripts.md b/.trae/specs/modules/07-lib-and-scripts.md index 808fe53..6ed372a 100644 --- a/.trae/specs/modules/07-lib-and-scripts.md +++ b/.trae/specs/modules/07-lib-and-scripts.md @@ -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` — 返回Logo URL - 导出 `fetchLogoWithMethod(websiteUrl, html?): Promise` — 返回 `{url, method}` - 10种抓取策略按优先级执行 - 支持从SystemConfig读取配置 -- 每种成功方法返回中文名称(如"HTML Link图标"、"OG/Twitter图片"等) -- 详见 [05-ai-discovery.md](./05-ai-discovery.md) +- 每种成功方法返回中文名称 ### logo-storage.ts - Logo本地存储 - 导出 `downloadLogo(url, filename): Promise` - 下载远程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 +- 重试工具函数 diff --git a/.trae/specs/modules/08-community.md b/.trae/specs/modules/08-community.md new file mode 100644 index 0000000..96d5a2a --- /dev/null +++ b/.trae/specs/modules/08-community.md @@ -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()` 发放积分,包含: +- 每日上限检查(防刷分) +- 事务写入(积分变更 + 历史记录同时写入) +- 等级自动随积分变化更新 + +### 通知自动触发 +以下操作自动生成通知: +- 回复评论 → 通知评论作者 +- 点赞评论 → 通知评论作者 +- 回复话题 → 通知话题作者 + +### 权限控制 +- 所有写操作(评论/发帖/回复/点赞)需已登录 +- 编辑/删除操作仅限作者本人 +- 锁定话题禁止回复 \ No newline at end of file diff --git a/.trae/specs/modules/09-devops.md b/.trae/specs/modules/09-devops.md new file mode 100644 index 0000000..bc942d4 --- /dev/null +++ b/.trae/specs/modules/09-devops.md @@ -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" diff --git a/package-lock.json b/package-lock.json index 43ea689..ac44806 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 8e34d5b..838acd3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260507000000_add_daily_reports/migration.sql b/prisma/migrations/20260507000000_add_daily_reports/migration.sql new file mode 100644 index 0000000..07e6717 --- /dev/null +++ b/prisma/migrations/20260507000000_add_daily_reports/migration.sql @@ -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; diff --git a/prisma/migrations/20260508000000_add_hot_score/migration.sql b/prisma/migrations/20260508000000_add_hot_score/migration.sql deleted file mode 100644 index 34b78e5..0000000 --- a/prisma/migrations/20260508000000_add_hot_score/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `daily_report_items` ADD COLUMN `hot_score` INT NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20260525000000_expand_user_levels/migration.sql b/prisma/migrations/20260525000000_expand_user_levels/migration.sql new file mode 100644 index 0000000..a9186cd --- /dev/null +++ b/prisma/migrations/20260525000000_expand_user_levels/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b7048ba..03c61c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} + diff --git a/scripts/check-migrations.mjs b/scripts/check-migrations.mjs new file mode 100644 index 0000000..717b8b0 --- /dev/null +++ b/scripts/check-migrations.mjs @@ -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(); + }); diff --git a/scripts/cron-task1.sh b/scripts/cron-task1.sh new file mode 100644 index 0000000..c30fc18 --- /dev/null +++ b/scripts/cron-task1.sh @@ -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 \ No newline at end of file diff --git a/scripts/daily-discover.mjs b/scripts/daily-discover.mjs index 64cb906..46394e9 100644 --- a/scripts/daily-discover.mjs +++ b/scripts/daily-discover.mjs @@ -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()); diff --git a/scripts/daily-news.mjs b/scripts/daily-news.mjs index edcaad0..024a498 100644 --- a/scripts/daily-news.mjs +++ b/scripts/daily-news.mjs @@ -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(); diff --git a/scripts/fix-forum-categories.mjs b/scripts/fix-forum-categories.mjs new file mode 100644 index 0000000..691a5af --- /dev/null +++ b/scripts/fix-forum-categories.mjs @@ -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(); + }); diff --git a/scripts/lib/retry.mjs b/scripts/lib/retry.mjs new file mode 100644 index 0000000..007ae7f --- /dev/null +++ b/scripts/lib/retry.mjs @@ -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 }; \ No newline at end of file diff --git a/scripts/migrate-user-levels.mjs b/scripts/migrate-user-levels.mjs new file mode 100644 index 0000000..c935aac --- /dev/null +++ b/scripts/migrate-user-levels.mjs @@ -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(); + }); diff --git a/scripts/run-migration-manual.mjs b/scripts/run-migration-manual.mjs new file mode 100644 index 0000000..f41c4ce --- /dev/null +++ b/scripts/run-migration-manual.mjs @@ -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(); diff --git a/scripts/seed-forum-categories.mjs b/scripts/seed-forum-categories.mjs new file mode 100644 index 0000000..98b4dc0 --- /dev/null +++ b/scripts/seed-forum-categories.mjs @@ -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(); + }); diff --git a/scripts/seed-task-configs.mjs b/scripts/seed-task-configs.mjs new file mode 100644 index 0000000..0cf01eb --- /dev/null +++ b/scripts/seed-task-configs.mjs @@ -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); +}); \ No newline at end of file diff --git a/scripts/task1-discover-tools.mjs b/scripts/task1-discover-tools.mjs new file mode 100644 index 0000000..696697a --- /dev/null +++ b/scripts/task1-discover-tools.mjs @@ -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 = /([\s\S]*?)<\/item>/g; + let m; + while ((m = regex.exec(xml)) !== null) { + const b = m[1]; + const t = (b.match(/<!\[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); +}); \ No newline at end of file diff --git a/scripts/task3-check-tools.mjs b/scripts/task3-check-tools.mjs new file mode 100644 index 0000000..6bc925f --- /dev/null +++ b/scripts/task3-check-tools.mjs @@ -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); +}); diff --git a/scripts/task5-update-stars.mjs b/scripts/task5-update-stars.mjs new file mode 100644 index 0000000..785d5fc --- /dev/null +++ b/scripts/task5-update-stars.mjs @@ -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); +}); diff --git a/scripts/task6-review-hot.mjs b/scripts/task6-review-hot.mjs new file mode 100644 index 0000000..012a632 --- /dev/null +++ b/scripts/task6-review-hot.mjs @@ -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); +}); diff --git a/src/app/(admin)/admin/categories/page.tsx b/src/app/(admin)/admin/categories/page.tsx index 3cc0019..b984889 100644 --- a/src/app/(admin)/admin/categories/page.tsx +++ b/src/app/(admin)/admin/categories/page.tsx @@ -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"> diff --git a/src/app/(admin)/admin/error.tsx b/src/app/(admin)/admin/error.tsx new file mode 100644 index 0000000..6484309 --- /dev/null +++ b/src/app/(admin)/admin/error.tsx @@ -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> + ) +} \ No newline at end of file diff --git a/src/app/(admin)/admin/layout.tsx b/src/app/(admin)/admin/layout.tsx index 9e42f4c..efb1bf0 100644 --- a/src/app/(admin)/admin/layout.tsx +++ b/src/app/(admin)/admin/layout.tsx @@ -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({ diff --git a/src/app/(admin)/admin/loading.tsx b/src/app/(admin)/admin/loading.tsx new file mode 100644 index 0000000..53b0b7e --- /dev/null +++ b/src/app/(admin)/admin/loading.tsx @@ -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> + ) +} \ No newline at end of file diff --git a/src/app/(admin)/admin/login/page.tsx b/src/app/(admin)/admin/login/page.tsx index 1bf9c62..6716416 100644 --- a/src/app/(admin)/admin/login/page.tsx +++ b/src/app/(admin)/admin/login/page.tsx @@ -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> + ); +} diff --git a/src/app/(admin)/admin/pending-skills/page.tsx b/src/app/(admin)/admin/pending-skills/page.tsx index cf883ab..a06e617 100644 --- a/src/app/(admin)/admin/pending-skills/page.tsx +++ b/src/app/(admin)/admin/pending-skills/page.tsx @@ -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"> diff --git a/src/app/(admin)/admin/pending/page.tsx b/src/app/(admin)/admin/pending/page.tsx index 1707d05..7bf4f1c 100644 --- a/src/app/(admin)/admin/pending/page.tsx +++ b/src/app/(admin)/admin/pending/page.tsx @@ -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")} diff --git a/src/app/(admin)/admin/roles/page.tsx b/src/app/(admin)/admin/roles/page.tsx index c1cd458..3f9aef5 100644 --- a/src/app/(admin)/admin/roles/page.tsx +++ b/src/app/(admin)/admin/roles/page.tsx @@ -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"> diff --git a/src/app/(admin)/admin/skill-categories/page.tsx b/src/app/(admin)/admin/skill-categories/page.tsx index ac777d1..8eae16b 100644 --- a/src/app/(admin)/admin/skill-categories/page.tsx +++ b/src/app/(admin)/admin/skill-categories/page.tsx @@ -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"> diff --git a/src/app/(admin)/admin/skills/page.tsx b/src/app/(admin)/admin/skills/page.tsx index 6f8ba07..93b306d 100644 --- a/src/app/(admin)/admin/skills/page.tsx +++ b/src/app/(admin)/admin/skills/page.tsx @@ -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"> diff --git a/src/app/(admin)/admin/tasks/[taskKey]/page.tsx b/src/app/(admin)/admin/tasks/[taskKey]/page.tsx new file mode 100644 index 0000000..69b14f9 --- /dev/null +++ b/src/app/(admin)/admin/tasks/[taskKey]/page.tsx @@ -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> + ); +} \ No newline at end of file diff --git a/src/app/(admin)/admin/tasks/logs/page.tsx b/src/app/(admin)/admin/tasks/logs/page.tsx new file mode 100644 index 0000000..0d5f5d8 --- /dev/null +++ b/src/app/(admin)/admin/tasks/logs/page.tsx @@ -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> + ); +} \ No newline at end of file diff --git a/src/app/(admin)/admin/tasks/page.tsx b/src/app/(admin)/admin/tasks/page.tsx new file mode 100644 index 0000000..7660eaa --- /dev/null +++ b/src/app/(admin)/admin/tasks/page.tsx @@ -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> + ); +} \ No newline at end of file diff --git a/src/app/(admin)/admin/tools/page.tsx b/src/app/(admin)/admin/tools/page.tsx index 099d1d3..ddedae0 100644 --- a/src/app/(admin)/admin/tools/page.tsx +++ b/src/app/(admin)/admin/tools/page.tsx @@ -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"> diff --git a/src/app/HomePageClient.tsx b/src/app/HomePageClient.tsx new file mode 100644 index 0000000..980c6e7 --- /dev/null +++ b/src/app/HomePageClient.tsx @@ -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> + ); +} \ No newline at end of file diff --git a/src/app/api/admin/check-tools/route.ts b/src/app/api/admin/check-tools/route.ts index 9fcf0d6..a3cc11b 100644 --- a/src/app/api/admin/check-tools/route.ts +++ b/src/app/api/admin/check-tools/route.ts @@ -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 }); diff --git a/src/app/api/admin/discover-news/route.ts b/src/app/api/admin/discover-news/route.ts index 8b3f859..48748f2 100644 --- a/src/app/api/admin/discover-news/route.ts +++ b/src/app/api/admin/discover-news/route.ts @@ -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({ diff --git a/src/app/api/admin/discover-skills/route.ts b/src/app/api/admin/discover-skills/route.ts index c7c0a82..aeaa216 100644 --- a/src/app/api/admin/discover-skills/route.ts +++ b/src/app/api/admin/discover-skills/route.ts @@ -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 } }), diff --git a/src/app/api/admin/discover-tools/route.ts b/src/app/api/admin/discover-tools/route.ts index 153588f..42522e9 100644 --- a/src/app/api/admin/discover-tools/route.ts +++ b/src/app/api/admin/discover-tools/route.ts @@ -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 } }), diff --git a/src/app/api/admin/pending-skills/[id]/publish/route.ts b/src/app/api/admin/pending-skills/[id]/publish/route.ts index dfec168..17057ff 100644 --- a/src/app/api/admin/pending-skills/[id]/publish/route.ts +++ b/src/app/api/admin/pending-skills/[id]/publish/route.ts @@ -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 }); diff --git a/src/app/api/admin/pending-skills/route.ts b/src/app/api/admin/pending-skills/route.ts index adf9d5c..667ced7 100644 --- a/src/app/api/admin/pending-skills/route.ts +++ b/src/app/api/admin/pending-skills/route.ts @@ -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" } }), ]); diff --git a/src/app/api/admin/pending/[id]/publish/route.ts b/src/app/api/admin/pending/[id]/publish/route.ts index 739a869..59737ba 100644 --- a/src/app/api/admin/pending/[id]/publish/route.ts +++ b/src/app/api/admin/pending/[id]/publish/route.ts @@ -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) { diff --git a/src/app/api/admin/tasks/configs/route.ts b/src/app/api/admin/tasks/configs/route.ts new file mode 100644 index 0000000..6be0459 --- /dev/null +++ b/src/app/api/admin/tasks/configs/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/tasks/logs/route.ts b/src/app/api/admin/tasks/logs/route.ts new file mode 100644 index 0000000..6d519c8 --- /dev/null +++ b/src/app/api/admin/tasks/logs/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/tasks/run/route.ts b/src/app/api/admin/tasks/run/route.ts new file mode 100644 index 0000000..6d422cf --- /dev/null +++ b/src/app/api/admin/tasks/run/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 716e999..60ea318 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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 } - ); } diff --git a/src/app/api/comments/[id]/like/route.ts b/src/app/api/comments/[id]/like/route.ts new file mode 100644 index 0000000..6a713b1 --- /dev/null +++ b/src/app/api/comments/[id]/like/route.ts @@ -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 }); +} diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts new file mode 100644 index 0000000..04bbd0c --- /dev/null +++ b/src/app/api/comments/route.ts @@ -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 }); +} diff --git a/src/app/api/forum/categories/route.ts b/src/app/api/forum/categories/route.ts new file mode 100644 index 0000000..798491d --- /dev/null +++ b/src/app/api/forum/categories/route.ts @@ -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 }); +} diff --git a/src/app/api/forum/topics/[id]/posts/[postId]/route.ts b/src/app/api/forum/topics/[id]/posts/[postId]/route.ts new file mode 100644 index 0000000..54cc445 --- /dev/null +++ b/src/app/api/forum/topics/[id]/posts/[postId]/route.ts @@ -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 }); +} diff --git a/src/app/api/forum/topics/[id]/posts/route.ts b/src/app/api/forum/topics/[id]/posts/route.ts new file mode 100644 index 0000000..302c8c5 --- /dev/null +++ b/src/app/api/forum/topics/[id]/posts/route.ts @@ -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 } + ); +} diff --git a/src/app/api/forum/topics/[id]/route.ts b/src/app/api/forum/topics/[id]/route.ts new file mode 100644 index 0000000..5148350 --- /dev/null +++ b/src/app/api/forum/topics/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/forum/topics/route.ts b/src/app/api/forum/topics/route.ts new file mode 100644 index 0000000..d2934d5 --- /dev/null +++ b/src/app/api/forum/topics/route.ts @@ -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 } + ); +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..1659300 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -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 }); +} diff --git a/src/app/api/skill-reviews/rankings/route.ts b/src/app/api/skill-reviews/rankings/route.ts new file mode 100644 index 0000000..efdea18 --- /dev/null +++ b/src/app/api/skill-reviews/rankings/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/user/collections/[id]/route.ts b/src/app/api/user/collections/[id]/route.ts index c848e85..5feaf9d 100644 --- a/src/app/api/user/collections/[id]/route.ts +++ b/src/app/api/user/collections/[id]/route.ts @@ -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 } } diff --git a/src/app/api/user/favorites/route.ts b/src/app/api/user/favorites/route.ts index 6218af9..babb179 100644 --- a/src/app/api/user/favorites/route.ts +++ b/src/app/api/user/favorites/route.ts @@ -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 }); } diff --git a/src/app/api/user/history/route.ts b/src/app/api/user/history/route.ts index df8cb46..36dfecb 100644 --- a/src/app/api/user/history/route.ts +++ b/src/app/api/user/history/route.ts @@ -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 }); } diff --git a/src/app/api/user/points/route.ts b/src/app/api/user/points/route.ts new file mode 100644 index 0000000..e0cba2b --- /dev/null +++ b/src/app/api/user/points/route.ts @@ -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); +} diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 176ce7d..31a55f5 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -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); } diff --git a/src/app/categories/[slug]/CategoryContent.tsx b/src/app/categories/[slug]/CategoryContent.tsx new file mode 100644 index 0000000..e1d30de --- /dev/null +++ b/src/app/categories/[slug]/CategoryContent.tsx @@ -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> + )} + </> + ); +} \ No newline at end of file diff --git a/src/app/categories/[slug]/page.tsx b/src/app/categories/[slug]/page.tsx index 54b76ec..b180b5a 100644 --- a/src/app/categories/[slug]/page.tsx +++ b/src/app/categories/[slug]/page.tsx @@ -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> ); -} +} \ No newline at end of file diff --git a/src/app/community/[category]/page.tsx b/src/app/community/[category]/page.tsx new file mode 100644 index 0000000..7b9c46a --- /dev/null +++ b/src/app/community/[category]/page.tsx @@ -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> + ); +} diff --git a/src/app/community/page.tsx b/src/app/community/page.tsx new file mode 100644 index 0000000..e3e5872 --- /dev/null +++ b/src/app/community/page.tsx @@ -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> + ); +} diff --git a/src/app/community/topic/[id]/page.tsx b/src/app/community/topic/[id]/page.tsx new file mode 100644 index 0000000..df23928 --- /dev/null +++ b/src/app/community/topic/[id]/page.tsx @@ -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> + ); +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..e53603f --- /dev/null +++ b/src/app/error.tsx @@ -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> + ) +} \ No newline at end of file diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..0e803cf --- /dev/null +++ b/src/app/loading.tsx @@ -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> + ) +} \ No newline at end of file diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 01e8c83..6844400 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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> ); diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..88adca1 --- /dev/null +++ b/src/app/not-found.tsx @@ -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> + ) +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index b95179a..59d496a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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} + /> ); -} +} \ No newline at end of file diff --git a/src/app/reviews/ReviewsList.tsx b/src/app/reviews/ReviewsList.tsx new file mode 100644 index 0000000..1fccbd1 --- /dev/null +++ b/src/app/reviews/ReviewsList.tsx @@ -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> + )} + </> + ); +} \ No newline at end of file diff --git a/src/app/reviews/SidebarRankings.tsx b/src/app/reviews/SidebarRankings.tsx new file mode 100644 index 0000000..fea456e --- /dev/null +++ b/src/app/reviews/SidebarRankings.tsx @@ -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 }; \ No newline at end of file diff --git a/src/app/reviews/page.tsx b/src/app/reviews/page.tsx index b72b2ff..c2f0d80 100644 --- a/src/app/reviews/page.tsx +++ b/src/app/reviews/page.tsx @@ -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> - ); -} +} \ No newline at end of file diff --git a/src/app/skills/SkillsPageClient.tsx b/src/app/skills/SkillsPageClient.tsx new file mode 100644 index 0000000..21ceaac --- /dev/null +++ b/src/app/skills/SkillsPageClient.tsx @@ -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> + ); +} \ No newline at end of file diff --git a/src/app/skills/[slug]/page.tsx b/src/app/skills/[slug]/page.tsx index 064b8a0..104d50f 100644 --- a/src/app/skills/[slug]/page.tsx +++ b/src/app/skills/[slug]/page.tsx @@ -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> diff --git a/src/app/skills/page.tsx b/src/app/skills/page.tsx index 27c5c5b..fcad115 100644 --- a/src/app/skills/page.tsx +++ b/src/app/skills/page.tsx @@ -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} + /> ); -} +} \ No newline at end of file diff --git a/src/app/tools/ToolsContent.tsx b/src/app/tools/ToolsContent.tsx new file mode 100644 index 0000000..28fed38 --- /dev/null +++ b/src/app/tools/ToolsContent.tsx @@ -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> + ); +} \ No newline at end of file diff --git a/src/app/tools/[slug]/page.tsx b/src/app/tools/[slug]/page.tsx index a9da4c3..f4da112 100644 --- a/src/app/tools/[slug]/page.tsx +++ b/src/app/tools/[slug]/page.tsx @@ -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> ); -} +} \ No newline at end of file diff --git a/src/app/tools/page.tsx b/src/app/tools/page.tsx index 1719489..9d20957 100644 --- a/src/app/tools/page.tsx +++ b/src/app/tools/page.tsx @@ -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> ); -} +} \ No newline at end of file diff --git a/src/app/user/[id]/page.tsx b/src/app/user/[id]/page.tsx new file mode 100644 index 0000000..fe1833f --- /dev/null +++ b/src/app/user/[id]/page.tsx @@ -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> + ); +} diff --git a/src/app/user/collections/[id]/page.tsx b/src/app/user/collections/[id]/page.tsx new file mode 100644 index 0000000..b2a9349 --- /dev/null +++ b/src/app/user/collections/[id]/page.tsx @@ -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> + ); +} \ No newline at end of file diff --git a/src/app/user/collections/page.tsx b/src/app/user/collections/page.tsx new file mode 100644 index 0000000..4ddf948 --- /dev/null +++ b/src/app/user/collections/page.tsx @@ -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> + ); +} \ No newline at end of file diff --git a/src/app/user/page.tsx b/src/app/user/page.tsx index 1801561..05b026f 100644 --- a/src/app/user/page.tsx +++ b/src/app/user/page.tsx @@ -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 diff --git a/src/components/common/CommentCard.tsx b/src/components/common/CommentCard.tsx new file mode 100644 index 0000000..d6c3544 --- /dev/null +++ b/src/components/common/CommentCard.tsx @@ -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> + ); +} diff --git a/src/components/common/CommentForm.tsx b/src/components/common/CommentForm.tsx new file mode 100644 index 0000000..8acc253 --- /dev/null +++ b/src/components/common/CommentForm.tsx @@ -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)} + /> + </> + ); +} \ No newline at end of file diff --git a/src/components/common/CommentThread.tsx b/src/components/common/CommentThread.tsx new file mode 100644 index 0000000..df6daf2 --- /dev/null +++ b/src/components/common/CommentThread.tsx @@ -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> + ); +} diff --git a/src/components/common/FavoriteButton.tsx b/src/components/common/FavoriteButton.tsx index a5d677c..b8a1d9f 100644 --- a/src/components/common/FavoriteButton.tsx +++ b/src/components/common/FavoriteButton.tsx @@ -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(); } }; diff --git a/src/components/common/LevelProgress.tsx b/src/components/common/LevelProgress.tsx new file mode 100644 index 0000000..0323bfd --- /dev/null +++ b/src/components/common/LevelProgress.tsx @@ -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> + ); +} diff --git a/src/components/common/LoginForm.tsx b/src/components/common/LoginForm.tsx new file mode 100644 index 0000000..1dcc5cb --- /dev/null +++ b/src/components/common/LoginForm.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; + +interface Props { + onSuccess: () => void; + socialLogin?: React.ReactNode; +} + +export function LoginForm({ onSuccess, socialLogin }: Props) { + 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 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("自动登录失败,请手动登录"); + } else { + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + + if (result?.error) throw new Error("邮箱或密码错误"); + } + + onSuccess(); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( + <div className="space-y-4"> + <div className="text-center"> + <h2 className="text-xl font-bold"> + {mode === "login" ? "欢迎回来" : "创建账号"} + </h2> + <p className="text-sm text-muted-foreground mt-1"> + {mode === "login" ? "登录以使用收藏等功能" : "注册后即可收藏喜欢的工具和技能"} + </p> + </div> + + <form onSubmit={handleSubmit} className="space-y-4 mt-2"> + {mode === "register" && ( + <input + type="text" + placeholder="昵称(选填)" + value={name} + onChange={(e) => setName(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 text-foreground placeholder:text-muted-foreground/50" + /> + )} + <input + type="email" + placeholder="邮箱" + value={email} + onChange={(e) => setEmail(e.target.value)} + 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 text-foreground placeholder:text-muted-foreground/50" + /> + <input + type="password" + placeholder="密码" + value={password} + onChange={(e) => setPassword(e.target.value)} + required + minLength={6} + 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 text-foreground placeholder:text-muted-foreground/50" + /> + + {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 hover:bg-primary/90 transition-colors disabled:opacity-50 text-sm font-medium shadow-sm shadow-primary/20" + > + {loading ? "处理中..." : mode === "login" ? "登录" : "注册"} + </button> + </form> + + {socialLogin !== undefined && ( + <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> + )} + + {socialLogin} + + <p className="text-sm text-center text-muted-foreground"> + {mode === "login" ? "还没有账号?" : "已有账号?"} + <button + type="button" + onClick={() => { + setMode(mode === "login" ? "register" : "login"); + setError(""); + }} + className="ml-1 text-primary hover:underline font-medium" + > + {mode === "login" ? "去注册" : "去登录"} + </button> + </p> + </div> + ); +} \ No newline at end of file diff --git a/src/components/common/LoginModal.tsx b/src/components/common/LoginModal.tsx index 988a162..3d1d588 100644 --- a/src/components/common/LoginModal.tsx +++ b/src/components/common/LoginModal.tsx @@ -1,14 +1,11 @@ "use client"; -import { useState } from "react"; -import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import { Dialog, DialogContent, - DialogHeader, - DialogTitle, } from "@/components/ui/dialog"; +import { LoginForm } from "@/components/common/LoginForm"; interface Props { open: boolean; @@ -16,139 +13,21 @@ interface Props { } export function LoginModal({ open, onClose }: Props) { - 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 resetForm = () => { - setEmail(""); - setPassword(""); - setName(""); - setError(""); - setLoading(false); - }; - const handleClose = () => { - resetForm(); - setMode("login"); onClose(); }; - 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("自动登录失败,请手动登录"); - } else { - const result = await signIn("credentials", { - email, - password, - redirect: false, - }); - - if (result?.error) throw new Error("邮箱或密码错误"); - } - - handleClose(); - router.refresh(); - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } - }; - return ( <Dialog open={open} onOpenChange={(v) => !v && handleClose()}> <DialogContent className="sm:max-w-md border-border/60 bg-white shadow-[0_8px_32px_rgba(0,0,0,0.1)]"> - <DialogHeader> - <DialogTitle className="text-center text-xl"> - {mode === "login" ? "欢迎回来" : "创建账号"} - </DialogTitle> - </DialogHeader> - - <p className="text-center text-sm text-muted-foreground -mt-2"> - {mode === "login" ? "登录以使用收藏等功能" : "注册后即可收藏喜欢的工具和技能"} - </p> - - <form onSubmit={handleSubmit} className="space-y-4 mt-2"> - {mode === "register" && ( - <input - type="text" - placeholder="昵称(选填)" - value={name} - onChange={(e) => setName(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 text-foreground placeholder:text-muted-foreground/50" - /> - )} - <input - type="email" - placeholder="邮箱" - value={email} - onChange={(e) => setEmail(e.target.value)} - 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 text-foreground placeholder:text-muted-foreground/50" - /> - <input - type="password" - placeholder="密码" - value={password} - onChange={(e) => setPassword(e.target.value)} - required - minLength={6} - 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 text-foreground placeholder:text-muted-foreground/50" - /> - - {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 hover:bg-primary/90 transition-colors disabled:opacity-50 text-sm font-medium shadow-sm shadow-primary/20" - > - {loading ? "处理中..." : mode === "login" ? "登录" : "注册"} - </button> - </form> - - <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={() => { + handleClose(); + router.refresh(); + }} + /> </DialogContent> </Dialog> ); diff --git a/src/components/common/MemberBadge.tsx b/src/components/common/MemberBadge.tsx new file mode 100644 index 0000000..828c134 --- /dev/null +++ b/src/components/common/MemberBadge.tsx @@ -0,0 +1,28 @@ +import { getLevelInfo } from "@/lib/level-config"; +import { UserLevel } from "@prisma/client"; + +interface MemberBadgeProps { + level: UserLevel; + points: number; + size?: "sm" | "md" | "lg"; + showPoints?: boolean; +} + +export default function MemberBadge({ level, points, size = "sm", showPoints = true }: MemberBadgeProps) { + const levelInfo = getLevelInfo(points); + const sizes = { + sm: "px-2 py-0.5 text-[10px]", + md: "px-2.5 py-1 text-xs", + lg: "px-3 py-1.5 text-sm", + }; + + return ( + <span + className={`inline-flex items-center gap-1 rounded-full border font-medium ${levelInfo.bgColor} ${levelInfo.color} ${levelInfo.borderColor} ${sizes[size]}`} + > + <span>{levelInfo.icon}</span> + <span>{levelInfo.label}</span> + {showPoints && <span className="opacity-60">{points}分</span>} + </span> + ); +} diff --git a/src/components/common/ShareButton.tsx b/src/components/common/ShareButton.tsx new file mode 100644 index 0000000..395d9b1 --- /dev/null +++ b/src/components/common/ShareButton.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { Share2, Copy, CheckCircle, Link, MessageCircle } from "lucide-react"; + +interface ShareButtonProps { + url?: string; + title?: string; +} + +export default function ShareButton({ url, title }: ShareButtonProps) { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const shareUrl = url || (typeof window !== "undefined" ? window.location.href : ""); + const shareTitle = title || "追光AI - AI工具与技能发现平台"; + const canShare = typeof navigator !== "undefined" && "share" in navigator; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + const input = document.createElement("input"); + input.value = shareUrl; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleNativeShare = async () => { + if (navigator.share) { + try { + await navigator.share({ title: shareTitle, url: shareUrl }); + } catch (e) { + } + } else { + handleCopy(); + } + }; + + return ( + <> + <button + onClick={() => setOpen(!open)} + className="p-2 rounded-full transition-colors hover:bg-muted/50" + title="分享" + > + <Share2 className="w-4 h-4 text-muted-foreground" /> + </button> + + {open && ( + <> + <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} /> + <div className="absolute right-0 top-8 z-50 w-72 rounded-xl border border-border/50 bg-white shadow-xl overflow-hidden"> + <div className="px-4 py-3 border-b border-border/40"> + <h3 className="text-sm font-semibold">分享</h3> + </div> + + <div className="p-4 space-y-3"> + <button + onClick={handleNativeShare} + className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary text-white text-sm hover:bg-primary/90 transition-colors" + > + <Share2 className="w-4 h-4" /> + {canShare ? "分享到..." : "复制链接"} + </button> + + <div className="grid grid-cols-2 gap-2"> + <button + onClick={() => { + window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(shareTitle)}&url=${encodeURIComponent(shareUrl)}`, "_blank"); + }} + className="flex items-center gap-2 px-3 py-2 rounded-lg bg-sky-50 text-sky-700 border border-sky-200/60 text-xs hover:bg-sky-100 transition-colors" + > + <Link className="w-3.5 h-3.5" /> + Twitter + </button> + <button + onClick={() => { + window.open(`https://connect.qq.com/share?title=${encodeURIComponent(shareTitle)}&url=${encodeURIComponent(shareUrl)}`, "_blank"); + }} + className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-50 text-blue-700 border border-blue-200/60 text-xs hover:bg-blue-100 transition-colors" + > + <MessageCircle className="w-3.5 h-3.5" /> + QQ + </button> + </div> + + <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 border border-border/40"> + <input + readOnly + value={shareUrl} + className="flex-1 bg-transparent text-xs text-muted-foreground outline-none truncate" + /> + <button + onClick={handleCopy} + className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 flex-shrink-0" + > + {copied ? ( + <> + <CheckCircle className="w-3 h-3" /> + 已复制 + </> + ) : ( + <> + <Copy className="w-3 h-3" /> + 复制 + </> + )} + </button> + </div> + </div> + </div> + </> + )} + </> + ); +} diff --git a/src/components/forum/ForumBoard.tsx b/src/components/forum/ForumBoard.tsx new file mode 100644 index 0000000..9169970 --- /dev/null +++ b/src/components/forum/ForumBoard.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { MessageSquare, TrendingUp } from "lucide-react"; + +interface ForumBoardData { + id: number; + name: string; + slug: string; + description?: string; + icon?: string; + topicCount: number; +} + +interface ForumBoardProps { + board: ForumBoardData; +} + +const ICON_MAP: Record<string, string> = { + "general": "💬", + "ai-tools": "🤖", + "skills": "⚡", + "tech": "💻", + "tutorials": "📚", + "jobs": "💼", + "feedback": "📢", + "random": "🎲", +}; + +export default function ForumBoard({ board }: ForumBoardProps) { + const icon = board.icon || ICON_MAP[board.slug] || "📌"; + + return ( + <Link href={`/community/${board.slug}`}> + <div className="flex items-center gap-4 p-4 rounded-xl border border-border/40 bg-white transition-all hover:border-border/60 hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)] group"> + <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/5 text-2xl"> + {icon} + </div> + <div className="min-w-0 flex-1"> + <h3 className="text-sm font-semibold group-hover:text-primary transition-colors">{board.name}</h3> + {board.description && ( + <p className="mt-0.5 text-xs text-muted-foreground/60 truncate">{board.description}</p> + )} + </div> + <div className="flex items-center gap-4 text-xs text-muted-foreground/40 flex-shrink-0"> + <div className="flex items-center gap-1"> + <MessageSquare className="w-3.5 h-3.5" /> + <span>{board.topicCount}</span> + </div> + </div> + <svg className="w-4 h-4 text-muted-foreground/30 group-hover:text-primary group-hover:translate-x-0.5 transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> + </svg> + </div> + </Link> + ); +} diff --git a/src/components/forum/ForumEditor.tsx b/src/components/forum/ForumEditor.tsx new file mode 100644 index 0000000..cd5fd91 --- /dev/null +++ b/src/components/forum/ForumEditor.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; +import { useSession } from "next-auth/react"; +import { LoginModal } from "@/components/common/LoginModal"; + +interface ForumEditorProps { + onSubmit: (content: string) => void; + placeholder?: string; + maxLength?: number; + submitText?: string; + disabled?: boolean; +} + +export default function ForumEditor({ + onSubmit, + placeholder = "写下你的想法...", + maxLength = 10000, + submitText = "发布", + disabled = false, +}: ForumEditorProps) { + const { data: session, status } = useSession(); + const [content, setContent] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [showLoginModal, setShowLoginModal] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async () => { + if (status !== "authenticated") { + setShowLoginModal(true); + return; + } + + const trimmed = content.trim(); + if (!trimmed) { + setError("内容不能为空"); + return; + } + if (trimmed.length > maxLength) { + setError(`内容不能超过${maxLength}个字符`); + return; + } + + setSubmitting(true); + setError(""); + + try { + await onSubmit(trimmed); + setContent(""); + } catch (err) { + setError(err instanceof Error ? err.message : "提交失败"); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + <div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]"> + <textarea + value={content} + onChange={(e) => { + setContent(e.target.value); + if (error) setError(""); + }} + placeholder={placeholder} + rows={4} + maxLength={maxLength} + disabled={disabled} + className="w-full resize-none rounded-t-xl border-0 bg-transparent px-4 py-3 text-sm focus:outline-none focus:ring-0 placeholder:text-muted-foreground/40 disabled:opacity-50" + /> + + <div className="flex items-center justify-between border-t border-border/40 px-4 py-2"> + <div className="flex items-center gap-3"> + <span className="text-xs text-muted-foreground/50"> + {content.length}/{maxLength} + </span> + {error && ( + <span className="text-xs text-destructive">{error}</span> + )} + </div> + <button + onClick={handleSubmit} + disabled={submitting || !content.trim() || disabled} + className="rounded-lg bg-primary text-white text-sm px-4 py-2 hover:bg-primary/90 disabled:opacity-50 transition-colors shadow-sm shadow-primary/20" + > + {submitting ? "发布中..." : submitText} + </button> + </div> + </div> + + <LoginModal open={showLoginModal} onClose={() => setShowLoginModal(false)} /> + </> + ); +} diff --git a/src/components/forum/ForumTopicCard.tsx b/src/components/forum/ForumTopicCard.tsx new file mode 100644 index 0000000..c6ca46e --- /dev/null +++ b/src/components/forum/ForumTopicCard.tsx @@ -0,0 +1,116 @@ +"use client"; + +import Link from "next/link"; +import { MessageSquare, TrendingUp, Clock, Pin } from "lucide-react"; +import MemberBadge from "@/components/common/MemberBadge"; +import { UserLevel } from "@prisma/client"; + +interface TopicUser { + id: number; + name: string; + avatarUrl?: string; + level: UserLevel; + points: number; +} + +interface TopicCategory { + name: string; + slug: string; + icon?: string; +} + +interface Topic { + id: number; + title: string; + slug: string; + isPinned: boolean; + isLocked: boolean; + viewCount: number; + replyCount: number; + lastReplyAt: string | null; + createdAt: string; + category: TopicCategory; + user: TopicUser; +} + +interface ForumTopicCardProps { + topic: Topic; +} + +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); + + if (seconds < 60) return "刚刚"; + if (minutes < 60) return `${minutes}分钟前`; + if (hours < 24) return `${hours}小时前`; + if (days < 30) return `${days}天前`; + return date.toLocaleDateString("zh-CN"); +} + +export default function ForumTopicCard({ topic }: ForumTopicCardProps) { + const initial = topic.user.name.charAt(0).toUpperCase(); + + return ( + <Link href={`/community/topic/${topic.id}`}> + <div 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 shadow-[0_1px_2px_rgba(0,0,0,0.04)] group"> + {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 flex-wrap"> + {topic.isPinned && ( + <span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full"> + <Pin className="w-2.5 h-2.5" /> + 置顶 + </span> + )} + {topic.isLocked && ( + <span className="inline-flex items-center gap-0.5 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 group-hover:text-primary transition-colors truncate"> + {topic.title} + </h3> + </div> + + <div className="mt-1.5 flex items-center gap-3 flex-wrap text-xs text-muted-foreground/50"> + <span className="inline-flex items-center gap-1"> + <span className={`px-1.5 py-0.5 rounded bg-muted/30 text-[10px]`}>{topic.category.name}</span> + </span> + <span>{topic.user.name}</span> + <MemberBadge level={topic.user.level} points={topic.user.points} size="sm" showPoints={false} /> + <span className="flex items-center gap-1"> + <Clock className="w-3 h-3" /> + {getTimeAgo(new Date(topic.createdAt))} + </span> + <span className="flex items-center gap-1 ml-auto"> + <MessageSquare className="w-3 h-3" /> + {topic.replyCount} + </span> + <span className="flex items-center gap-1"> + <TrendingUp className="w-3 h-3" /> + {topic.viewCount} + </span> + </div> + + {topic.lastReplyAt && ( + <p className="mt-1 text-[10px] text-muted-foreground/30"> + 最后回复: {getTimeAgo(new Date(topic.lastReplyAt))} + </p> + )} + </div> + </div> + </Link> + ); +} diff --git a/src/components/forum/NotificationBell.tsx b/src/components/forum/NotificationBell.tsx new file mode 100644 index 0000000..d28e543 --- /dev/null +++ b/src/components/forum/NotificationBell.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { useSession } from "next-auth/react"; +import { Bell } from "lucide-react"; +import { LoginModal } from "@/components/common/LoginModal"; + +interface Notif { + id: number; + type: string; + title: string; + content?: string; + link?: string; + isRead: boolean; + createdAt: string; +} + +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); + + if (seconds < 60) return "刚刚"; + if (minutes < 60) return `${minutes}分钟前`; + if (hours < 24) return `${hours}小时前`; + if (days < 7) return `${days}天前`; + return date.toLocaleDateString("zh-CN"); +} + +export default function NotificationBell() { + const { data: session, status } = useSession(); + const [open, setOpen] = useState(false); + const [notifications, setNotifications] = useState<Notif[]>([]); + const [unreadCount, setUnreadCount] = useState(0); + const [showLoginModal, setShowLoginModal] = useState(false); + const ref = useRef<HTMLDivElement>(null); + + const fetchNotifications = async () => { + try { + const res = await fetch("/api/notifications?pageSize=5"); + if (res.ok) { + const data = await res.json(); + setNotifications(data.notifications || []); + setUnreadCount(data.unreadCount || 0); + } + } catch (e) { + console.error("Failed to fetch notifications:", e); + } + }; + + useEffect(() => { + if (status === "authenticated") { + fetchNotifications(); + } + }, [status]); + + useEffect(() => { + if (!open) return; + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open]); + + const handleClick = () => { + if (status !== "authenticated") { + setShowLoginModal(true); + return; + } + setOpen(!open); + if (!open && unreadCount > 0) { + fetch("/api/notifications", { method: "PUT" }).then(() => { + setUnreadCount(0); + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); + }); + } + }; + + const handleNotifClick = (notif: Notif) => { + setOpen(false); + if (notif.link) { + window.location.href = notif.link; + } + }; + + return ( + <> + <div className="relative" ref={ref}> + <button + onClick={handleClick} + className="relative p-2 rounded-full transition-colors hover:bg-muted/50" + > + <Bell className="w-5 h-5 text-muted-foreground" /> + {unreadCount > 0 && ( + <span className="absolute top-1 right-1 h-4 w-4 rounded-full bg-red-500 text-[10px] font-bold text-white flex items-center justify-center"> + {unreadCount > 9 ? "9+" : unreadCount} + </span> + )} + </button> + + {open && ( + <div className="absolute right-0 mt-2 w-80 rounded-xl border border-border/50 bg-white shadow-xl z-50 overflow-hidden"> + <div className="px-4 py-3 border-b border-border/40"> + <h3 className="text-sm font-semibold">通知</h3> + </div> + + {notifications.length === 0 ? ( + <div className="py-8 text-center"> + <p className="text-xs text-muted-foreground/50">暂无通知</p> + </div> + ) : ( + <div className="max-h-80 overflow-y-auto"> + {notifications.map((notif) => ( + <button + key={notif.id} + onClick={() => handleNotifClick(notif)} + className={`w-full text-left px-4 py-3 border-b border-border/30 transition-colors hover:bg-muted/20 ${ + !notif.isRead ? "bg-primary/[0.02]" : "" + }`} + > + <p className={`text-xs ${!notif.isRead ? "font-medium" : ""}`}>{notif.title}</p> + {notif.content && ( + <p className="mt-0.5 text-[10px] text-muted-foreground/50 truncate">{notif.content}</p> + )} + <p className="mt-1 text-[10px] text-muted-foreground/30">{getTimeAgo(new Date(notif.createdAt))}</p> + </button> + ))} + </div> + )} + + <div className="px-4 py-2 border-t border-border/40"> + <button + onClick={() => { + setOpen(false); + window.location.href = "/user/notifications"; + }} + className="w-full text-center text-xs text-primary hover:text-primary/80" + > + 查看全部通知 + </button> + </div> + </div> + )} + </div> + + <LoginModal open={showLoginModal} onClose={() => setShowLoginModal(false)} /> + </> + ); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 54e2f54..9767950 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -4,14 +4,17 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useState, useEffect } from "react"; import { useSession, signOut } from "next-auth/react"; -import { Heart, User, LogOut } from "lucide-react"; +import { Heart, User, LogOut, MessageCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; +import NotificationBell from "@/components/forum/NotificationBell"; +import MemberBadge from "@/components/common/MemberBadge"; const navLinks = [ { label: "AI工具", href: "/", icon: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" }, { label: "AI技能", href: "/skills", icon: "M13 10V3L4 14h7v7l9-11h-7z" }, { label: "AI评测", href: "/reviews", icon: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" }, { label: "AI日报", href: "/daily", icon: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" }, + { label: "社区", href: "/community", icon: "M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v2l-3-3H6a2 2 0 01-2-2v-2l3-3h10z" }, ]; export default function Header() { @@ -110,6 +113,7 @@ export default function Header() { {status === "authenticated" && session?.user ? ( <div className="hidden items-center gap-3 sm:flex"> + <NotificationBell /> <Link href="/user/favorites" className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground" @@ -126,6 +130,9 @@ export default function Header() { {session.user.name || session.user.email} </span> </Link> + {session.user.level && ( + <MemberBadge level={session.user.level} points={session.user.points || 0} size="sm" showPoints={false} /> + )} <button onClick={() => signOut({ callbackUrl: "/" })} className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground" @@ -217,6 +224,14 @@ export default function Header() { <div className="mt-2 border-t border-border/60 pt-2"> {status === "authenticated" && session?.user ? ( <> + <Link + href="/community" + onClick={() => setMobileOpen(false)} + className="flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + > + <MessageCircle className="h-4 w-4" /> + 社区 + </Link> <Link href="/user/favorites" onClick={() => setMobileOpen(false)} diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts index 808d5c0..b1d2c76 100644 --- a/src/lib/admin-auth.ts +++ b/src/lib/admin-auth.ts @@ -2,15 +2,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "./auth"; import { NextResponse } from "next/server"; import { prisma } from "./prisma"; - -interface PermissionCache { - roleId: number; - permissionKeys: Set<string>; - timestamp: number; -} - -let _permissionCache: PermissionCache | null = null; -const CACHE_TTL = 30_000; +import { cache } from "react"; export async function getUserPermissionKeys(userId: number): Promise<Set<string>> { const user = await prisma.user.findUnique({ @@ -32,51 +24,61 @@ export async function getUserPermissionKeys(userId: number): Promise<Set<string> return new Set(keys); } -export async function requireAdmin() { +const cachedGetUserPermissionKeys = cache(getUserPermissionKeys); + +interface AdminSession { + userId: number; + userRole: string; + isAdmin: boolean; + permissionKeys: Set<string> | null; +} + +const getAdminSession = cache(async (): Promise<AdminSession | null> => { const session = await getServerSession(authOptions); - if (!session?.user) { + if (!session?.user) return null; + + const userId = (session.user as { id?: number; role?: string }).id; + const userRole = (session.user as { role?: string }).role || ""; + + if (userRole === "admin") { + return { userId: userId || 0, userRole, isAdmin: true, permissionKeys: null }; + } + + if (!userId) return null; + + const perms = await getUserPermissionKeys(userId); + + return { + userId, + userRole, + isAdmin: perms.size > 0, + permissionKeys: perms, + }; +}); + +export async function requireAdmin() { + const adminSession = await getAdminSession(); + + if (!adminSession) { return NextResponse.json({ error: "未登录" }, { status: 401 }); } - const userId = (session.user as { id?: number; role?: string }).id; - const userRole = (session.user as { role?: string }).role; - if (userRole === "admin") return null; - - if (userRole !== "admin" && userId) { - const user = await prisma.user.findUnique({ - where: { id: userId }, - include: { - assignedRole: { - include: { - permissions: { include: { permission: true } }, - }, - }, - }, - }); - - if (user?.assignedRole?.permissions.length) { - return null; - } - } + if (adminSession.isAdmin) return null; return NextResponse.json({ error: "无管理员权限" }, { status: 403 }); } export async function requirePermission(key: string) { - const response = await requireAdmin(); - if (response) return response; + const adminSession = await getAdminSession(); - const session = await getServerSession(authOptions); - const userId = (session!.user as { id?: number; role?: string }).id; - const userRole = (session!.user as { role?: string }).role; - - if (userRole === "admin") return null; - - if (userId) { - const perms = await getUserPermissionKeys(userId); - if (perms.has(key)) return null; + if (!adminSession) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); } + if (adminSession.userRole === "admin") return null; + + if (adminSession.permissionKeys?.has(key)) return null; + return NextResponse.json({ error: `缺少权限: ${key}` }, { status: 403 }); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 23ac13f..74c69cb 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -4,6 +4,22 @@ import GithubProvider from "next-auth/providers/github"; import { prisma } from "@/lib/prisma"; import bcrypt from "bcryptjs"; +const loginAttempts = new Map<string, { count: number; resetAt: number }>(); +const MAX_LOGIN_ATTEMPTS = 7; +const LOGIN_WINDOW_MS = 60_000; + +function checkLoginRateLimit(email: string): boolean { + const now = Date.now(); + const record = loginAttempts.get(email); + if (!record || now > record.resetAt) { + loginAttempts.set(email, { count: 1, resetAt: now + LOGIN_WINDOW_MS }); + return true; + } + if (record.count >= MAX_LOGIN_ATTEMPTS) return false; + record.count++; + return true; +} + export const authOptions = { providers: [ CredentialsProvider({ @@ -15,6 +31,10 @@ export const authOptions = { async authorize(credentials) { if (!credentials?.email || !credentials?.password) return null; + if (!checkLoginRateLimit(credentials.email)) { + throw new Error("登录尝试过于频繁,请1分钟后再试"); + } + const user = await prisma.user.findUnique({ where: { email: credentials.email }, }); @@ -27,11 +47,15 @@ export const authOptions = { ); if (!isValid) return null; + loginAttempts.delete(credentials.email); + return { id: user.id.toString(), email: user.email, name: user.name, role: user.role, + level: user.level, + points: user.points, }; }, }), @@ -79,6 +103,8 @@ export const authOptions = { if (user) { token.id = user.id; token.role = user.role; + token.level = user.level; + token.points = user.points; } return token; }, @@ -86,6 +112,8 @@ export const authOptions = { if (session.user) { session.user.id = token.id; session.user.role = token.role; + session.user.level = token.level; + session.user.points = token.points; } return session; }, @@ -95,6 +123,7 @@ export const authOptions = { }, session: { strategy: "jwt" as const, + maxAge: 30 * 24 * 60 * 60, }, secret: process.env.NEXTAUTH_SECRET, }; diff --git a/src/lib/level-config.ts b/src/lib/level-config.ts new file mode 100644 index 0000000..19d7ac2 --- /dev/null +++ b/src/lib/level-config.ts @@ -0,0 +1,94 @@ +import { UserLevel } from "@prisma/client"; + +const LEVEL_CONFIG = [ + { level: UserLevel.LV01, minPoints: 0, label: "LV1", icon: "⭐", color: "text-slate-600", bgColor: "bg-slate-50", borderColor: "border-slate-200/60" }, + { level: UserLevel.LV02, minPoints: 10, label: "LV2", icon: "⭐", color: "text-slate-600", bgColor: "bg-slate-50", borderColor: "border-slate-200/60" }, + { level: UserLevel.LV03, minPoints: 30, label: "LV3", icon: "⭐⭐", color: "text-amber-700", bgColor: "bg-amber-50", borderColor: "border-amber-200/60" }, + { level: UserLevel.LV04, minPoints: 60, label: "LV4", icon: "⭐⭐", color: "text-amber-700", bgColor: "bg-amber-50", borderColor: "border-amber-200/60" }, + { level: UserLevel.LV05, minPoints: 100, label: "LV5", icon: "⭐⭐⭐", color: "text-amber-700", bgColor: "bg-amber-50", borderColor: "border-amber-200/60" }, + { level: UserLevel.LV06, minPoints: 200, label: "LV6", icon: "⭐⭐⭐", color: "text-yellow-700", bgColor: "bg-yellow-50", borderColor: "border-yellow-200/60" }, + { level: UserLevel.LV07, minPoints: 350, label: "LV7", icon: "⭐⭐⭐⭐", color: "text-yellow-700", bgColor: "bg-yellow-50", borderColor: "border-yellow-200/60" }, + { level: UserLevel.LV08, minPoints: 600, label: "LV8", icon: "⭐⭐⭐⭐", color: "text-yellow-700", bgColor: "bg-yellow-50", borderColor: "border-yellow-200/60" }, + { level: UserLevel.LV09, minPoints: 1000, label: "LV9", icon: "⭐⭐⭐⭐⭐", color: "text-orange-600", bgColor: "bg-orange-50", borderColor: "border-orange-200/60" }, + { level: UserLevel.LV10, minPoints: 1500, label: "LV10", icon: "⭐⭐⭐⭐⭐", color: "text-orange-600", bgColor: "bg-orange-50", borderColor: "border-orange-200/60" }, + { level: UserLevel.LV11, minPoints: 2200, label: "LV11", icon: "🌟⭐", color: "text-rose-600", bgColor: "bg-rose-50", borderColor: "border-rose-200/60" }, + { level: UserLevel.LV12, minPoints: 3000, label: "LV12", icon: "🌟⭐", color: "text-rose-600", bgColor: "bg-rose-50", borderColor: "border-rose-200/60" }, + { level: UserLevel.LV13, minPoints: 4000, label: "LV13", icon: "🌟🌟⭐", color: "text-rose-700", bgColor: "bg-rose-50", borderColor: "border-rose-200/60" }, + { level: UserLevel.LV14, minPoints: 5200, label: "LV14", icon: "🌟🌟⭐", color: "text-violet-700", bgColor: "bg-violet-50", borderColor: "border-violet-200/60" }, + { level: UserLevel.LV15, minPoints: 6600, label: "LV15", icon: "🌟🌟🌟⭐", color: "text-violet-700", bgColor: "bg-violet-50", borderColor: "border-violet-200/60" }, + { level: UserLevel.LV16, minPoints: 8200, label: "LV16", icon: "🌟🌟🌟⭐", color: "text-purple-700", bgColor: "bg-purple-50", borderColor: "border-purple-200/60" }, + { level: UserLevel.LV17, minPoints: 10000, label: "LV17", icon: "🌟🌟🌟🌟⭐", color: "text-purple-700", bgColor: "bg-purple-50", borderColor: "border-purple-200/60" }, + { level: UserLevel.LV18, minPoints: 12500, label: "LV18", icon: "🌟🌟🌟🌟⭐", color: "text-purple-700", bgColor: "bg-purple-50", borderColor: "border-purple-200/60" }, + { level: UserLevel.LV19, minPoints: 15500, label: "LV19", icon: "🌟🌟🌟🌟🌟⭐", color: "text-indigo-700", bgColor: "bg-indigo-50", borderColor: "border-indigo-200/60" }, + { level: UserLevel.LV20, minPoints: 20000, label: "LV20", icon: "👑", color: "text-indigo-700", bgColor: "bg-indigo-50", borderColor: "border-indigo-200/60" }, +]; + +export const POINTS_RULES = { + comment: 5, + comment_like: 2, + forum_topic: 10, + forum_post: 5, + forum_post_like: 2, + review: 8, + daily_login: 1, + share: 10, +}; + +export const DAILY_LIMITS = { + comment: 50, + forum_post: 30, + review: 20, + comment_like: 20, + forum_post_like: 20, + share: 50, +}; + +export function getLevelInfo(points: number) { + let current = LEVEL_CONFIG[0]; + let next: typeof LEVEL_CONFIG[0] | null = null; + + for (const cfg of LEVEL_CONFIG) { + if (points >= cfg.minPoints) { + current = cfg; + } + } + + const idx = LEVEL_CONFIG.findIndex((c) => c.level === current.level); + if (idx < LEVEL_CONFIG.length - 1) { + next = LEVEL_CONFIG[idx + 1]; + } + + return { + level: current.level, + label: current.label, + icon: current.icon, + color: current.color, + bgColor: current.bgColor, + borderColor: current.borderColor, + points, + nextLevel: next ? { + level: next.level, + label: next.label, + icon: next.icon, + minPoints: next.minPoints, + remaining: next.minPoints - points, + } : null, + progress: next + ? Math.min(((points - current.minPoints) / (next.minPoints - current.minPoints)) * 100, 100) + : 100, + }; +} + +export function getReasonLabel(reason: string): string { + const labels: Record<string, string> = { + comment: "发表评论", + comment_like: "评论被点赞", + forum_topic: "发布话题", + forum_post: "回复话题", + forum_post_like: "回复被点赞", + review: "发表评价", + daily_login: "每日登录", + share: "分享空间", + }; + return labels[reason] || reason; +} \ No newline at end of file diff --git a/src/lib/points-reward.ts b/src/lib/points-reward.ts new file mode 100644 index 0000000..fed35ff --- /dev/null +++ b/src/lib/points-reward.ts @@ -0,0 +1,112 @@ +import { prisma } from "@/lib/prisma"; +import { POINTS_RULES, DAILY_LIMITS, getLevelInfo, getReasonLabel } from "@/lib/level-config"; + +export { getLevelInfo, getReasonLabel }; + +async function getTodayEarned(userId: number, reason: string): Promise<number> { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(startOfDay); + endOfDay.setDate(endOfDay.getDate() + 1); + + const result = await prisma.pointsHistory.aggregate({ + where: { + userId, + reason, + createdAt: { + gte: startOfDay, + lt: endOfDay, + }, + }, + _sum: { + points: true, + }, + }); + + return result._sum.points || 0; +} + +export async function rewardPoints( + userId: number, + reason: keyof typeof POINTS_RULES, + detail?: string, + refType?: string, + refId?: number +): Promise<{ success: boolean; points: number; message: string }> { + const points = POINTS_RULES[reason]; + const dailyLimit = DAILY_LIMITS[reason as keyof typeof DAILY_LIMITS]; + + if (dailyLimit) { + const todayEarned = await getTodayEarned(userId, reason); + if (todayEarned >= dailyLimit) { + return { + success: false, + points: 0, + message: `今日${reason}积分已达上限(${dailyLimit})`, + }; + } + } + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: userId }, + data: { points: { increment: points } }, + }); + + await tx.pointsHistory.create({ + data: { + userId, + points, + reason, + detail: detail || null, + refType: refType || null, + refId: refId || null, + }, + }); + }); + + return { success: true, points, message: `+${points}积分` }; +} + +export async function getUserPointsHistory( + userId: number, + page = 1, + pageSize = 20 +) { + const [history, total] = await Promise.all([ + prisma.pointsHistory.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.pointsHistory.count({ where: { userId } }), + ]); + + return { history, total, page, pageSize }; +} + +export async function getUserStats(userId: number) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + points: true, + level: true, + comments: { select: { id: true } }, + forumTopics: { select: { id: true } }, + forumPosts: { select: { id: true } }, + reviews: { select: { id: true } }, + }, + }); + + if (!user) return null; + + return { + points: user.points, + level: getLevelInfo(user.points), + commentCount: user.comments.length, + topicCount: user.forumTopics.length, + postCount: user.forumPosts.length, + reviewCount: user.reviews.length, + }; +} \ No newline at end of file diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..b8cd2a2 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,13 @@ +const ipCounts = new Map<string, { count: number; resetAt: number }>(); + +export function rateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean { + const now = Date.now(); + const record = ipCounts.get(ip); + if (!record || now > record.resetAt) { + ipCounts.set(ip, { count: 1, resetAt: now + windowMs }); + return true; + } + if (record.count >= maxRequests) return false; + record.count++; + return true; +} \ No newline at end of file diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..1a98d8e --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; + +export const registerSchema = z.object({ + email: z.string().email("邮箱格式不正确"), + password: z.string().min(6, "密码至少6位").max(128, "密码最多128位"), + name: z.string().max(100, "昵称最多100字符").optional(), +}); + +export const loginSchema = z.object({ + email: z.string().email("邮箱格式不正确"), + password: z.string().min(1, "密码不能为空"), +}); + +export const paginationSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const toolImportSchema = z.object({ + tools: z.array(z.object({ + name: z.string().min(1, "名称不能为空"), + description: z.string().optional(), + websiteUrl: z.string().url().optional().or(z.literal("")), + logoUrl: z.string().optional(), + pricingModel: z.enum(["free", "freemium", "paid", "subscription"]).optional(), + pricingDetail: z.string().optional(), + features: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + })).min(1, "tools 数组不能为空"), + categoryId: z.number().int().positive("categoryId 必须为正整数"), +}); + +export const skillImportSchema = z.object({ + skills: z.array(z.object({ + name: z.string().min(1, "名称不能为空"), + description: z.string().optional(), + sourceUrl: z.string().optional(), + sourceType: z.string().optional(), + rating: z.number().min(0).max(5).optional(), + features: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + })).min(1, "skills 数组不能为空"), + categoryId: z.number().int().positive("categoryId 必须为正整数"), +}); + +export const newsImportSchema = z.object({ + items: z.array(z.object({ + title: z.string().min(1, "标题不能为空"), + summary: z.string().optional(), + detail: z.string().optional(), + category: z.string().optional(), + sourceUrl: z.string().optional(), + hotScore: z.number().min(0).max(100).optional(), + })).min(1, "items 数组不能为空"), +}); + +export const checkTypeSchema = z.object({ + type: z.enum(["published", "pending"], { message: "type 必须为 published 或 pending" }), +}); + +export const profileUpdateSchema = z.object({ + name: z.string().max(100, "昵称最多100字符").optional().nullable(), +}); + +export const favoriteSchema = z.object({ + toolId: z.number().int().positive().optional(), + skillId: z.number().int().positive().optional(), +}).refine((data) => data.toolId || data.skillId, { + message: "必须提供 toolId 或 skillId", +}); + +export const historySchema = z.object({ + toolId: z.number().int().positive().optional(), + skillId: z.number().int().positive().optional(), +}).refine((data) => data.toolId || data.skillId, { + message: "必须提供 toolId 或 skillId", +}); + +export const taskRunSchema = z.object({ + taskKey: z.string().min(1, "taskKey 不能为空"), +}); + +export const roleCreateSchema = z.object({ + name: z.string().min(1, "角色名称不能为空"), + slug: z.string().min(1, "角色标识不能为空"), + permissionIds: z.array(z.number().int().positive()).optional(), +}); + +export const categoryCreateSchema = z.object({ + name: z.string().min(1, "名称不能为空"), + slug: z.string().optional(), + icon: z.string().optional(), + parentId: z.number().int().positive().optional().nullable(), + sortOrder: z.number().int().min(0).optional(), +}); + +export const skillCategoryCreateSchema = z.object({ + name: z.string().min(1, "名称不能为空"), + slug: z.string().min(1, "Slug不能为空"), + icon: z.string().optional(), + parentId: z.number().int().positive().optional().nullable(), + sortOrder: z.number().int().min(0).optional(), +}); + +export const systemConfigUpdateSchema = z.record(z.string(), z.unknown()); + +export type RegisterInput = z.infer<typeof registerSchema>; +export type LoginInput = z.infer<typeof loginSchema>; +export type ToolImportInput = z.infer<typeof toolImportSchema>; +export type SkillImportInput = z.infer<typeof skillImportSchema>; +export type NewsImportInput = z.infer<typeof newsImportSchema>; +export type CheckTypeInput = z.infer<typeof checkTypeSchema>; \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 54b9580..42039c2 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -10,12 +10,20 @@ export default withAuth( if (!token) { return NextResponse.redirect(new URL("/admin/login", req.url)); } + const role = (token as { role?: string }).role; + if (role !== "admin" && role !== "vip") { + return NextResponse.redirect(new URL("/admin/login?error=unauthorized", req.url)); + } } if (path.startsWith("/api/admin")) { if (!token) { return NextResponse.json({ error: "未登录" }, { status: 401 }); } + const role = (token as { role?: string }).role; + if (role !== "admin" && role !== "vip") { + return NextResponse.json({ error: "无管理员权限" }, { status: 403 }); + } } }, { diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..357f0ba --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,29 @@ +import { DefaultSession } from "next-auth"; +import { UserLevel } from "@prisma/client"; + +declare module "next-auth" { + interface Session { + user: { + id: string; + role: string; + level?: UserLevel; + points?: number; + } & DefaultSession["user"]; + } + + interface User { + id: string; + role: string; + level?: UserLevel; + points?: number; + } +} + +declare module "next-auth/jwt" { + interface JWT { + id: string; + role: string; + level?: UserLevel; + points?: number; + } +} \ No newline at end of file diff --git a/tmp-deploy.sh b/tmp-deploy.sh deleted file mode 100644 index 6057e42..0000000 --- a/tmp-deploy.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -cd /home/ubuntu/zhuiguang-ai -git pull origin master -npm install -npx prisma generate -NODE_OPTIONS="--max-old-space-size=4096" npm run build -pm2 restart zhuiguang-ai -echo "=== DONE ==="